Files

20 KiB

Context

PRODUCT.md defines Galaxy Control (galaxyctrl) with an allowlisted catalog of exactly 80 actions, deterministic addressing across multiple running Galaxy app processes, and a simple enabled/disabled Galaxy Control setting. SECURITY.md is the normative security architecture. If this technical plan and SECURITY.md disagree, update the plan before implementing. The design is external-only: all callers are same-user processes. There is no inside-Galaxy/outside-Galaxy distinction, no verified-terminal invocation context, and no authenticated-user identity layer. Security relies on owner-only filesystem discovery, same-user Unix credential broker with kernel peer credentials, short-lived instance-bound exact-action credentials, loopback HTTP transport, and app-side enforcement.

Existing building blocks

  • crates/http_server/src/lib.rs runs a native-only loopback Axum server on fixed port 9277.
  • app/src/lib.rs registers that HTTP server and currently merges only installation-detection and profiling routers.
  • app/src/workspace/action.rs defines tab creation and workspace actions.
  • app/src/pane_group/mod.rs shows pane creation/splitting semantics.
  • app/src/settings/theme.rs and app/src/themes/theme_chooser.rs define theme settings behavior.
  • crates/galaxy_cli/src/lib.rs defines existing CLI/parser conventions and channel-specific command naming.
  • app/src/lib.rs routes CLI invocations into CLI execution before GUI launch.
  • script/macos/bundle and script/linux/bundle show wrapper-script packaging patterns.

Proposed changes

0. Security architecture dependency

Before implementing any local-control listener, CLI command, credential path, or action handler, the implementation must be checked against SECURITY.md. Required security gates:

  • Galaxy Control has a single setting: enabled or disabled.
  • The authoritative value lives in protected local storage, not ordinary preferences.
  • When disabled, no credentials are issued, no control requests are accepted, and discovery records contain no actionable endpoint.
  • When enabled, same-user processes may request exact-action credentials from the broker.
  • The broker authenticates the OS user through kernel peer credentials, not the calling application.
  • Every credential grants one exact action, is bound to the issuing instance, and has a short expiry.
  • The app bridge verifies the exact granted action before selector resolution or handler dispatch.
  • Close actions (window.close, tab.close, pane.close) flow through normal Galaxy close behavior so existing app warnings remain authoritative.
  • Input-staging commands never submit the buffer. There is no input.run action.
  • Terminal execution is idle-only, and interruption requires the exact active block ID immediately before ETX is emitted.
  • The Block, Auth, Drive, and History families are entirely absent from the 80-action catalog. Input is limited to input.insert and input.replace.

1. Protocol crate and stable envelope

Create a shared protocol crate used by both the app server and the galaxyctrl client. It defines:

  • A request protocol version for defensive schema guarding.
  • Discovery/health response types.
  • The 80-action ActionKind enum with implementation status metadata. The Block, Auth, Drive, and History families are entirely absent; Input is limited to input.insert and input.replace, with command control isolated in the Terminal family.
  • Selector types:
    • InstanceSelector: Active, Id(InstanceId), Pid(u32).
    • WindowSelector: Active, Id(WindowId), Index(u32), Title(String).
    • TabSelector: Active, Id(TabId), Index(u32), Title(String).
    • PaneSelector: Active, Id(PaneId), Index(u32).
    • SessionSelector: Active, Id(SessionId).
  • Opaque protocol-facing ID newtypes for instance/window/tab/pane/session identifiers.
  • Typed parameter payloads per action.
  • Success/error envelopes with stable machine-readable error codes from SECURITY.md. The protocol treats target IDs as opaque. Internal runtime IDs are implementation details. Request shape for tab.create:
{
  "protocol_version": 1,
  "request_id": "client-generated-id",
  "target": { "window": "active" },
  "action": { "kind": "tab.create", "params": {} }
}

Success response:

{
  "protocol_version": 1,
  "request_id": "client-generated-id",
  "response": { "status": "ok", "data": {} }
}

Error response:

{
  "protocol_version": 1,
  "request_id": "client-generated-id",
  "response": {
    "status": "error",
    "error": { "code": "missing_target", "message": "No active window is available", "details": null }
  }
}

Error codes include: local_control_disabled, unauthorized_local_client, insufficient_permissions, ambiguous_instance, ambiguous_target, stale_target, missing_target, invalid_request, invalid_selector, invalid_params, unsupported_action, not_allowlisted, target_state_conflict, no_instance, protocol_version_unsupported, transport_unavailable, bridge_unavailable, internal.

2. Per-process discovery

