Repository navigation
Plugins phase 2 (atomic-plugins#54): installation identities, host-signed proxy calls, connection requests #1700
Description
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:
- Atomic request signatures v2: method, full URL and body hash (ontola/atomic-plugins#54) #1696: v2 request signatures (method, full URL, body hash). It adds
client::get_authentication_headers_v2. - Plugin frames: integration-proxy capabilities instead of the relay (ontola/atomic-plugins#54) #1697: frame capabilities instead of the relay, stacked on Atomic request signatures v2: method, full URL and body hash (ontola/atomic-plugins#54) #1696. It refuses connections for apps without an app agent.
- Install drive apps from the plugin catalog, with versioned updates #1689: installs drive apps from the catalog through
createApp, which gives them an app agent. - fix(plugins): refuse to run an app whose key is not on this node #1644: refuses to run an app whose key isn't on this node.
Pieces, in priority order
- 1. App identity for installed catalog plugins (decision 2).
- Finding: a JS Installation already gets one. Activating it mints an app agent on that node (
ensure_js_identityinserver/src/plugins/plugin.rs), stored inTree::AppAgentunder (drive, installation), andGET /app-agentreports it. Plugin frames: integration-proxy capabilities instead of the relay (ontola/atomic-plugins#54) #1697's refusal only hits drive apps without a key; Install drive apps from the plugin catalog, with versioned updates #1689 catalog apps go throughcreateApp. - Still missing: which agent is the installation's
appat the proxy (open question 1), and wasip2 installations, whose identity lives only inPluginMeta(open question 5).
- Finding: a JS Installation already gets one. Activating it mints an app agent on that node (
- 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.httpto 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 ownx-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/connectin the system browser, return toatomic://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-debugatbae5cdbe3(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-applyand/plugin-trigger(POST);/plugin-release,/plugin-release-packageand/plugin-release-pin;/plugin-sync-preview,-apply,-scheduleand-status;/plugin-connection-stateand-checkpoint;/plugin-external-read,-status,-confirmand-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/deploymentsand/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-agentand/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.
- Already accept v2 (Atomic request signatures v2: method, full URL and body hash (ontola/atomic-plugins#54) #1696) but don't require it yet:
Open questions (not settled by the #54 proposal)
-
Which agent is an installation's
appat the proxy? Delegations belong to the installation (decision 10), andPOST /runtimesrequiresapp≠ runtime. AcreateAppapp 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?
- (a) the agent of the node where it was first activated, recorded on the Installation, with other nodes registering as runtimes (parity with
-
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?
-
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 /connectionslists only the signer's own connections).
- the page writes
-
Manifest operations name absolute origins, but the proxy origin differs per node (
http://localhost:…orhttps://localthought.io). Should a manifest declare proxy operations with a placeholder that the host resolves to the configured origin? -
wasip2 class extenders: their identity is
PluginMeta.agent_secret, notTree::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? -
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)?
-
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?
-
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
writelist. JS Installations are in neither, so it doesn't see them. Should installation identities be recorded there too (this ties into question 1)? -
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
michielbdejong commentedon Sep 24, 2026
Provisional answers from the atomic-plugins side, pending Michiel's confirmation. They follow the #54 proposal and atomic-plugins#122's proxy model.
appagent: option (b). The installing page generates a keypair and records the publicatomic: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.createAppapps keep their existing agent as theirapp.POST /runtimes {app, runtime, label}with the user key.{platform: connection_id}onto the Installation when it delegates. It isn't secret. The host passes it next toctx.config.proxy: ["clockify"], and the plugin callsctx.http("atomic-proxy:/clockify/api/v1/..."). The host resolves that to{configured origin}/proxy/{connection_id}/{platform}/....https://integrations.atomic.place, once atomic.place runs the proxy (atomic-saas#125; localthought.io is being retired).Work on 1–3 has started, stacked on #1702.
michielbdejong commentedon Sep 24, 2026
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_URLat 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.
michielbdejong commentedon Sep 24, 2026
Follow-up gap: there are two separate settings for the proxy origin, and nothing keeps them in sync.
integration-proxy-url, with a build-time default fromVITE_INTEGRATION_PROXY_URL(currentlyhttps://localthought.io).--integration-proxy-url(Host-signed integration-proxy calls for installations, with a loopback exception for exactly the proxy (#1700) #1702).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.
michielbdejong commentedon Sep 24, 2026
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).
michielbdejong commentedon Sep 24, 2026
Progress on pieces 1–4:
integrationAppAgenton the Installation: a keyless id minted ininstallRelease, immutable once set, and available to plugins asctx.app.InstallationRuntimechild per node: written by that node's agent, and it names that agent.integrationConnections{platform: connection_id}, passed asctx.connections.proxy: [platform], andatomic-proxy:/platform/pathresolves to{proxy}/proxy/{connection_id}/{platform}/path, then gets signed as in Host-signed integration-proxy calls for installations, with a loopback exception for exactly the proxy (#1700) #1702.Engineering choices made along the way:
atomic-proxy:request must also match a declared operation, not just a declared platform. That's the same rule as every otherctx.httpcall; otherwise declaring a platform would allow any write through the connection.DELETE /runtimes/{agent}. Cleaning it up afterwards is a follow-up.Still to do:
POST /runtimescall./commit.proxyplatforms.michielbdejong commentedon Sep 25, 2026
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.
michielbdejong commentedon Sep 25, 2026
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:
/uploadand POST on arbitrary resources keep accepting v1 for now./uploadis multipart, and the browser can't hash the exact bytes it sends without rewriting the upload path.Both can move to v2 later, after an upload redesign and a client deprecation window.
Replay cache:
michielbdejong commentedon Sep 25, 2026
Piece 9 (flow b) is up as PR #1836, on #1733.
How it works:
ConnectionRequestrecords are children of the node's ownInstallationRuntime, so the node's agent can write them under the normal rights rules.ctx.httpto an undelegatedatomic-proxy:platform returns the verdictneedsConnection.Also fixed:
InstallationRuntimechildren were created without genesis ordrive, so pages never saw server-published runtimes. Runtime registration from #1729 was silently doing nothing for them.Only
not-connectedis detected so far;revokedandexpiredneed signals from the proxy. Piece 6 still waits for integrations.atomic.place.lint(lib): blank lines oxlint wants in plugin-manifest.ts