262 lines
20 KiB
Markdown
262 lines
20 KiB
Markdown
# 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`:
|
|
```json
|
|
{
|
|
"protocol_version": 1,
|
|
"request_id": "client-generated-id",
|
|
"target": { "window": "active" },
|
|
"action": { "kind": "tab.create", "params": {} }
|
|
}
|
|
```
|
|
Success response:
|
|
```json
|
|
{
|
|
"protocol_version": 1,
|
|
"request_id": "client-generated-id",
|
|
"response": { "status": "ok", "data": {} }
|
|
}
|
|
```
|
|
Error response:
|
|
```json
|
|
{
|
|
"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
|
|
```mermaid
|
|
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.
|