Keep the existing fixed-port 9277 HTTP behavior intact. Add a separate local-control listener per process. Design:

  • Each Galaxy process creates a random opaque instance_id at startup.
  • Each process binds a loopback control listener on an ephemeral port.
  • Each process writes a discovery record into a secure per-user directory when Galaxy Control is enabled.
  • The record contains: instance_id, PID, channel/build metadata, control-listener endpoint, protocol version, start timestamp, and the filename of its instance-bound broker socket.
  • The record does not contain bearer tokens, raw credentials, or control authority.
  • The CLI passes its compiled channel into the shared discovery scan. The scan excludes records from other channels before returning candidates, rejects records whose endpoint is not exactly 127.0.0.1 or whose broker socket is not the expected filename, prunes stale same-channel records after health checks, and selects an instance using product selector rules. This same-channel boundary applies to listing, implicit selection, and explicit instance or PID selection.
  • When Galaxy Control is disabled, no discovery record is published. Default discovery directory: ~/.galaxy/local-control/. $XDG_RUNTIME_DIR/galaxy/local-control is preferred when available. On Unix, the directory is restricted to 0700 and records/sockets to 0600.

3. Credential broker

The broker is a Unix-domain socket inside the owner-only discovery directory, one per instance. It is the protected path from discovery metadata to a short-lived exact-action credential. Flow:

  1. Client reads the discovery record to learn the broker socket filename.
  2. Client connects to the socket.
  3. Broker calls the platform peer-credential API and verifies the connecting process's UID equals Galaxy's effective UID. The broker authenticates the OS user, not the calling application.
  4. Client sends a credential request naming one exact action.
  5. Broker checks that Galaxy Control is enabled and evaluates the requested action against the catalog.
  6. Broker mints a short-lived credential in memory: instance-bound, one exact action, short expiry, unique credential ID.
  7. Broker returns the credential to the client. Properties:
  • Credentials are never written to discovery records or disk.
  • There is no stored bootstrap secret or reusable token.
  • The broker evaluates current Galaxy Control state at issuance time.
  • Every credential is bound to exactly one action and one instance.
  • Issued credentials exist only in the app's process-local credential map and the client's memory.

4. Transport: loopback HTTP

The control listener is an instance-local Axum server bound to 127.0.0.1 on an ephemeral port. Before dispatch, the listener:

  • Rejects requests carrying an Origin header.
  • Requires the Host header to exactly match 127.0.0.1:<port>.
  • Requires a bearer credential present in the instance's process-local credential map.
  • Rejects missing, malformed, expired, or wrong-instance credentials.
  • Decodes the typed request only after transport authentication.
  • Passes the request and credential to the app bridge for exact-action enforcement.

5. App-side request bridge

The HTTP handler runs on a Tokio runtime thread. It cannot directly access GalaxyUI state because all UI state is single-threaded on the main app event loop. The bridge transfers work to the main thread.

Thread model

  • Tokio thread (HTTP handler): Owns the Axum router, validates transport credentials, deserializes the RequestEnvelope, hands the request to the bridge.
  • Main app thread: Owns all GalaxyUI entities (App, AppContext, views, models). All UI state reads and mutations happen here.
  • Bridge: Uses ModelSpawner<LocalControlBridge> to transfer a typed closure from the Tokio thread to the main thread, execute it with &mut ModelContext, and return the result.

Flow for tab.create

HTTP handler (Tokio thread)
  ├─ verify Galaxy Control is enabled
  ├─ verify credential existence, expiry, instance binding
  ├─ deserialize RequestEnvelope
  ├─ call bridge_spawner.spawn(move |bridge, ctx| { ... }).await
  └─ serialize ResponseEnvelope as JSON

LocalControlBridge::handle_request (main thread)
  ├─ verify the credential grants the exact requested action
  ├─ match request.action.kind
  │   └─ ActionKind::TabCreate
  │       ├─ resolve window: active window, or sole window, or missing_target/ambiguous_target
  │       ├─ ctx.views_of_type::<Workspace>(window_id)
  │       └─ workspace.update(ctx, |workspace, ctx| {
  │             workspace.handle_action(&WorkspaceAction::AddTerminalTab { ... }, ctx)
  │           })
  └─ return ResponseEnvelope::ok(request_id, ...)

Adding new action handlers

  1. Add an entry to the ActionKind catalog.
  2. Add a match arm in LocalControlBridge::handle_request.
  3. Verify the credential grants the exact action before selector resolution.
  4. Resolve selectors and dispatch onto existing app types through ctx.
  5. Return ResponseEnvelope::ok(...) or ResponseEnvelope::error(...).

6. Target resolution

