Skip to content

Plugins phase 2 (atomic-plugins#54): installation identities, host-signed proxy calls, connection requests #1700

Open

Description

@michielbdejong

Tracks the atomic-server host side of phase 2 of ontola/atomic-plugins#54 (the "Proposal: integration-proxy auth redesign" comment, decisions 2, 8, 10 and 12). The proxy side is ontola/atomic-plugins#122, whose POST /runtimes {app, agent, label?} registers a node's app agent as a runtime of installation app. The owner signs that call, and the proxy refuses a runtime that equals the app or the owner.

Plugin work targets feat/plugin-debug.

Related PRs:

Pieces, in priority order

  • 1. App identity for installed catalog plugins (decision 2).
  • 2. App agents per (installation, node), registered as runtimes (decision 10).
    • JS activation already mints one agent per node, because every node that activates the Installation mints its own.
    • Still to build: the page registering it with POST /runtimes, signed with the user key, and labelled with the node's name (decision 3). Also removing it when the node or the installation goes away.
  • 3. Host signs ctx.http to the configured proxy origin (decisions 8 and 12). Done in Host-signed integration-proxy calls for installations, with a loopback exception for exactly the proxy (#1700) #1702 (stacked on Atomic request signatures v2: method, full URL and body hash (ontola/atomic-plugins#54) #1696):
    • --integration-proxy-url / ATOMIC_INTEGRATION_PROXY_URL, validated at boot;
    • requests to exactly that origin are signed with v2 as this node's app agent for the installation, inside with_app_agent. The plugin's own x-atomic-* headers are dropped. An installation with no agent on this node is refused;
    • a loopback SSRF exception for exactly that origin (scheme, host and port). The host connects to loopback itself, with no DNS lookup, and every other loopback or private target is still refused;
    • tell the plugin which connection it was delegated, e.g. next to ctx.config (open question 3);
    • a way for manifests to declare proxy operations without hard-coding the proxy origin (open question 4).
  • 4. Connection requests on installation resources (flow b):
    • a "needs a connection" run outcome;
    • pausing that installation's runs that need the platform, checked at run time and not only when a schedule is armed;
    • a notice in the data browser (on the plugin's page and on the drive);
    • clearing the request as a commit, which resumes the runs.
  • 5. Tauri atomic:// deep link for connect (decision 4): open /connect in the system browser, return to atomic://integrations/return, and redeem and delegate in the webview.
  • 6. Later: require v2 on atomic-server's own state-changing POSTs. Checked against feat/plugin-debug at bae5cdbe3 (server/src/routes.rs, server/src/handlers/website.rs):
    • Already accept v2 (Atomic request signatures v2: method, full URL and body hash (ontola/atomic-plugins#54) #1696) but don't require it yet: /app-agent (POST, DELETE) and /plugin-view-token.
    • Accept v2, then require it:
      • /plugin-secret (POST, DELETE);
      • /app-write;
      • /plugin-run;
      • /plugin-schedule (POST, DELETE);
      • /plugin-resume, /plugin-auto-apply and /plugin-trigger (POST);
      • /plugin-release, /plugin-release-package and /plugin-release-pin;
      • /plugin-sync-preview, -apply, -schedule and -status;
      • /plugin-connection-state and -checkpoint;
      • /plugin-external-read, -status, -confirm and -apply;
      • the 13 /integration-action* routes: -approve, -call, -cancel, -consumer-abandon, -consumers, -grant, -grants, -history, -history-compact, -proposals, -recovery-confirm, -recovery-inspect, and /integration-actions;
      • /upload;
      • /bind-drive, and POST on any resource;
      • /forget-peer;
      • /iroh-sync;
      • /website-hosting/assets/{hash} (POST), /website-hosting/deployments and /website-hosting/activate.
    • Out of scope: /commit, because commits carry their own signature, and PUT /blob/{hash}, which is content-addressed.
    • Prerequisite: JSON handlers must read the raw body bytes first, so the body hash matches what the server parses. Atomic request signatures v2: method, full URL and body hash (ontola/atomic-plugins#54) #1696 did this for /app-agent and /plugin-view-token.
    • If you touch lib/src/authentication_v2_vectors.json, keep it byte-identical: the proxy (atomic-plugins#122) verifies the same vectors.

Open questions (not settled by the #54 proposal)

  1. Which agent is an installation's app at the proxy? Delegations belong to the installation (decision 10), and POST /runtimes requires app ≠ runtime. A createApp app has one agent, whose key is on the node that created it. A JS Installation has a different agent on every node that activates it, and none of them is recorded in synced data. Options:

    • (a) the agent of the node where it was first activated, recorded on the Installation, with other nodes registering as runtimes (parity with createApp);
    • (b) a keyless installation id minted by the installing page and recorded on the Installation, with every node's agent a runtime.

    Either way: which property, and which agent signs the commit that records it?

  2. Who mints a node's agent? Flow (b) step 6 has the page mint it and hand it to the node. JS activation already mints it on the node. Keep node-minting, or move it to the page?

  3. How does the host learn the delegated connection id, so it can pass it next to ctx.config? The node never sees delegations. Options:

    • the page writes {platform: connection_id} onto the Installation when it delegates (not secret);
    • a proxy endpoint a runtime may call (today GET /connections lists only the signer's own connections).
  4. Manifest operations name absolute origins, but the proxy origin differs per node (http://localhost:… or https://localthought.io). Should a manifest declare proxy operations with a placeholder that the host resolves to the configured origin?

  5. wasip2 class extenders: their identity is PluginMeta.agent_secret, not Tree::AppAgent, so the host doesn't sign for them, and it refuses their requests to the proxy origin. Should they reach the proxy at all?

  6. Default proxy origin: the data browser defaults to its build-time proxy. Should the server default to the same, or stay unset (as in the PR)?

  7. Replay on atomic-server (piece 6): atomic-server keeps no v2 replay store, so a proof stays valid for 5 minutes, bound to its method, URL and body. Only the proxy accepts each proof once. Is that acceptable for these endpoints, or do they need a replay store?

  8. fix(plugins): refuse to run an app whose key is not on this node #1644 and installations: fix(plugins): refuse to run an app whose key is not on this node #1644's fail-closed check reads app identity from the drive's app-identities folder and the write list. JS Installations are in neither, so it doesn't see them. Should installation identities be recorded there too (this ties into question 1)?

  9. From the proposal's own "still to settle", for flow (b): which agent signs a connection request and its clearing, and whether it lives on the Installation or on a child resource the node's agent may write.

Activity

  1. michielbdejong commented on Sep 24, 2026

    @michielbdejong
    ContributorAuthor

    Provisional answers from the atomic-plugins side, pending Michiel's confirmation. They follow the #54 proposal and atomic-plugins#122's proxy model.

    1. The installation's app agent: option (b). The installing page generates a keypair and records the public atomic:agent:<pubkey> on the Installation (e.g. integration-app-agent), in a commit signed with the user key. It then discards the private key. Nobody ever signs as the app; delegations and frame capabilities name it, and every node's agent is a runtime. createApp apps keep their existing agent as their app.
    2. Who mints a node's agent: keep node-minting at activation. The node publishes its agent's public id on a child resource of the Installation that it's allowed to write. The page signs POST /runtimes {app, runtime, label} with the user key.
    3. The delegated connection id: the page writes {platform: connection_id} onto the Installation when it delegates. It isn't secret. The host passes it next to ctx.config.
    4. Absolute origins in manifests: use a placeholder. A manifest declares proxy: ["clockify"], and the plugin calls ctx.http("atomic-proxy:/clockify/api/v1/..."). The host resolves that to {configured origin}/proxy/{connection_id}/{platform}/....
    5. wasip2 class extenders: they don't reach the proxy for now. Keep refusing, and document it.
    6. The default proxy origin: leave it unset for now. Later it defaults to https://integrations.atomic.place, once atomic.place runs the proxy (atomic-saas#125; localthought.io is being retired).
    7. Replay protection: an in-memory replay cache per node, keyed on the signature digest and kept for the ±5 min window, for all v2-required state-changing endpoints.
    8. fix(plugins): refuse to run an app whose key is not on this node #1644: yes, record installation identities there too (the id from answer 1), so the fail-closed check sees them.
    9. Connection requests (flow b): the node's agent writes the request onto a child resource of the Installation that it's allowed to write. The user clears it with a commit signed by the user. Nodes watch that resource and resume.

    Work on 1–3 has started, stacked on #1702.

  2. michielbdejong commented on Sep 24, 2026

    @michielbdejong
    ContributorAuthor

    Decision (Michiel), for answer 6 (the default proxy origin): a self-hosted AtomicServer defaults to atomic.place's integration proxy (https://integrations.atomic.place), once that runs. The UI must make this visible wherever a user connects a service, along the lines of: "This traffic goes through atomic.place. If you want to run your own atomic-integration-proxy, follow the instructions here: …" The link needs a public self-hosting guide for the proxy (build, config, OAuth apps and callback URLs, and how to point --integration-proxy-url / VITE_ATOMIC_INTEGRATION_PROXY_URL at it). That guide has to live somewhere public, not in the internal atomic-saas repo.

    The other answers are engineering choices, and they go ahead as proposed above.

  3. michielbdejong commented on Sep 24, 2026

    @michielbdejong
    ContributorAuthor

    Follow-up gap: there are two separate settings for the proxy origin, and nothing keeps them in sync.

    If the two point at different proxies, the page's delegations and the node's signed requests hit different proxies. Proposed: the server publishes its configured proxy origin (e.g. on the drive or a server-info endpoint), and the data browser uses that as its default. The per-browser override is then only for development.

  4. michielbdejong commented on Sep 24, 2026

    @michielbdejong
    ContributorAuthor

    Self-hosting guide for the proxy: ontola/atomic-plugins#79, which will be at https://github.com/ontola/atomic-plugins/blob/main/integration-proxy/SELF_HOSTING.md (the path is fixed).

  5. michielbdejong commented on Sep 24, 2026

    @michielbdejong
    ContributorAuthor

    Progress on pieces 1–4:

    Engineering choices made along the way:

    • An atomic-proxy: request must also match a declared operation, not just a declared platform. That's the same rule as every other ctx.http call; otherwise declaring a platform would allow any write through the connection.
    • Revoking leaves the runtime child in place, so the page can still call DELETE /runtimes/{agent}. Cleaning it up afterwards is a follow-up.

    Still to do:

    • The page-side POST /runtimes call.
    • Test syncing runtime children between nodes: the child's genesis is saved locally without a rights check, and another node may refuse it through /commit.
    • The install review screen should list the proxy platforms.
  6. michielbdejong commented on Sep 25, 2026

    @michielbdejong
    ContributorAuthor

    Picking up pieces 5, 7 and 8 (Claude, atomic-server session), following the answers recorded above:

    Piece 9 (flow b connection requests) follows after that. Piece 6 (defaulting to integrations.atomic.place) waits until that host is live.

  7. michielbdejong commented on Sep 25, 2026

    @michielbdejong
    ContributorAuthor

    Pieces 5, 7 and 8 are up as stacked PRs on #1725: #1831 (5, wasip2 refusal), #1832 (7, v2 required plus a replay cache), #1833 (8, #1644's check sees installation identities).

    Engineering choice recorded for piece 7: /upload and POST on arbitrary resources keep accepting v1 for now.

    • /upload is multipart, and the browser can't hash the exact bytes it sends without rewriting the upload path.
    • POST on a resource is the general API that invites and third-party clients use.

    Both can move to v2 later, after an upload redesign and a client deprecation window.

    Replay cache:

    • per node, in memory;
    • each accepted signature is kept until its timestamp plus 5 minutes;
    • a replay gets 401;
    • at most 100k entries, and when full it answers 429 instead of dropping live entries;
    • it's cleared on restart.
  8. michielbdejong commented on Sep 25, 2026

    @michielbdejong
    ContributorAuthor

    Piece 9 (flow b) is up as PR #1836, on #1733.

    How it works:

    • ConnectionRequest records are children of the node's own InstallationRuntime, so the node's agent can write them under the normal rights rules.
    • ctx.http to an undelegated atomic-proxy: platform returns the verdict needsConnection.
    • Scheduled and triggered runs pause until the request is cleared and a connection exists.
    • The Installation page shows the request with Connect and "Use existing connection" buttons, and the plugin list marks it.
    • Only the named runtime agent may open a request, and only writers of the Installation may clear it.

    Also fixed: InstallationRuntime children were created without genesis or drive, so pages never saw server-published runtimes. Runtime registration from #1729 was silently doing nothing for them.

    Only not-connected is detected so far; revoked and expired need signals from the proxy. Piece 6 still waits for integrations.atomic.place.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestpluginShould probably be an Atomic Pluginsecurityserveratomic-server

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions