Skip to main content
The GCS half of a plugin runs inside a sandboxed iframe served by Mission Control. Same envelope shape as the agent half, different transport (postMessage instead of msgpack-over-Unix-socket), same capability re-resolution. One slot is the exception: flight.skill registers a cockpit Skill into the Skill Bar registry rather than mounting an iframe. Every other slot in this page is iframe-backed.

Iframe sandbox

Every GCS plugin gets one iframe per host instance. The iframe is mounted with the strictest sandbox flags that still allow JavaScript and lazy-load:
The Content Security Policy is delivered as a response header on the plugin document, not as an iframe attribute. The header is covered in the next section. The flags that are deliberately absent:
  • allow-same-origin is not set. The iframe runs in a null origin. It cannot read host cookies, localStorage, or the host’s document.
  • allow-top-navigation is not set. The plugin cannot redirect the parent window.
  • allow-forms is not set. The plugin renders forms using its own React tree, not browser-native form submission.
  • allow-popups is not set. No new windows.
The plugin can run JavaScript, fetch its own bundle, render its own UI, and call back to the host via postMessage. That is all.

CSP

The host serves each plugin from /plugins/<id>/ with a per-plugin Content Security Policy:
connect-src 'self' means a plugin cannot fetch from the public internet. If your plugin needs an outbound HTTP call, route it through the agent half with the network.outbound capability. The GCS half must not be your network egress point.

postMessage RPC envelope

Same shape as the agent IPC envelope, just delivered via window.parent.postMessage:
The token field carries a per-RPC capability token (base64-encoded JSON claims) when the host bridge runs with a token validator. The validator checks expiry, plugin id, agent id, capability membership, and signature before the call dispatches. When the bridge runs without a validator (legacy hosts and unit tests), the field is optional. The plugin posts to window.parent with targetOrigin set to the host origin. The host posts back to the iframe’s contentWindow with targetOrigin set to the null origin ("*", justified because the iframe is sandboxed and there is no privileged origin to leak to). The SDK hides this. You write:
and the SDK builds the envelope, attaches version: 1, generates the id, awaits the correlated response, and throws HostError on error envelopes. For methods without a high-level facade, drop down to ctx.client.request(method, capability, args).

Capability tokens on the GCS side

Every privileged RPC carries capability in the envelope. The host bridge (running on the parent page) re-resolves the required capability from method and args via resolveRequiredCapability, then checks the granted set for that plugin. The plugin cannot lie its way past a missing grant; the host ignores the envelope’s capability field for authorization. When a token is present, its grantedCapabilities claim is the authoritative set, and the in-memory granted set must include the capability too, so a revocation applied in the GCS store takes effect before a fresh token is minted. A required capability outside the token claim returns capability_denied; one outside the in-memory set returns permission_denied. The bridge mirrors the agent-side logic exactly so plugin authors debug one model, not two.

The named UI slots

There are 12 slots. Nine are fleet-scoped. The other three (node.detail.tab, cockpit.panel, and flight.skill) are per-node scoped: their contribution is bound to the currently-selected node and is torn down and re-mounted when the operator switches nodes. A per-node contribution receives a capability token whose agentId claim matches the selected node, so cross-node RPCs are rejected at the bridge. flight.skill is the one slot that does not mount an iframe. The plugin registers a Skill keyed to the active drone; the cockpit’s arm and confirmation gates still apply on activation. Every other slot renders an iframe. Each iframe-backed slot has a contract: required props the host injects, the size and layout it lives in, the events it can hand back to its parent. Slot contracts live in @altnautica/plugin-sdk’s slot types. A plugin contributes to a slot by declaring a panels[] entry in the manifest:
Multiple plugins can target the same slot. The order field is a hint the host carries for sorting where a slot chooses to apply it.

Slot orchestrator

The host runs a slot orchestrator. It reads the enabled plugins from the install set, looks at each plugin’s contributes.panels list, and mounts the plugin’s bundle into the matching slot. Which panel renders in which slot is declared in the manifest, not chosen at runtime by the plugin. Before a contribution mounts, the slot applies a capability gate: a contribution only renders when its granted capabilities include the slot’s matching ui.slot.<id> capability. A contribution missing that grant is dropped, the operator gets a one-shot warning toast, and the denial is logged. The grant is fixed at install time, so the install record is the source of truth. The bundle’s single entry point is definePlugin. The SDK arranges the mount call once the iframe document is ready, handing your code a PluginContext and the plugin’s id and version:

Themeing

The host pushes a theme.changed event with a flat record of CSS variables on mount and on every theme toggle. Apply them in the plugin’s root:
The SDK does not auto-apply because plugins choose the granularity (full root, scoped panel, or none).

Error envelopes

The SDK throws HostError whose code is a machine-readable id. The host returns permission_denied when a call lacks its capability; the client itself raises timeout when the host does not respond in time and disposed when the client is torn down with calls in flight. Other codes are host-defined and arrive in the response envelope’s error.code. Branch on code, not on message.

Local iteration

During development, build the GCS half against the test harness in @altnautica/plugin-sdk/harness so you can drive RPC calls, inject telemetry, and assert on the envelopes your bundle sends without mounting it in the live GCS. See the TypeScript SDK page for the harness API.

See also