Implement target resolution as a reusable component. Resolution order: instance → window → tab → pane → session. Selector behavior:

  • active resolves from current app focus state. For window-scoped mutations, a missing active window may fall back to the sole existing window.
  • Explicit opaque IDs must resolve exactly or return stale_target.
  • Index selectors resolve to a concrete opaque ID before execution.
  • Title/name selectors are exact by default and return ambiguous_target on multiple matches.
  • Session-scoped requests against non-terminal panes return target_state_conflict. Target resolution happens after credential authentication and exact-action verification.

7. Terminal command handler

The Terminal family resolves an existing pane/session through the shared target resolver and obtains the terminal view without changing focus.

  • terminal.status takes one short-lived TerminalModel lock, snapshots the active block ID, state, and elapsed running time, and produces a bounded command summary through the existing secret-obfuscating API.
  • terminal.execute validates the command before target resolution, then re-snapshots the active block and verifies that the staged input buffer is empty inside the terminal-view update. It drops the model lock before writing the command plus carriage return to TerminalView::write_to_pty; it never clears or overwrites staged input. Busy blocks and nonempty staged input return target_state_conflict.
  • terminal.interrupt re-snapshots immediately before the PTY write, compares the caller's expected block ID, verifies the block is still running, drops the model lock, and emits ETX. A different active block returns stale_target.

The handler never holds TerminalModel across another view update or PTY event, and does not acquire nested terminal-model locks. Because it uses the existing terminal PTY event path, Wormhole and other remote sessions retain their normal transport routing.

The hidden ACP --agent-safe MCP profile is pinned to opaque window, tab, and pane/session identifiers. It exposes concrete terminal tools instead of the generic catalog invoker and filters mutations according to the active ACP permission profile. Its deadline helper waits in the MCP subprocess, polls bounded status for the same block, and invokes the existing exact-block interrupt only if the deadline is reached while that block is still active.

8. Close behavior

The 3 close actions (window.close, tab.close, pane.close) flow through normal Galaxy close behavior after exact-action credential validation and deterministic target resolution. Existing warnings for unsaved files, running processes, shared sessions, and similar app state remain authoritative and may cancel the close.

9. CLI parsing and output

The CLI uses Galaxy's existing command-line libraries:

  • clap (derive) for argument parsing and subcommand trees.
  • serde / serde_json for JSON serialization.
  • clap_complete for shell completion generation.
  • OutputFormat enum (Pretty, Json, Ndjson, Text) shared from galaxy_cli. New subcommand types live in galaxy_cli::local_control and follow existing #[derive(Parser)] patterns.

10. CLI packaging

The shipped product is a bundled galaxyctrl wrapper script that calls the channel-specific Galaxy binary with a hidden --galaxyctrl flag:

  • macOS: A channel-specific wrapper in Resources/bin (galaxyctrl for Stable, otherwise galaxyctrl-<channel>).
  • Linux: Standalone release and validation archives include a galaxyctrl wrapper, and normal app packages install channel-specific Galaxy AI and Galaxy Control launchers.
  • Windows: Fails closed until authenticated broker transport is implemented. Startup: app/src/lib.rs recognizes --galaxyctrl before app launch and routes into galaxy_cli::local_control. The control-mode path initializes only command parsing, discovery, credential material, HTTP transport, and output formatting. It does not initialize GUI state, rendering, or terminal session models.

11. Feature flag

Gate behind FeatureFlag::GalaxyControlCli with Cargo feature galaxy_control_cli. When disabled:

  • No Galaxy Control settings page.
  • No LocalControlBridge, LocalControlServer, discovery records, broker sockets, or /v1/control endpoints.
  • The galaxyctrl wrapper returns a structured no_instance or feature-disabled error. When enabled:
  • Settings > Galaxy Control is rendered.
  • All local-control infrastructure starts when Galaxy Control is enabled (the default on internal dogfood channels; public channels require explicit opt-in through Settings > Galaxy Control or the Enable Galaxy Control Command Palette action).
  • resources/bundled/skills/galaxyctrl/SKILL.md teaches the built-in agent and users how to discover and invoke the allowlisted CLI surface.
  • The skill manager maps galaxyctrl to FeatureFlag::GalaxyControlCli through BundledSkillActivation. Both skill listing and direct bundled-skill reads enforce the activation state.

12. First slice: discovery + tab.create

The first implementation slice proves the end-to-end architecture:

  • Shared protocol types and error envelopes.
  • FeatureFlag::GalaxyControlCli and Cargo feature.
  • Settings > Galaxy Control page and matching Enable/Disable Galaxy Control Command Palette actions.
  • Protected local-only mode storage (channel-based default: enabled on dogfood channels, disabled on public channels).
  • Discovery registry and CLI instance selection.
  • galaxyctrl wrapper entrypoint with --galaxyctrl control-mode dispatch.
  • Per-process credential broker (Unix socket, peer credential check).
  • Loopback control listener.
  • App-side request bridge with ModelSpawner.
  • Exact-action credential issuance and enforcement.
  • app.ping, app.version, instance.list, and tab.create.
  • Structured success/error output in pretty and JSON formats.

13. Follow-up slices

After the first slice validates the architecture, add remaining catalog actions in family groups:

  • Window/tab mutations (including close through normal Galaxy close behavior).
  • Pane mutations (including close through normal Galaxy close behavior).
  • Session actions.
  • Input staging (insert and replace only, never submitting).
  • Terminal status, idle-only execution, and race-safe interruption.
  • Appearance/theme actions.
  • Settings reads and writes.
  • Surface availability, idempotent direct opens, and toggles.
  • File open intent. Each addition extends the ActionKind catalog, adds a handler, adds validation/tests, and adds CLI surface.

End-to-end flow

sequenceDiagram
    participant CLI as galaxyctrl
    participant REG as Discovery registry
    participant BROKER as Unix credential broker
    participant HTTP as Loopback control listener
    participant BRIDGE as App bridge
    participant UI as Galaxy app state

    CLI->>REG: Read same-channel instance discovery records
    CLI->>HTTP: Health/protocol check (app.ping)
    HTTP-->>CLI: Instance metadata
    CLI->>CLI: Resolve instance selector
    CLI->>BROKER: Connect to Unix socket
    BROKER->>BROKER: Verify peer UID == Galaxy UID
    BROKER->>BROKER: Check Galaxy Control == enabled
    CLI->>BROKER: Request credential for exact action
    BROKER-->>CLI: Short-lived instance-bound credential
    CLI->>HTTP: POST /v1/control with credential + typed request
    HTTP->>HTTP: Validate credential, reject if expired/invalid
    HTTP->>BRIDGE: Typed request + credential on main thread
    BRIDGE->>BRIDGE: Verify exact action matches credential
    BRIDGE->>BRIDGE: Resolve target selectors
    BRIDGE->>UI: Execute allowlisted handler
    UI-->>BRIDGE: Typed result
    BRIDGE-->>HTTP: Response envelope
    HTTP-->>CLI: JSON success/error

Testing

  • Catalog invariant: Every ActionKind with Implemented status has a parseable galaxyctrl CLI route, generated help/completion coverage, and an app-side bridge handler.
  • Galaxy Control gate: Disabled state rejects all credential requests and control requests. Enabled state allows them. Toggling invalidates outstanding credentials.
  • Credential model: Raw credentials never appear in discovery records. Credentials are instance-bound, action-bound, and short-lived. A credential for one action fails with insufficient_permissions for any other action.
  • Selector resolution: Tests for active, explicit ID, index, stale target, ambiguous target, missing target, and target-state-conflict cases.
  • Channel isolation: Discovery tests prove that a CLI scan excludes records published by other Galaxy channels.
  • Input staging: Only input.insert and input.replace exist. No input.run, input.get, input.clear, or input.mode.set. Tests prove no buffer submission occurs.
  • Terminal command control: Empty/NUL/oversized commands and busy targets are rejected. Interrupt tests cover matching, stale, and idle block IDs. The model lock is released before PTY writes.
  • Excluded families: The Block, Auth, Drive, and History families are entirely absent. The CLI rejects their command routes at parse time, the protocol rejects their action names at deserialization (invalid_request), and action.inspect/capability.inspect report non-catalog names as not_allowlisted.
  • Unsupported platforms: Windows fails closed with no fallback.
  • Action count: Tests verify the catalog contains exactly 80 uniformly authorized actions.
  • Bundled skill gate: Tests verify the galaxyctrl bundled skill is discoverable and readable only while FeatureFlag::GalaxyControlCli is enabled, without affecting unrelated bundled skills.

Risks and mitigations

  • Same-user residual risk: The broker authenticates the OS user, not the calling application. Any process running as the same user can request credentials. Mitigated by: protected enablement, short expiry, exact-action grants, app-side revalidation, normal Galaxy close warnings for close actions.
  • Browser-to-localhost: Mitigated by: no permissive CORS, Origin header rejection, Host header validation, credential requirement.
  • Fixed-port contention: Mitigated by: leaving 9277 undisturbed, using per-process ephemeral ports for control.
  • Terminal execution risk: Mitigated by: public-channel opt-in, exact-action grants, deterministic session targets, idle-only execution, bounded validated commands, secret-obfuscated status, and compare-and-swap interruption.
  • Heavyweight CLI startup: Mitigated by: --galaxyctrl routes before GUI launch, control-mode path initializes only what's needed.