From d05dd49fd252be664485af83dd7cc1c711280669 Mon Sep 17 00:00:00 2001 From: Prince Pal <107296821+princepal9120@users.noreply.github.com> Date: Mon, 4 May 2026 06:15:11 +0530 Subject: [PATCH 01/48] fix: point OSS desktop entry at package launcher (#9424) From 14980d7d861e3a048c0d93b825d03b575966d174 Mon Sep 17 00:00:00 2001 From: Jeff Lloyd Date: Thu, 21 May 2026 13:18:36 -0400 Subject: [PATCH 02/48] Fix arity of request_ambient_agent_task_id_for_hidden_child test call (#11483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Fix a pre-existing compile error in `app/src/pane_group/mod_tests.rs` that breaks every Warp CI job that builds the `warp` test binary (`Formatting + Clippy (MacOS / Linux / Windows)` and the `Run *Tests` jobs). The call site in `test_entering_remote_parent_agent_view_lazily_restores_local_hidden_child_pane` was passing 4 arguments to `request_ambient_agent_task_id_for_hidden_child`, but the helper at `app/src/pane_group/mod_tests.rs:574` only takes 3 (`panes`, `child_pane_id`, `ctx`). The stray `local_child_conversation_id` argument was added in #10794 and isn't part of the signature, so `rustc` rejects it with E0061. The two other in-tree call sites already use the correct 3-arg shape. This is currently masking CI on every open PR that triggers Warp CI (e.g. #11479's `dependabot/cargo/openssl-0.10.80`). The fix is the exact removal `rustc` itself suggests. Diagnosis context: [conversation](https://staging.warp.dev/conversation/7e6b9f40-55c9-4235-bc3c-1a5424213cc4), [plan](https://staging.warp.dev/drive/notebook/kmgJ7rLA29QP3bvmIzBNS6). ## Linked Issue None — this is a bare compile-fix for a regression in test code introduced by #10794. ## Testing Validated locally in a worktree off `origin/master`: - `cargo fmt --all -- --check` — clean. - `cargo check -p warp --tests` — succeeds (previously failed with E0061). - `cargo clippy --locked -p warp --tests --all-targets -- -D warnings` — succeeds. (Workspace-wide clippy hit an unrelated local `command-signatures-v2` build-script error tied to a `corepack`/`yarn` mismatch in my environment, not introduced by this change; CI will exercise the full workspace.) - [x] I have manually tested my changes locally with `cargo check`/`cargo clippy` (no runtime behavior changes — this is a test-only call-site fix). ## Agent Mode - [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode Co-Authored-By: Oz Co-authored-by: Oz From 2fea28bedd48f847fc3c169504955634c7c98475 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd Date: Thu, 21 May 2026 20:11:04 -0600 Subject: [PATCH 03/48] Add warpctrl product and tech specs Product spec defines the allowlisted local-control CLI with hierarchical selectors, action catalog, 4-tier classification model (read-only metadata, read-only terminal data, non-destructive mutation, destructive/high-risk), differentiated agent vs human permission policies, protocol-first settings, and future extensibility for files and Warp Drive objects. Tech spec covers protocol envelope, per-process discovery, local auth, ModelSpawner bridge architecture, target resolution, CLI library constraints (clap/serde matching Oz CLI), and packaging model. README documents packaging, install/invocation, end-to-end test flow, security model, and authenticated request flow. Co-Authored-By: Oz --- specs/warp-control-cli/PRODUCT.md | 227 +++++++++++++++++++++ specs/warp-control-cli/README.md | 92 +++++++++ specs/warp-control-cli/TECH.md | 322 ++++++++++++++++++++++++++++++ 3 files changed, 641 insertions(+) create mode 100644 specs/warp-control-cli/PRODUCT.md create mode 100644 specs/warp-control-cli/README.md create mode 100644 specs/warp-control-cli/TECH.md diff --git a/specs/warp-control-cli/PRODUCT.md b/specs/warp-control-cli/PRODUCT.md new file mode 100644 index 0000000000..884471b05c --- /dev/null +++ b/specs/warp-control-cli/PRODUCT.md @@ -0,0 +1,227 @@ +# Summary +Warp should ship an allowlisted standalone local control CLI binary, provisionally named `warpctrl`, that lets developers script the same classes of user-visible actions they can already perform inside the running app: manipulating windows, tabs, panes, sessions, appearance, settings, and selected UI surfaces. The CLI should operate against one or more already-running local Warp app processes through a stable machine protocol, with deterministic target selection and clear errors when a process or target is ambiguous. +## Problem +Warp already has rich interactive actions, but they are primarily reachable through UI, keybindings, menus, or deeplinks. Developers cannot reliably compose those same actions into shell scripts, demos, automation, or agent workflows, and there is no general local protocol for addressing a specific running Warp instance, window, pane, or session. +## Goals / Non-goals +Goals: +- Provide a first-class, scriptable standalone `warpctrl` binary for controlling running Warp app processes. +- Keep CLI startup lightweight by avoiding GUI-app startup or full terminal initialization for routine control commands. +- Keep the surface allowlisted and finite instead of exposing arbitrary internal actions. +- Make targeting explicit and deterministic across multiple Warp processes, windows, tabs, panes, and sessions. +- Support both ergonomic active-target defaults and precise selectors for automation. +- Define a complete protocol/catalog up front, while shipping the implementation incrementally. +Non-goals: +- Replacing the Oz CLI or mixing cloud-agent management into this CLI. +- Exposing every internal app action, debug action, developer-only helper, or privileged state mutation. +- Treating the CLI as a general RPC escape hatch into Warp internals. +- Requiring developers or automation to spawn the Warp GUI executable in CLI mode for ordinary control commands. +- Requiring the first implementation slice to ship every action in the catalog. +## Behavior +1. The Warp control CLI operates only on running local Warp app processes. If no compatible Warp process is available, the CLI exits non-zero with a clear “no running Warp instance found” error. +2. The CLI exposes only explicitly allowlisted actions. Unknown action names, unsupported parameter combinations, or requests for non-allowlisted capabilities fail with structured errors; they are never forwarded to arbitrary internal dispatch. +3. Every successful mutating request identifies: + - The Warp process instance that executed it. + - The resolved target, when the action addresses a window, tab, pane, or session. + - A success payload suitable for JSON output. +4. Every failure identifies: + - A stable machine-readable error code. + - A human-readable explanation. + - Any selector that was ambiguous, missing, stale, unsupported, or invalid. +5. The CLI supports human-readable output by default and JSON output for scripts. JSON output has stable field names and is available for discovery commands, read commands, successful mutations, and failures. +6. The CLI supports process discovery and instance selection: + - `warpctrl instance list` returns all reachable local Warp app processes that support the protocol. + - Each process has an opaque `instance_id`, a channel/build identity, and enough display metadata for a developer to choose it. + - If exactly one compatible process is available, commands may target it implicitly. + - If multiple compatible processes are available, the CLI may select a single clearly active/frontmost instance when that state is unambiguous; otherwise it fails and asks the developer to pass an explicit instance selector. + - Developers can explicitly choose an instance by opaque instance ID. Channel or PID filters may be offered as convenience filters, but opaque instance ID is the canonical selector. +7. The CLI supports introspection for target discovery: + - `warpctrl window list` + - `warpctrl tab list` + - `warpctrl pane list` + - `warpctrl session list` + - `warpctrl app active` + These commands return opaque protocol-facing IDs and enough metadata for subsequent commands without requiring knowledge of internal Warp identifiers. +8. The target selector model is hierarchical: + - Instance selector resolves a running Warp process. + - Window selector resolves within the instance. + - Tab selector resolves within the window. + - Pane selector resolves within the tab or active pane group context. + - Session selector resolves within the pane when the pane hosts terminal session state. +9. Every selector family supports an ergonomic `active` form when that concept exists: + - Active instance, if unambiguous. + - Active window in the selected instance. + - Active tab in the selected window. + - Active pane in the selected tab. + - Active session in the selected pane. +10. Every selector family supports explicit opaque IDs returned by introspection. Tabs may also support index selectors where index-based workflows are already user-visible, but IDs remain the preferred automation surface. +11. “Active session” means the currently selected terminal session for the resolved pane/window context. If the selected target does not contain a terminal session, session-scoped actions fail rather than silently redirecting elsewhere. +12. When a command omits lower-level selectors, it resolves them from the chosen higher-level context using active defaults. Example: a pane split command with only `--instance` uses that instance’s active window, active tab, and active pane. +13. When an explicitly supplied target disappears between discovery and execution, the request fails with a stale-target error. The CLI must not silently choose a different tab, pane, or session. +14. The protocol is command-oriented, not open-ended state mutation. Each action has a named command, validated parameters, and defined target scope. +15. The complete allowlisted action catalog should be organized into these namespaces. +16. Discovery and read-only state actions: + - List instances. + - Get protocol/app version information for one instance. + - List windows, tabs, panes, and sessions. + - Get the currently active instance/window/tab/pane/session chain when available. + - Inspect enough target metadata to let a script decide what to address next. +17. Window actions: + - Create a new window. + - Focus a target window. + - Close a target window. +18. Tab actions: + - Create a new terminal tab. + - Create a new agent tab where that is already a user-visible app capability. + - Activate a target tab. + - Activate previous, next, or last tab. + - Move a target tab left or right. + - Rename or reset a tab title. + - Set or clear active-tab color where that is already supported in the UI. + - Close the active tab, a target tab, other tabs, or tabs to the right of a target tab. +19. Pane actions: + - Split a target pane left, right, up, or down. + - Optionally choose the shell/session profile for split operations when that already maps to user-facing behavior. + - Focus a target pane. + - Navigate focus left, right, up, or down among panes. + - Close a target pane. + - Toggle maximize for a target pane. + - Resize pane dividers left, right, up, or down when that is supported by the app. +20. Session and terminal-input actions: + - Cycle to the previous or next session where the app exposes session cycling. + - Insert text into the active input without executing it. + - Replace the active input buffer. + - Clear the active input buffer where that matches existing user behavior. + - Run a command in the target session where the app already supports user-triggered command execution. + - Switch input mode between terminal and agent modes only where that mode switch is already user-visible and valid for the selected target. + These commands are part of the protocol catalog, but command execution should be treated as a higher-risk mutating action with explicit confirmation in spec/review before rollout. +21. Appearance actions: + - List available themes. + - Set the current fixed theme. + - Toggle or set “follow system theme.” + - Set the light and dark themes used when following the system theme. + - Increase, decrease, or reset font size. + - Increase, decrease, or reset UI zoom. + - Set other allowlisted appearance controls only when they correspond to stable user-facing controls. +22. Settings actions: + - Read allowlisted user-facing settings. + - Set allowlisted settings to validated values. + - Toggle allowlisted boolean settings. + - Reject attempts to mutate private, debug-only, unsafe, derived, or unsupported settings. + - Return a stable error when a named setting exists internally but is not part of the public local-control allowlist. +23. The settings allowlist should initially cover settings families that are already plainly user-facing and valuable for scripting: + - Theme/system-theme configuration. + - Font/zoom-related controls. + - Notifications. + - Syntax highlighting and error-underlining toggles. + - Accessibility verbosity where exposed to users. + - Selected panel/layout toggles when the user-facing behavior is already stable. + Additional settings families can be added only by extending the allowlist. +24. Panel and surface actions: + - Open the general settings surface. + - Open a specific settings page or settings search result. + - Open or toggle the command palette with an optional initial query where the app already supports query seeding. + - Open or toggle command search where that is already user-visible. + - Toggle or open the left panel, Warp Drive surface, right panel, resource center, AI assistant panel, code review panel, and vertical tabs panel where valid. +25. File/path intent actions may be included when they already mirror existing user-visible deep-link behavior: + - Open a path in a new tab or window. + - Open a repository picker or repo path flow where the current app already supports it. + These should remain allowlisted intent actions rather than arbitrary filesystem RPCs. +26. The following categories are explicitly excluded from the initial public allowlist even if there are internal actions for them: + - Crash, panic, heap-dump, token-copying, debug-reset, and similar developer/debug helpers. + - Arbitrary auth manipulation. + - Arbitrary cloud object mutation or broad Warp Drive CRUD. + - Arbitrary internal view dispatch by string. + - Arbitrary setting names outside the allowlist. +27. CLI command names should be noun-oriented and discoverable. During the provisional standalone-binary phase, the control CLI should expose a `warpctrl ...` command surface: + - `warpctrl instance list` + - `warpctrl app active` + - `warpctrl tab create` + - `warpctrl pane split --direction right` + - `warpctrl pane split --instance --window active --pane active --direction right` + - `warpctrl theme set "Warp Dark"` + - `warpctrl setting set appearance.themes.system_theme true` + - `warpctrl input insert "cargo check" --replace` + Channelized install names or aliases may vary during packaging. If the product later converges on `warp ...`, update packaging, shell completions, and operator docs together. +28. The wire protocol mirrors the CLI model. A mutating request contains: + - An action name from the allowlist. + - A structured target selector. + - Validated parameters. + A response contains: + - Success/failure status. + - Resolved instance and target metadata. + - Result data or structured error data. +29. The protocol is versioned. Clients must be able to determine whether a running Warp process supports the protocol version and action they intend to call. +30. Multiple running Warp processes are a supported normal case, not an error state. A local stable build and local dev build, or multiple supported local app instances, can coexist; the CLI provides deterministic discovery and addressing rather than assuming one global server. +31. Requests should be scoped to local-user control of the running app. A command that fails authentication or local authorization reports that condition explicitly and does not degrade into a less-specific request. +32. If a selected action is valid in general but impossible in the current UI state, the CLI reports a state-specific failure. Examples include: + - Splitting a pane that no longer exists. + - Running a session-scoped command against a non-terminal pane. + - Focusing a window that has closed. + - Setting a theme that is not available in that instance. +33. The first `warpctrl` implementation slice should ship the smallest end-to-end vertical slice that proves: + - Process discovery and target resolution work. + - A standalone CLI binary can reach a running local Warp process without launching or initializing the GUI app. + - `warpctrl tab create` creates a new terminal tab in the selected running instance. + - The command returns a structured success or failure payload suitable for human-readable and JSON output. + The first slice should include the minimum health/introspection commands needed to discover a running instance and exercise `tab.create`. +34. Follow-up PRs should fill out the remaining catalog in parallelizable groups once the protocol, discovery model, target resolution, error model, `tab.create` action path, and standalone `warpctrl` packaging shape have been validated by the first slice. +35. The protocol transport should be designed so that the default target is localhost but the CLI can be extended in the future to target remote URLs (e.g., a Warp instance on another machine or a hosted control endpoint). This is not in scope for the first implementation but should not be precluded by the architecture. +## Action classification and permission model +Agents (Oz cloud agents, local agent mode, and third-party automation) are expected to be major consumers of the warpctrl CLI alongside human developers. The action catalog must support differentiated permission policies for human callers versus agent callers, and must clearly classify every action by its risk profile so that Warp can enforce appropriate guardrails at both the protocol and product level. +### Classification tiers +Every action in the catalog belongs to exactly one of the following tiers, from least to most sensitive: +1. **Read-only / metadata.** Actions that return app-level structural information without exposing terminal content. These are safe for any caller and should never require elevated permission. + - Instance discovery and health: `instance list`, `app active`, `app version`, `app ping`. + - Layout enumeration: `window list`, `tab list`, `pane list`, `session list`. + - Appearance/settings reads that return configuration values but not user data: `theme list`, `setting list`, `setting get`. +2. **Read-only / terminal data.** Actions that return content from a user's terminal sessions, command history, pane output buffers, or input editor state. These expose potentially sensitive user data and must be gated separately from structural metadata reads. + - Reading pane output or scrollback content (when implemented). + - Reading the current input buffer contents. + - Reading command history or session replay data. + Even though these are read-only, they cross a privacy boundary that metadata reads do not. An agent that can enumerate tabs should not automatically be able to read terminal output. +3. **Mutating / non-destructive.** Actions that change app state in ways that are visible but reversible or low-risk. They do not destroy user data or execute arbitrary commands. + - Layout mutations: `tab create`, `tab activate`, `tab move`, `tab rename`, `window create`, `window focus`, `pane split`, `pane focus`, `pane navigate`, `pane maximize`, `pane resize`. + - Appearance mutations: `theme set`, `font-size increase/decrease/reset`, `zoom increase/decrease/reset`. + - Settings writes for allowlisted non-destructive settings: `setting set`, `setting toggle`. + - Panel/surface toggles: settings open, command palette, Warp Drive toggle, AI assistant toggle, etc. +4. **Mutating / destructive or high-risk.** Actions that destroy user state, close active work, or execute arbitrary content in a terminal session. These require the strongest permission gates and explicit review before agent use. + - Closing targets: `tab close`, `window close`, `pane close`. + - Terminal input injection: `input insert`, `input replace`, `input clear`. + - Command execution in a session (when implemented). + - Input mode switching between terminal and agent modes. + Any action that can cause data loss (closing an unsaved session) or execute arbitrary code (injecting and running a shell command) belongs here regardless of how simple the API looks. +### Permission policies +The protocol and product should support per-caller permission policies keyed to these tiers: +- **Human interactive use** defaults to full access across all tiers, gated only by local authentication (the bearer token). +- **Agent use** should default to read-only metadata access and require explicit opt-in for each higher tier. The product should support: + - A baseline "read-only metadata" grant that lets agents discover and enumerate without accessing terminal content or mutating state. + - A "read terminal data" grant that additionally permits reading pane output, input buffers, and session content. + - A "mutate non-destructive" grant that additionally permits layout and appearance changes. + - A "mutate destructive" grant that additionally permits closing targets, injecting input, and executing commands. +- The permission model should be expressible in the protocol (e.g., a capability or scope field in the authentication material) so that the app bridge can enforce it server-side, not just client-side. +- When an agent attempts an action above its granted tier, the bridge should return a structured `insufficient_permissions` error that identifies the required tier, rather than silently downgrading or returning a generic failure. +### Future entity extensibility: files and Warp Drive objects +The selector and action model should be designed to accommodate entity types beyond the current window/tab/pane/session hierarchy. Two important future entity families are **local files** and **Warp Drive objects** (workflows, notebooks, environment variables, prompts). Neither is in scope for the first implementation, but the protocol should not preclude them. +**Files.** Warp already supports file opening via deep links and the built-in editor. A future `file` namespace could support: +- `warpctrl file open ` — open a file in a Warp editor tab, equivalent to clicking a file link. +- `warpctrl file open --line ` — open at a specific line. +- `warpctrl file list` — list files currently open in editor tabs across the instance. +File selectors would use filesystem paths (absolute or relative to the working directory of the target pane/session). Unlike window/tab/pane selectors, file selectors are not opaque IDs — they are user-visible paths. The protocol should support a `file` field in the target selector that accepts a path string, distinct from the opaque ID selectors used for windows, tabs, and panes. +**Warp Drive objects.** Warp Drive stores typed objects (workflows, notebooks, environment variable sets, prompts) that users can reference, execute, and share. A future `drive` namespace could support: +- `warpctrl drive list --type workflow` — list Warp Drive objects by type. +- `warpctrl drive get ` — retrieve a specific Drive object by its opaque ID or by name/path. +- `warpctrl drive run ` — execute a workflow in a target session, equivalent to invoking it from the command palette. +- `warpctrl drive insert ` — insert a notebook's runnable commands into the active input. +Drive object selectors should support both opaque IDs (for automation stability) and human-friendly name/path lookups (for interactive use). The type field (`workflow`, `notebook`, `env_var`, `prompt`) acts as a namespace filter. Drive actions that execute content in a terminal session (e.g., running a workflow) inherit the destructive/high-risk tier from the action classification model. +**Design constraints for both:** +- File and Drive selectors are orthogonal to the window/tab/pane hierarchy — a file open action targets an instance (which window to open in), not a specific pane. A Drive workflow execution targets a session (which pane to run in). +- The `TargetSelector` type in the protocol should be extensible with optional fields for these new selector families without breaking existing requests that omit them. +- The action classification tiers apply: listing Drive objects is tier 1 (metadata), reading Drive object content is tier 1 or 2 depending on whether it contains user data, executing a Drive workflow is tier 4 (destructive/high-risk). +### Settings: protocol-first +Settings reads and writes should go through the local-control protocol like other actions, not bypass it via direct file manipulation. +- `warpctrl setting get `, `warpctrl setting set `, and `warpctrl setting toggle ` send requests to the running Warp instance through the standard authenticated control endpoint. +- The app bridge validates the key against the allowlist and the value against the expected type before applying the change. +- This keeps authorization enforcement consistent: the same permission tier checks and caller-type policies apply to settings mutations as to any other action, rather than creating a second unguarded path through the filesystem. +- The app owns the write to the settings file and any side effects (e.g., theme reload, layout reflow) as a single atomic operation, avoiding races between a CLI file write and the app's file watcher. +- If a future need arises for offline settings manipulation (no running Warp process), a separate file-based path can be added later with its own validation, but it should not be the default. +- The action classification still applies: settings reads are tier 1 (metadata), settings writes are tier 3 (non-destructive mutation). diff --git a/specs/warp-control-cli/README.md b/specs/warp-control-cli/README.md new file mode 100644 index 0000000000..570959e581 --- /dev/null +++ b/specs/warp-control-cli/README.md @@ -0,0 +1,92 @@ +# warpctrl operator README +`warpctrl` is the provisional standalone CLI for controlling an already-running local Warp app instance. It is intended for scripts, demos, agent workflows, and developer automation that need to perform allowlisted Warp UI actions without launching the GUI executable in CLI mode. +The first implementation slice is intentionally narrow: +- discover compatible running Warp instances; +- select one instance implicitly when unambiguous or explicitly with `--instance`; +- send authenticated local-control requests through the per-instance discovery record; +- create a new terminal tab with `warpctrl tab create`. +The local-control protocol and catalog are broader than this slice, but commands outside the implemented capability set should fail with structured unsupported-action errors until their handlers land. +## Packaging model +`warpctrl` should be packaged as a separate CLI artifact from the Warp GUI app while reusing shared repository code: +- `crates/local_control` owns discovery records, local authentication material, client transport, protocol envelopes, action names, and error types. +- `crates/warp_cli` owns command parsing conventions for local-control subcommands. +- the app-side bridge owns the per-process loopback listener and dispatches supported actions onto the live Warp UI context. +The binary should initialize only CLI parsing, instance discovery, local authentication loading, request serialization, HTTP transport, and output formatting. It should not initialize GUI state, terminal models, rendering, workspaces, or main-app startup paths. +During the provisional naming period, release artifacts and helper names may be channelized, but operator docs and examples should use `warpctrl` unless an integration branch explicitly documents a channel-specific alias. +This branch wires the standalone binary target and the macOS/Linux bundle-script artifact selectors: +- `cargo build -p warp --bin warpctrl` +- `script/macos/bundle --artifact warpctrl ...` +- `script/linux/bundle --artifact warpctrl ...` +Windows has the native Rust binary target, but installer/release helper exposure remains follow-up packaging work. +## Install and invocation guidance +### macOS +Build locally with `cargo build -p warp --bin warpctrl`, then run `target/debug/warpctrl` or copy/symlink that binary onto `PATH`. +For distributable standalone artifact checks, use `script/macos/bundle --artifact warpctrl` with the desired channel/signing flags. The bundle script writes a standalone `warpctrl` binary into its macOS artifact output directory instead of embedding it in the GUI app bundle. +### Linux +Build locally with `cargo build -p warp --bin warpctrl`, then run `target/debug/warpctrl` or copy/symlink that binary onto `PATH`. +For distributable standalone artifact checks, use `script/linux/bundle --artifact warpctrl` with the desired channel/package selection. The Linux bundle script routes packaging through the standalone control-binary artifact path; downstream package installation should place the emitted `warpctrl` binary according to that package format. +Run `warpctrl --version` after installation to confirm the shell is resolving the expected build. +### Windows +Build locally with `cargo build -p warp --bin warpctrl`, then run `target\debug\warpctrl.exe` or copy that binary onto `PATH`. +The Windows-native binary target exists in this slice. Installer helper creation and release-artifact wiring still need a later packaging change before docs can promise an installer-provided `warpctrl` command. +## End-to-end local test flow +Use matching app and CLI bits from the same branch or release artifact so the protocol version and action catalog agree. +1. Start Warp and leave at least one window open. +2. Confirm that the local-control server registered the running process: + ```bash + warpctrl instance list + ``` +3. If exactly one compatible instance is listed, create a new terminal tab: + ```bash + warpctrl tab create + ``` +4. If multiple compatible instances are listed, copy the desired `instance_id` and target it explicitly: + ```bash + warpctrl tab create --instance + ``` +5. Verify the running app receives focus for the selected instance and a new terminal tab appears according to Warp's normal new-tab placement behavior. +6. Optionally inspect state before and after the mutation: + ```bash + warpctrl tab list --instance + ``` +Expected failures: +- no running compatible app: exits non-zero with a no-instance error; +- multiple ambiguous instances: exits non-zero and asks for `--instance`; +- unsupported app build or stale discovery record: exits non-zero with a protocol, stale-target, or transport error; +- `tab.create` not yet implemented by the running app bridge: exits non-zero with an unsupported-action error. +## Security model +The local-control protocol is designed for same-user scripting, not cross-user or network access. The trust boundary is the local user account. +- **Loopback-only listener.** Each Warp process binds its control server to `127.0.0.1` on an ephemeral port. The listener is not reachable from the network. +- **Per-instance bearer token.** A random token is generated at startup and written into the discovery record. Every control request must present this token in the `Authorization` header; missing or invalid tokens are rejected with HTTP 401. +- **File-permission-gated discovery.** Discovery records are stored in `~/.warp/local-control/` with `0600` permissions (owner read/write only). Any process that can read the file can authenticate, so the security boundary is the same as `~/.ssh/` or macOS Keychain — same-user process isolation. +- **Stale-record pruning.** On each `instance list` or implicit discovery call, records whose PID is no longer alive are deleted automatically, preventing stale tokens from lingering on disk. +- **No CORS.** The control endpoints do not set permissive CORS headers, so browser-origin JavaScript cannot read responses even if it guesses the port. The bearer token requirement provides a second layer since browsers cannot read the discovery file. +```mermaid +sequenceDiagram + participant CLI as warpctrl + participant FS as ~/.warp/local-control/ + participant HTTP as Warp loopback server
(127.0.0.1:ephemeral) + participant Bridge as App bridge + + CLI->>FS: Read discovery records (0600) + FS-->>CLI: instance_id, endpoint, auth_token + CLI->>CLI: Prune stale PIDs, select instance + CLI->>HTTP: POST /v1/control
Authorization: Bearer + HTTP->>HTTP: Verify token matches instance + alt Invalid or missing token + HTTP-->>CLI: 401 Unauthorized + else Valid token + HTTP->>Bridge: Dispatch action to app context + Bridge-->>HTTP: Structured result or error + HTTP-->>CLI: JSON response envelope + end +``` +**Known limitations and future hardening:** +- The token is stored in plaintext in the discovery JSON file. Any compromised process running as the same user can extract it. +- Tokens do not rotate or expire during a Warp session. A leaked token is valid until the process exits. +- Once higher-risk handlers land (e.g. `input.insert`, command execution), the same-user boundary becomes a code-execution trust boundary. Consider separating the token from the discovery metadata, adding per-request nonces, or switching to a Unix domain socket with `SO_PEERCRED` for kernel-verified caller identity. +## Documentation review notes +- Treat `warpctrl` as provisional executable naming until packaging signs off on final artifact aliases. +- Keep examples scoped to discovery and `tab create` until additional app-side handlers are implemented. +- Do not document catalog commands as usable just because they exist in protocol enums or parser scaffolding; operator docs should distinguish implemented commands from planned allowlist entries. +- Windows packaging may initially follow the existing helper-wrapper pattern rather than shipping a native standalone executable. Update this README when that decision is final. diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md new file mode 100644 index 0000000000..de1f2cc201 --- /dev/null +++ b/specs/warp-control-cli/TECH.md @@ -0,0 +1,322 @@ +# Context +`PRODUCT.md` defines a standalone local Warp control CLI binary, provisionally named `warpctrl`, with an allowlisted action catalog, deterministic addressing across multiple running Warp app processes, and an incremental implementation plan. +The existing app already has three relevant building blocks: +- `crates/http_server/src/lib.rs (7-61)` runs a native-only loopback Axum server on fixed port `9277`. +- `app/src/lib.rs (1993-2001)` registers that HTTP server in the native app and currently merges only installation-detection and profiling routers. +- `crates/app-installation-detection/src/lib.rs (15-60)` and `app/src/profiling.rs (208-242)` show the current local HTTP routes. They are narrow endpoints, not a general control plane. +Warp also already has the app-side behaviors the control API should reuse rather than reimplement: +- `app/src/terminal/view/action.rs (193-196)` defines split-pane terminal actions. +- `app/src/pane_group/mod.rs (4266-4360, 5377-5414)` shows pane creation/splitting semantics and how split events mutate pane layout. +- `app/src/workspace/action.rs (153-156)` defines the existing tab creation actions, including default and terminal-tab variants. +- `app/src/workspace/view.rs (21203-21244)` shows how user-visible default and terminal-tab actions are dispatched. +- `app/src/settings/theme.rs (9-82)` defines persisted theme settings. +- `app/src/themes/theme_chooser.rs (416-458)` shows persisted theme selection behavior. +- `app/src/workspace/action.rs (95-776)` is the largest existing inventory of user-visible workspace actions and informs the allowlist catalog. +- `app/src/workspace/util.rs (12-18)` defines `PaneViewLocator`, and `app/src/pane_group/pane/mod.rs (84-177)` defines serializable pane identifiers, both useful reference points for selector resolution. +- `app/src/uri/mod.rs (822-1093, 1166-1364)` demonstrates external intents being resolved into active windows/workspaces and dispatched into running app state. +The current Oz CLI build/distribution model is also directly relevant because the control CLI should follow the same standalone-artifact approach rather than relying on the Warp GUI executable to service ordinary shell invocations: +- `crates/warp_cli/src/lib.rs (88-188, 316-418)` defines the existing CLI/parser conventions and channel-specific command naming support. +- `app/src/lib.rs (631-746)` routes CLI invocations into CLI execution rather than GUI launch. +- `script/macos/bundle (353-735)` and `script/linux/bundle (157-294)` build standalone CLI artifacts with the `standalone` feature. +- `.github/workflows/create_release.yml (423-554, 660-858, 992-1276)` publishes macOS/Linux CLI artifacts. +- `script/windows/windows-installer.iss (235-263)` shows the current Windows helper-wrapper pattern for CLI access. +The most important constraint surfaced by this code is that the current fixed-port local HTTP server cannot be the entire solution for a multi-process control API. If multiple local Warp processes attempt to expose mutating routes through the same fixed port, only one can own it. The control design therefore needs explicit per-process discovery and addressing. +## Proposed changes +### 1. Protocol crate and stable envelope +Create a small shared protocol crate or equivalent shared module used by both the app server and standalone CLI client. It should define: +- Protocol version metadata. +- Discovery/health response types. +- Selector types: + - `InstanceSelector` + - `WindowSelector` + - `TabSelector` + - `PaneSelector` + - `SessionSelector` +- Opaque protocol-facing ID newtypes for instance/window/tab/pane/session identifiers. +- Allowlisted `ControlAction` variants and typed parameter payloads. +- Success/error envelopes with stable machine-readable error codes. +The protocol should treat target IDs as opaque. The app may encode existing runtime identifiers internally, but the public wire contract should not require callers to understand `EntityId`, `PaneId`, or other implementation types. +Recommended top-level request shape for `tab.create`: +```json +{ + "protocol_version": 1, + "request_id": "client-generated-id", + "action": "tab.create", + "target": { + "window": "active" + }, + "params": {} +} +``` +Recommended response shape: +```json +{ + "ok": true, + "protocol_version": 1, + "request_id": "client-generated-id", + "instance_id": "opaque-instance-id", + "resolved_target": { + "window_id": "opaque-window-id", + "tab_id": "opaque-tab-id" + }, + "result": {} +} +``` +Error payloads should include a stable code such as `no_instance`, `ambiguous_instance`, `stale_target`, `invalid_selector`, `unsupported_action`, `not_allowlisted`, `invalid_params`, `target_state_conflict`, or `unauthorized_local_client`. +### 2. Per-process discovery instead of fixed-port-only routing +Keep the existing fixed-port HTTP behavior intact for installation detection/profiling compatibility. Add a separate local-control listener that follows the same native Axum/Tokio pattern but supports multiple local Warp app processes. +Recommended design: +- Each participating Warp process creates a random opaque `instance_id` at startup. +- Each process binds a loopback control listener on an ephemeral port or an app-managed available port. +- Each process writes a discovery record into a secure per-user Warp state directory. The record should contain: + - `instance_id` + - PID + - channel/build metadata + - control-listener endpoint + - protocol version + - start timestamp + - local-auth material reference or token metadata +- The CLI loads discovery records, removes or ignores stale records after health checks, and chooses an instance using the product selector rules. +- `warpctrl instance list` is a CLI-first projection of this discovery registry plus health responses. +This design preserves the current `9277` behavior while avoiding cross-process port contention for the new control API. +### 3. Local authentication boundary +Mutating localhost routes should not copy the permissive CORS posture of `/install_detection`. +Recommended local trust model: +- No browser-readable CORS allowance on control endpoints. +- Per-instance random bearer token or equivalent local credential stored in the discovery record or adjacent secure local state with user-only permissions. +- CLI automatically loads and presents the credential. +- The app rejects missing/invalid local credentials before action resolution. +- Health metadata exposed without credentials, if needed for stale-record pruning, must not reveal mutating capabilities or sensitive target state. +This keeps the protocol local and scriptable without creating an ambient browser-to-localhost control surface. +### 4. App-side request bridge onto the UI/application context +The HTTP handler runs on a Tokio runtime thread owned by the local-control server. It cannot directly access or mutate Warp's UI models, views, or app context because all WarpUI state is single-threaded and owned by the main app event loop. The bridge solves this by sending a closure from the Tokio handler thread to the main thread, executing it in the model's context, and returning the result to the waiting HTTP handler. +#### Thread model +- **Tokio runtime thread (HTTP handler):** Owns the Axum router, receives HTTP requests, authenticates, deserializes the `RequestEnvelope`. Cannot touch `AppContext`, views, or models. +- **Main app thread:** Owns all WarpUI entities (`App`, `AppContext`, views, models). All UI state reads and mutations must happen here. +- **Bridge:** Transfers a typed closure from the Tokio thread to the main thread, executes it with `&mut ModelContext`, and sends the return value back. +#### Implementation: `ModelSpawner` +The bridge uses WarpUI's `ModelSpawner` mechanism, which is the standard way for background threads to schedule work on a model's main-thread context: +1. During app initialization, a `LocalControlBridge` singleton model is created. The model's `ModelContext::spawner()` method returns a `ModelSpawner` — a cloneable, `Send` handle that can enqueue closures from any thread. +2. The `ModelSpawner` is stored in the Axum router's shared state (`ControlServerState`), making it available to every HTTP handler. +3. When an HTTP request arrives, the handler calls `spawner.spawn(|bridge, ctx| { ... }).await`: + - `spawn` sends a boxed `FnOnce(&mut LocalControlBridge, &mut ModelContext) -> R` closure through an `async_channel` to the main thread's task-callback loop. + - The main thread dequeues the closure, constructs a fresh `ModelContext` for the bridge model, and calls the closure. + - Inside the closure, the bridge has full access to `ModelContext`, which derefs to `AppContext`. This means it can call `ctx.windows()`, `ctx.views_of_type::(window_id)`, `workspace.update(ctx, ...)`, and any other main-thread API. + - The closure returns a typed result (e.g., `ResponseEnvelope`), which is sent back to the Tokio thread via a `oneshot` channel. +4. The HTTP handler awaits the oneshot result and serializes it as the HTTP response. +#### Concrete flow for `tab.create` +``` +HTTP handler (Tokio thread) + │ + ├─ verify auth token + ├─ deserialize RequestEnvelope + ├─ call bridge_spawner.spawn(move |bridge, ctx| { + │ bridge.handle_request(request, ctx) // runs on main thread + │ }).await + │ + └─ serialize ResponseEnvelope as JSON + +LocalControlBridge::handle_request (main thread) + │ + ├─ match request.action.kind + │ └─ ActionKind::TabCreate + │ ├─ validate_tab_create_target(&request.target) + │ ├─ ctx.windows().active_window() + │ │ └─ fallback: ctx.windows().ordered_window_ids().first() + │ ├─ ctx.views_of_type::(window_id) + │ └─ workspace.update(ctx, |workspace, ctx| { + │ workspace.handle_action( + │ &WorkspaceAction::AddTerminalTab { hide_homepage: false }, + │ ctx, + │ ) + │ }) + │ + └─ return ResponseEnvelope::ok(request_id, json!({ ... })) +``` +#### Why this pattern +- **Thread safety.** WarpUI's entity/view system is not `Send` or `Sync`. The only safe way to interact with it from a background thread is through `ModelSpawner`, which serializes access through the main event loop. +- **Synchronous result.** Unlike fire-and-forget patterns (e.g., URI intent dispatch in `app/src/uri/mod.rs`), the `spawn` call returns a concrete `Result`, so the HTTP handler can produce a structured success or error response. +- **Reuses existing infrastructure.** `ModelSpawner` is already used throughout the codebase for background-to-main-thread communication (e.g., async file I/O results, network responses). No new concurrency primitive is needed. +- **Action dispatch reuses existing app behavior.** The bridge calls `workspace.handle_action(&WorkspaceAction::AddTerminalTab { ... }, ctx)` — the exact same method the UI keybinding system uses. This ensures the control CLI produces identical behavior to the corresponding user action, including side effects like tab count updates, focus changes, and event emissions. +#### Adding new action handlers +To add a new action to the bridge: +1. Add a variant to `ActionKind` in `crates/local_control/src/protocol.rs`. +2. Add a match arm in `LocalControlBridge::handle_request` in `app/src/local_control/mod.rs`. +3. Inside the match arm, use `ctx` (which is a `&mut ModelContext` that derefs to `&mut AppContext`) to resolve selectors and dispatch the action onto existing app types. +4. Return a `ResponseEnvelope::ok(...)` or `ResponseEnvelope::error(...)` with the result. +The bridge closure has access to the full `AppContext` API surface, including `ctx.windows()`, `ctx.window_ids()`, `ctx.views_of_type::(window_id)`, `handle.update(ctx, ...)`, and `handle.read(ctx, ...)`. This makes it straightforward to wire new actions to existing UI behavior without introducing new concurrency concerns. +### 5. Target resolution model +Implement target resolution as a reusable component rather than scattering lookup logic across handlers. +Recommended resolution order: +1. Select instance in the CLI/discovery layer. +2. Resolve window inside the target process. +3. Resolve tab within the window. +4. Resolve pane within the tab/pane-group context. +5. Resolve session only for session-scoped commands. +Selector behavior: +- `active` resolves from current app focus/selection state. +- Explicit opaque IDs must resolve exactly or return `stale_target`. +- Index selectors are allowed only for user-visible indexed concepts such as tabs and should resolve to a concrete opaque ID before execution. +- A session-scoped request against a non-terminal pane returns `target_state_conflict`. +Implementation references: +- Window-level active selection already exists inside the app through `WindowManager`. +- Pane scoping can build on the conceptual model of `PaneViewLocator` in `app/src/workspace/util.rs (12-18)`. +- Existing URI intent routing in `app/src/uri/mod.rs (895-1093)` shows how to locate workspaces/windows and avoid silently acting in the wrong place. +### 6. Allowlisted handler families +Use one handler module per action family. The protocol layer owns parsing/validation; handler modules own target resolution and delegation to existing app logic. +Recommended modules/families: +- Discovery/state: + - instances, version, active chain, windows/tabs/panes/sessions listings. +- Window/tab: + - new, focus, close, activate, move, rename, color, close variants. +- Pane: + - split, focus, navigate, close, maximize, resize. +- Input/session: + - insert, replace, clear, run command, cycle session, mode switch where supported. +- Appearance/settings: + - theme list/set, system-theme controls, font/zoom actions, allowlisted settings reads/writes/toggles. +- Panels/surfaces: + - settings/page/search, palettes, left/right panels, Drive, resource center, code review, vertical tabs, AI assistant. +Do not use a generic “dispatch action by string” endpoint. Every handler should be reachable only through an explicit `ControlAction` variant. +### 7. First slice: prove discovery and `tab.create` +The first `warpctrl` implementation slice should land the minimum cross-cutting architecture plus a single representative tab mutation: +- Shared protocol types and error envelopes. +- Discovery registry and CLI instance selection. +- A standalone `warpctrl` binary or artifact path that runs control commands without starting the GUI app runtime. +- Per-process authenticated local-control server. +- App-side request bridge and selector resolver. +- Read-only `ping/version` plus `warpctrl instance list` or equivalent minimal discovery command. +- End-to-end `warpctrl tab create` for the selected instance, reusing the same app behavior as the user-visible new-terminal-tab action. +Why `tab.create` first: +- It proves a UI/layout action can be targeted and executed against live app state. +- It exercises process discovery, local authentication, request bridging, selector defaults, app-context dispatch, and structured success/error output without introducing higher-risk terminal input execution. +- It gives operators a concise end-to-end smoke test: discover a running instance, create a tab, and confirm the live app changed. +The PR should also introduce the shell-facing CLI command grammar that the remainder of the protocol will reuse and establish a lightweight CLI startup path distinct from GUI startup. +### 8. Follow-up slices: fill out the remaining protocol in parallel +After the first slice validates discovery, auth, selector resolution, CLI syntax, and server-to-app execution, follow-up slices can add the remaining allowlisted catalog in parallelized action-family groups. The baseline code should make new action additions mostly additive: +- Extend `ControlAction`. +- Add typed params/results. +- Add a handler. +- Add validation/tests. +- Add CLI surface/tests. +### 9. CLI parsing and output libraries +The `warpctrl` CLI must use the same argument parsing and output libraries as the existing Oz CLI so that conventions, derive patterns, and shell-completion generation remain consistent across both binaries. +- **clap** (with the `derive` feature) for argument parsing, subcommand trees, and help generation. Both binaries share the `warp_cli` crate, so parser types defined there are reused directly. +- **serde** / **serde_json** for JSON request/response serialization and for `--output-format json` output. +- **clap_complete** for shell completion generation, reusing the same infrastructure the Oz CLI uses. +- The `OutputFormat` enum (`Pretty`, `Json`, `Ndjson`, `Text`) is shared from `warp_cli::agent::OutputFormat` so human-readable vs. machine-readable output follows the same conventions. +- New subcommand types for `warpctrl` live in `warp_cli::local_control` and follow the same `#[derive(Parser)]` / `#[derive(Subcommand)]` / `#[derive(Args)]` patterns used by the Oz CLI's top-level `Args` and `CliCommand` types. +Do not introduce alternative parsing libraries (e.g., `structopt`, `argh`) or alternative serialization approaches. Keeping one set of libraries across both CLIs reduces dependency weight, ensures consistent `--help` formatting, and lets contributors move between the two surfaces without learning a different stack. +### 10. CLI packaging and release shape +The shipped product shape should be a separate bundled `warpctrl` CLI binary that reuses shared CLI/protocol crates but does not depend on launching the GUI binary in command mode. Follow the Oz CLI release model as closely as practical: +- macOS: + - Add a standalone control CLI artifact path next to the existing Oz standalone CLI artifact flow. + - If the app bundle also exposes a wrapper/install flow, keep channelized naming consistent with the final product name decision. +- Linux: + - Extend bundle/release scripts to emit control CLI standalone artifacts and packages in the same broad pattern as the current Oz CLI tarball/deb/rpm/Arch package flow. +- Windows: + - Mirror the existing installer-generated helper-wrapper pattern first if that remains the canonical Oz behavior on Windows. + - If the product decision is to ship a true standalone Windows control CLI binary, add a dedicated release path in follow-up work rather than silently diverging from existing Oz precedent. +Startup and dependency expectations: +- The CLI process should initialize only command parsing, discovery, authentication material loading, protocol serialization, HTTP transport, and output formatting needed for the requested command. +- The CLI should not initialize GUI state, rendering, terminal session models, app workspaces, or other main-app-only subsystems. +- Startup cost should be treated as part of the product contract because control commands are expected to compose naturally in scripts and repeated interactive shell usage. +Naming decision: +- Product examples use provisional `warpctrl ...` command lines for the standalone local-control binary. +- Final artifact filenames, channelized aliases, and installer exposure should be chosen before broad rollout to avoid churn in bundle scripts, docs, shell completions, and release workflow files. +## End-to-end flow +```mermaid +sequenceDiagram + participant CLI as Warp control CLI + participant REG as Local discovery registry + participant PROC as Selected Warp process + participant HTTP as Local control listener + participant BRIDGE as App request bridge + participant RES as Target resolver + participant ACT as Allowlisted action handler + participant UI as Live Warp app state + + CLI->>REG: Read local instance records + CLI->>PROC: Health/protocol check for candidates + PROC-->>CLI: Instance metadata + compatibility + CLI->>CLI: Resolve instance selector + CLI->>HTTP: Authenticated POST tab.create request + HTTP->>BRIDGE: Typed request + response channel + BRIDGE->>RES: Resolve window/tab/pane/session selectors + RES-->>BRIDGE: Concrete target handles or typed error + BRIDGE->>ACT: Execute allowlisted ControlAction + ACT->>UI: Reuse existing tab creation behavior + UI-->>ACT: Mutation/read result + ACT-->>BRIDGE: Typed result + BRIDGE-->>HTTP: Response envelope + HTTP-->>CLI: JSON success/error response + CLI-->>CLI: Pretty or JSON output +``` +## Testing and validation +Map tests directly to `PRODUCT.md` behavior. +- Behavior 1-6, 29-31: + - Protocol version/unit tests. + - Discovery-registry tests with zero, one, multiple, stale, and incompatible instance records. + - Local-auth tests for missing/invalid/valid credentials. +- Behavior 7-13: + - Selector-resolution unit tests for active, explicit ID, index, stale target, ambiguous target, and non-terminal session target. + - Tests that no lower-level selector silently retargets after an explicit stale selector fails. +- Behavior 15-28: + - Parser/serde tests for every first-slice `ControlAction` variant. + - Router tests proving unknown/unallowlisted actions are rejected. + - CLI parse/output tests for pretty and JSON rendering. +- Behavior 18 and 33: + - App-side tests for `tab.create` using existing workspace/tab helpers or a narrow extracted helper. + - Manual local verification that `warpctrl tab create` creates a terminal tab in a running app. +- Behavior 30: + - Multi-process integration-style coverage using two synthetic discovery records and mock health responders, plus manual testing with multiple channel builds where practical. +- Packaging: + - `--artifact cli`-style bundle smoke tests or script-level checks for each supported platform path touched by the first slice. + - Startup-path tests or focused checks confirming `warpctrl` dispatches commands without entering GUI-app launch code. + - Shell completions/help output checks once final command naming is selected. +## Parallelization +The first slice should stay mostly sequential because protocol envelope, discovery, authentication, selector resolution, and `tab.create` are tightly coupled and need one coherent architecture. +The follow-up catalog expansion is a strong fit for remote Oz cloud-agent fan-out after the first slice lands. Proposed parallel workstreams: +- `control-window-tab-pane` — remote agent owns window/tab/pane action expansion, including CLI syntax, protocol variants, app handlers, and tests. Branch suggestion: `zach/warp-control-cli-window-tab-pane`. +- `control-settings-appearance` — remote agent owns settings/theme/font/zoom allowlist expansion and validation. Branch suggestion: `zach/warp-control-cli-settings-appearance`. +- `control-input-surfaces` — remote agent owns session/input plus panel/palette/settings-surface commands, with extra care around command execution risk. Branch suggestion: `zach/warp-control-cli-input-surfaces`. +- `control-introspection-packaging` — remote agent owns richer list/read commands, documentation/examples, and any follow-on bundle/release plumbing not completed in PR1. Branch suggestion: `zach/warp-control-cli-introspection-packaging`. +Merge strategy: +- Each remote agent works from the first slice’s merged baseline or a designated follow-up integration base. +- Each returns a branch or compact patch plus validation notes. +- A lead integrator folds accepted slices into one combined second PR so the public protocol remains coherent. +```mermaid +flowchart LR + P1["First slice merged
protocol + discovery + bridge + tab.create"] --> Launch["Launch follow-up cloud agents"] + Launch --> A["control-window-tab-pane"] + Launch --> B["control-settings-appearance"] + Launch --> C["control-input-surfaces"] + Launch --> D["control-introspection-packaging"] + A --> Merge["Lead integrates protocol additions"] + B --> Merge + C --> Merge + D --> Merge + Merge --> Validate["Full validation + docs review"] + Validate --> P2["Single PR2 with remaining allowlist"] +``` +## Risks and mitigations +- Fixed-port server assumptions: + - Mitigation: leave current `9277` endpoints undisturbed and use a per-process control listener plus discovery registry. +- Browser-to-localhost abuse: + - Mitigation: no permissive CORS, explicit local auth, and mutating routes gated before selector resolution. +- Action catalog drift from real UI behavior: + - Mitigation: each control action reuses or factors existing UI action paths rather than duplicating behavior. +- Leaking internal unstable identifiers: + - Mitigation: public protocol exposes opaque IDs and selectors; internal runtime IDs stay implementation details. +- Over-broad settings mutation: + - Mitigation: allowlisted setting keys only, with private/debug/derived settings rejected. +- Command execution risk: + - Mitigation: keep `input.run`/session execution in the catalog but require explicit follow-up product/review decision before broad rollout. +- Packaging churn due to provisional executable naming: + - Mitigation: document `warpctrl` as provisional and settle final aliases before broad release workflow rollout. +- Heavyweight CLI startup caused by sharing the GUI binary's launch path: + - Mitigation: ship a separate control CLI artifact with a narrow initialization path and keep GUI-only subsystems out of ordinary CLI command execution. +## Follow-ups +- Decide the final artifact filename/channel alias scheme around the provisional `warpctrl ...` public command surface. +- Decide whether Windows should follow the current Oz wrapper pattern indefinitely or gain standalone control CLI artifacts. +- Decide whether a future subscription/watch protocol is useful for scripts that want live state changes, rather than single request/response calls only. From ac892c9d5d2665e71a21fd0121d76dd8eeea7bb7 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd Date: Thu, 21 May 2026 20:29:03 -0600 Subject: [PATCH 04/48] Address warpctrl spec review feedback Co-Authored-By: Oz --- specs/warp-control-cli/PRODUCT.md | 8 ++++++++ specs/warp-control-cli/README.md | 7 ++++--- specs/warp-control-cli/TECH.md | 4 +++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/specs/warp-control-cli/PRODUCT.md b/specs/warp-control-cli/PRODUCT.md index 884471b05c..a2a7920d98 100644 --- a/specs/warp-control-cli/PRODUCT.md +++ b/specs/warp-control-cli/PRODUCT.md @@ -200,6 +200,14 @@ The protocol and product should support per-caller permission policies keyed to - A "mutate destructive" grant that additionally permits closing targets, injecting input, and executing commands. - The permission model should be expressible in the protocol (e.g., a capability or scope field in the authentication material) so that the app bridge can enforce it server-side, not just client-side. - When an agent attempts an action above its granted tier, the bridge should return a structured `insufficient_permissions` error that identifies the required tier, rather than silently downgrading or returning a generic failure. +### Scoped credentials and caller identity +The local discovery bearer token is not sufficient for differentiated human vs. agent permissions. If the same full-access token is readable by both humans and agents, the server cannot enforce agent-specific read-only defaults. Before agent-facing read/write or destructive actions ship, the product must introduce scoped credentials. +- The discovery record may expose a human CLI credential for same-user interactive use, but agent runtimes should not receive that full-access credential by default. +- Agent callers should receive a separate scoped credential minted by Warp or by a trusted local broker. The credential must identify the caller class (`human`, `local_agent`, `cloud_agent`, `third_party_automation`) and granted permission tiers. +- Scoped credentials should be bound to a specific Warp instance, protocol version, issued-at time, optional expiry time, and allowed action tiers. The bridge must validate those fields before action dispatch. +- The bridge, not the CLI frontend, enforces permission tiers by mapping every `ActionKind` to a required tier and comparing that tier to the credential's grants. +- If a process presents the human discovery credential, the bridge treats it as human interactive use. If a process presents an agent credential, the bridge applies the agent policy even if the process runs under the same local user account. +- The first implementation slice may ship a same-user human credential only because it implements discovery and `tab create`; any slice that exposes terminal data, input mutation, command execution, or other agent-consumable automation must add scoped credential issuance first. ### Future entity extensibility: files and Warp Drive objects The selector and action model should be designed to accommodate entity types beyond the current window/tab/pane/session hierarchy. Two important future entity families are **local files** and **Warp Drive objects** (workflows, notebooks, environment variables, prompts). Neither is in scope for the first implementation, but the protocol should not preclude them. **Files.** Warp already supports file opening via deep links and the built-in editor. A future `file` namespace could support: diff --git a/specs/warp-control-cli/README.md b/specs/warp-control-cli/README.md index 570959e581..6bf563e15c 100644 --- a/specs/warp-control-cli/README.md +++ b/specs/warp-control-cli/README.md @@ -45,7 +45,7 @@ Use matching app and CLI bits from the same branch or release artifact so the pr warpctrl tab create --instance ``` 5. Verify the running app receives focus for the selected instance and a new terminal tab appears according to Warp's normal new-tab placement behavior. -6. Optionally inspect state before and after the mutation: +6. In a future slice that implements `tab list`, inspect state before and after the mutation: ```bash warpctrl tab list --instance ``` @@ -58,7 +58,7 @@ Expected failures: The local-control protocol is designed for same-user scripting, not cross-user or network access. The trust boundary is the local user account. - **Loopback-only listener.** Each Warp process binds its control server to `127.0.0.1` on an ephemeral port. The listener is not reachable from the network. - **Per-instance bearer token.** A random token is generated at startup and written into the discovery record. Every control request must present this token in the `Authorization` header; missing or invalid tokens are rejected with HTTP 401. -- **File-permission-gated discovery.** Discovery records are stored in `~/.warp/local-control/` with `0600` permissions (owner read/write only). Any process that can read the file can authenticate, so the security boundary is the same as `~/.ssh/` or macOS Keychain — same-user process isolation. +- **File-permission-gated discovery.** Discovery records are stored in a per-user local-control directory. On POSIX platforms, files must be created with `0600` permissions (owner read/write only). On Windows, records must be stored under the current user's app data directory with an ACL that grants access only to the current user, Administrators, and SYSTEM. Any same-user process that can read the credential can authenticate, so the baseline security boundary is same-user process isolation. - **Stale-record pruning.** On each `instance list` or implicit discovery call, records whose PID is no longer alive are deleted automatically, preventing stale tokens from lingering on disk. - **No CORS.** The control endpoints do not set permissive CORS headers, so browser-origin JavaScript cannot read responses even if it guesses the port. The bearer token requirement provides a second layer since browsers cannot read the discovery file. ```mermaid @@ -68,7 +68,7 @@ sequenceDiagram participant HTTP as Warp loopback server
(127.0.0.1:ephemeral) participant Bridge as App bridge - CLI->>FS: Read discovery records (0600) + CLI->>FS: Read discovery records (user-only permissions / ACL) FS-->>CLI: instance_id, endpoint, auth_token CLI->>CLI: Prune stale PIDs, select instance CLI->>HTTP: POST /v1/control
Authorization: Bearer @@ -84,6 +84,7 @@ sequenceDiagram **Known limitations and future hardening:** - The token is stored in plaintext in the discovery JSON file. Any compromised process running as the same user can extract it. - Tokens do not rotate or expire during a Warp session. A leaked token is valid until the process exits. +- Windows local-control authentication is not complete until discovery-record ACL creation and validation are implemented. - Once higher-risk handlers land (e.g. `input.insert`, command execution), the same-user boundary becomes a code-execution trust boundary. Consider separating the token from the discovery metadata, adding per-request nonces, or switching to a Unix domain socket with `SO_PEERCRED` for kernel-verified caller identity. ## Documentation review notes - Treat `warpctrl` as provisional executable naming until packaging signs off on final artifact aliases. diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index de1f2cc201..434f7ce902 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -88,6 +88,7 @@ Recommended local trust model: - The app rejects missing/invalid local credentials before action resolution. - Health metadata exposed without credentials, if needed for stale-record pruning, must not reveal mutating capabilities or sensitive target state. This keeps the protocol local and scriptable without creating an ambient browser-to-localhost control surface. +For agent-facing permissions, local authentication must evolve from a single bearer token into scoped credentials before higher-risk surfaces ship. A scoped credential should encode caller class, instance binding, granted permission tiers, issue time, and optional expiry. The app bridge must authenticate the credential and enforce the required action tier server-side before selector resolution or mutation. The first slice can use the discovery bearer token for human same-user CLI use only because it is limited to discovery and `tab.create`. ### 4. App-side request bridge onto the UI/application context The HTTP handler runs on a Tokio runtime thread owned by the local-control server. It cannot directly access or mutate Warp's UI models, views, or app context because all WarpUI state is single-threaded and owned by the main app event loop. The bridge solves this by sending a closure from the Tokio handler thread to the main thread, executing it in the model's context, and returning the result to the waiting HTTP handler. #### Thread model @@ -122,7 +123,7 @@ LocalControlBridge::handle_request (main thread) │ └─ ActionKind::TabCreate │ ├─ validate_tab_create_target(&request.target) │ ├─ ctx.windows().active_window() - │ │ └─ fallback: ctx.windows().ordered_window_ids().first() + │ │ └─ if none: return invalid_selector / missing_target │ ├─ ctx.views_of_type::(window_id) │ └─ workspace.update(ctx, |workspace, ctx| { │ workspace.handle_action( @@ -138,6 +139,7 @@ LocalControlBridge::handle_request (main thread) - **Synchronous result.** Unlike fire-and-forget patterns (e.g., URI intent dispatch in `app/src/uri/mod.rs`), the `spawn` call returns a concrete `Result`, so the HTTP handler can produce a structured success or error response. - **Reuses existing infrastructure.** `ModelSpawner` is already used throughout the codebase for background-to-main-thread communication (e.g., async file I/O results, network responses). No new concurrency primitive is needed. - **Action dispatch reuses existing app behavior.** The bridge calls `workspace.handle_action(&WorkspaceAction::AddTerminalTab { ... }, ctx)` — the exact same method the UI keybinding system uses. This ensures the control CLI produces identical behavior to the corresponding user action, including side effects like tab count updates, focus changes, and event emissions. +- **Deterministic targeting.** The bridge must not silently fall back from the active window to an arbitrary ordered window for mutating actions. If the caller relies on the default active selector and no active window exists, return a structured missing-target or invalid-selector error. If future command forms allow explicit window IDs, resolve the explicit ID exactly or return `stale_target`. #### Adding new action handlers To add a new action to the bridge: 1. Add a variant to `ActionKind` in `crates/local_control/src/protocol.rs`. From 86612f325c9448e45f6bcb0e55d55d3b1b052d0d Mon Sep 17 00:00:00 2001 From: Zachary Lloyd Date: Fri, 22 May 2026 09:49:54 -0600 Subject: [PATCH 05/48] Add warpctrl security architecture documentation Co-Authored-By: Oz --- specs/warp-control-cli/SECURITY.md | 317 +++++++++++++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 specs/warp-control-cli/SECURITY.md diff --git a/specs/warp-control-cli/SECURITY.md b/specs/warp-control-cli/SECURITY.md new file mode 100644 index 0000000000..b390300aa9 --- /dev/null +++ b/specs/warp-control-cli/SECURITY.md @@ -0,0 +1,317 @@ +# warpctrl security architecture +`warpctrl` is a local-control CLI for an already-running Warp app instance. Its security architecture is designed to support the full control catalog: discovery, structural reads, terminal-data reads, non-destructive mutations, settings changes, input manipulation, command execution, and destructive window/tab/pane operations. +The correct architecture is not a single shared localhost bearer token with client-side conventions. The CLI, app bridge, and protocol must treat security as a local app-enforced capability system: discovery finds compatible instances, secure storage protects raw credential material, broker-issued credentials identify the granted scopes, the running Warp app's local-control bridge enforces action tiers before dispatch, and target resolution never silently retargets a request. +The action-tier model is primarily a safety and intent mechanism, not a hard security boundary against malicious same-user software. It lets a user, script, or agent intentionally request read-only or low-risk access so it does not accidentally mutate state or execute commands. It should not be described as strong access control against a process that can already run arbitrary commands as the user. +`warpctrl` must not require the selected Warp instance to be logged in to a Warp account. All request authentication, protocol validation, safety checks, and action dispatch happen locally inside the running app process. Warp cloud services, Firebase identity, team membership, and account login state are not part of the local-control trust model. +## Security goals +- Allow trusted local users and approved automation to control a running Warp instance through a stable, scriptable interface. +- Prevent unauthenticated localhost clients from invoking read or mutating control actions. +- Prevent browser-origin JavaScript from becoming an ambient localhost control client. +- Support multiple running Warp processes without a shared global mutating port or global credential. +- Separate discovery metadata from control authority so enumerating an instance does not automatically grant full control. +- Keep raw credential material out of plaintext discovery records and protect it with platform secure storage where available. +- Support least-privilege safety modes for automation and interactive use without relying on an unenforceable identity label. +- Classify every action by risk tier and enforce the required tier in the local app bridge, not in the CLI frontend. +- Prevent `warpctrl` from becoming an ambient full-power confused deputy that any same-user process can invoke for high-risk actions. +- Preserve deterministic targeting so a request never silently mutates or reads the wrong window, tab, pane, session, file, or Warp Drive object. +- Keep the action surface allowlisted and typed rather than exposing arbitrary internal app dispatch. +- Make high-risk operations auditable and configurable without logging sensitive terminal contents or credentials. +## Trust boundaries +`warpctrl` has several distinct trust boundaries. +### Operating-system user boundary +The baseline local trust boundary is the OS user account. Discovery records and local credential material must be readable only by the owning user. This protects against other local users and network peers, but it does not protect against an already-compromised same-user process. +### Invocation boundary +Same-user does not mean same authority. Interactive use and unattended automation may both run commands under the same user account, but they should be able to intentionally request narrower capabilities. The protocol needs scoped credentials that encode concrete grants, target scopes, and lifetimes rather than an abstract caller type that the bridge cannot reliably verify. +These scoped credentials are guardrails for well-behaved clients. They prevent accidental overreach and make user intent explicit, but they are not a defense against malicious same-user code that can automate the CLI, inspect the user's environment, or wait for user approvals. +### Application identity boundary +On platforms with secure credential storage, especially macOS, the raw local-control credential should be readable only by Warp-owned, correctly signed code. On macOS this means storing raw credential material in Keychain with access constrained by Warp's signing identity, designated requirement, Keychain access group, or equivalent platform mechanism. This narrows token extraction from “any same-user process can read a file” to “only trusted Warp-signed code can unwrap the secret.” +This boundary protects the credential from direct theft, but it does not prove that the user personally intended the specific action. Any same-user process may still be able to invoke the trusted `warpctrl` binary. That confused-deputy risk is handled by scoped credential issuance, action-tier policy, and local app-side bridge enforcement. +### Action boundary +Every action belongs to a risk tier. The bridge must map the requested action to a required tier and compare that tier to the presented credential before selector resolution or handler dispatch. +### Target boundary +A valid credential for one instance or target must not imply authority over another. Credentials should be bound to the issuing Warp instance and may be further scoped to target families such as terminal sessions, files, or Warp Drive objects when those surfaces are exposed. +## Threat model +### In scope +- Other local OS users attempting to control a Warp instance owned by the current user. +- Browser-origin JavaScript attempting to call localhost control endpoints. +- Same-user automation attempting actions without the required scoped grants. +- Same-user processes attempting to extract plaintext credentials from local state. +- Same-user processes invoking `warpctrl` as a confused deputy for actions the process could not authorize directly. +- Stale discovery records from exited Warp processes. +- Multiple running Warp instances where ambiguous selection could target the wrong process. +- Malformed clients attempting unknown, unsupported, unallowlisted, or invalid action payloads. +- Valid clients attempting actions above their granted tier. +- Explicit target IDs that become stale between discovery and execution. +- Future handlers that expose terminal data, settings writes, input mutation, command execution, file intents, or Warp Drive object operations. +### Out of scope +- A malicious process that already has arbitrary same-user filesystem and process access, except that scoped credentials should still reduce accidental over-granting to ordinary automation. +- Kernel, hypervisor, or administrator-level compromise. +- Security semantics for remote URL control endpoints. Remote control requires a separate transport and identity design before it can ship. +## Architecture overview +The security model has six layers: +1. **Discovery:** Find compatible live Warp instances without granting broad authority. +2. **Secure credential storage:** Store raw secrets outside plaintext discovery records and restrict access to trusted Warp-owned code where the platform supports it. +3. **Credential issuance:** Issue scope-specific credentials with explicit grants and lifetimes. +4. **Transport authentication:** Reject unauthenticated requests before reading or mutating app state. +5. **Safety policy:** Enforce requested action tiers and target scopes locally in the app bridge for well-behaved clients. +6. **Deterministic dispatch:** Resolve targets exactly and invoke only allowlisted typed handlers. +```mermaid +sequenceDiagram + participant Invoker as User / Automation + participant CLI as warpctrl + participant Registry as Per-user discovery registry + participant Broker as Credential broker + participant Store as Secure credential storage + participant HTTP as Warp control listener + participant Bridge as App bridge + safety policy + participant UI as Warp app state + + Invoker->>CLI: Invoke allowlisted command + CLI->>Registry: Read instance metadata + Registry-->>CLI: instance_id, endpoint, protocol version, credential reference + CLI->>Broker: Request scoped credential for action + Broker->>Store: Load or unwrap raw secret with Warp-signed access + Store-->>Broker: Raw secret or credential capability + Broker-->>CLI: Scoped credential with grants, scopes, expiry + CLI->>HTTP: Authenticated typed request + HTTP->>Bridge: Verify credential and protocol envelope + Bridge->>Bridge: Check action tier + target scope + alt Denied + Bridge-->>CLI: structured safety-policy error + else Allowed + Bridge->>UI: Resolve target exactly and run allowlisted handler + UI-->>Bridge: typed result or structured target error + Bridge-->>CLI: response envelope + end +``` +## Discovery registry +Each participating Warp process writes a discovery record in a secure per-user local-control directory. Discovery records are metadata, not a full control-authority model. +A discovery record should contain: +- opaque `instance_id`; +- PID and process start timestamp; +- channel and build metadata; +- protocol version and supported capability summary; +- loopback endpoint for the instance-local control listener; +- credential reference or bootstrap credential metadata, not necessarily the full control credential. +Discovery rules: +- Records must be readable only by the owning user. +- POSIX records must use owner-only permissions such as `0600` for files and a non-world-readable directory. +- Windows records must live under the current user's app data directory with ACLs limited to the current user, Administrators, and SYSTEM. +- The CLI must prune or ignore stale records whose PID is gone or whose health/protocol check fails. +- If multiple compatible instances are ambiguous, the CLI must require explicit `--instance` selection. +- Discovery metadata must not expose terminal contents, environment variables, auth tokens for cloud services, raw local-control credentials, or mutating capability grants. +## Credential model +The full `warpctrl` catalog requires scoped credentials. A single shared full-power bearer token is not sufficient once automation, terminal data, command execution, and destructive actions are supported. +### Credential properties +A control credential should encode or reference: +- issuing Warp instance; +- protocol version or accepted version range; +- granted action tiers; +- optional allowed action families; +- optional target restrictions, such as one session, one workspace, one file path, or one Warp Drive object type; +- issued-at time; +- expiry time or process-lifetime binding; +- unique credential ID for revocation and auditing; +- integrity protection so callers cannot forge or widen grants. +### Credential issuance +Warp should issue credentials through an app-owned local broker or equivalent trusted path. The broker decides which grants to issue based on the requested action tier, target scope, user configuration, execution context, and any explicit user approval. +Recommended defaults: +- Commands should start from least privilege and request only the grant needed for the requested action. +- Unattended automation should default to read-only metadata unless policy or an explicit approval grants more. +- Interactive use may receive broader local control only through an intentional approval or configured policy. +- Terminal data reads require an explicit `read_terminal_data` grant. +- Non-destructive mutations require an explicit `mutate_non_destructive` grant. +- Destructive operations, input injection, and command execution require explicit high-risk grants. +- Integrations should receive the narrowest grant needed for the configured workflow. +The broker must not issue broad authority merely because the request came from the signed `warpctrl` binary. It should evaluate the requested action tier, target scope, configured policy, execution context, and whether user approval is required. The CLI must not mint its own authority. It can request, load, and present credentials, but the app bridge remains the enforcement point for these safety grants. +### Safety grants, not strong access control +The tier system should be understood as a user-intent and accident-prevention mechanism: +- A user can ask an agent or script to operate with read-only metadata grants so it can inspect structure but cannot accidentally mutate state. +- A workflow can request terminal-data reads separately from structural metadata reads because terminal contents are more sensitive. +- A script can request non-destructive mutation without also receiving command-execution capability. +- Destructive actions and command execution can require an explicit approval or configured policy so surprising operations pause before they happen. +This model does not make untrusted same-user software safe. A malicious local process may invoke `warpctrl`, simulate user workflows, or use other OS-level capabilities outside `warpctrl`. The tier model is still valuable because it lets honest clients, agents, and scripts constrain themselves and gives Warp a structured point to prompt, deny, or audit risky actions. +### Credential storage +Credential storage should be platform-appropriate: +- Local discovery may store a credential reference rather than the credential itself. +- Raw long-lived credentials should prefer platform-secure storage such as macOS Keychain or Windows Credential Manager when practical. +- On macOS, raw control secrets should be stored in Keychain and restricted to trusted Warp-signed code using a designated requirement, Keychain access group, trusted-application ACL, or equivalent code-signing based mechanism. Restricting by filesystem path alone is insufficient because paths can be replaced or wrapped. +- Keychain item access should include the Warp app, the signed `warpctrl` binary, and any signed Warp-owned local broker/helper that needs to unwrap raw secrets. It should exclude arbitrary same-user applications. +- Short-lived credentials may be stored in owner-only local state if their lifetime and scope are narrow. +- Credentials must never be printed in human-readable output, JSON output, logs, errors, or shell completion data. +### Confused-deputy mitigation +Secure storage prevents arbitrary apps from reading the token; it does not prevent arbitrary apps from asking trusted Warp code to use the token on their behalf. +For example, if `warpctrl` can silently unwrap a full-power credential and execute any action, another same-user process can invoke `warpctrl input run ...` without reading the credential directly. That makes `warpctrl` a confused deputy. +Mitigations: +- Do not give `warpctrl` ambient non-interactive access to an unrestricted full-control credential. +- Prefer action-scoped or session-scoped credentials minted just in time by the broker. +- Require explicit user approval or preconfigured policy for Tier 4 actions and other sensitive grants. +- Distinguish user-approved credential requests from ambient unattended invocations through explicit approval prompts, configured policy, terminal/session context, or narrow credential request flows. +- Bind issued credentials to the requested instance, action tier, optional action family, optional target scope, and short expiry. +- Let `warpctrl` preflight and request credentials, but require the local app bridge to enforce scopes because direct protocol clients can bypass the CLI. +- Make denials structured and non-fatal for automation so callers can request narrower or user-approved grants rather than falling back to unsafe behavior. +## Transport authentication +The default transport is an instance-local loopback listener bound to `127.0.0.1` on an ephemeral per-process port. +Transport requirements: +- Bind only to loopback for local control. +- Do not set permissive CORS headers. +- Authenticate every control request locally in the selected Warp app process before selector resolution or action dispatch. +- Reject missing, malformed, expired, revoked, or invalid credentials with structured authentication errors. +- Keep unauthenticated health metadata minimal and non-sensitive. +- Preserve structured error envelopes so the CLI does not collapse security failures into generic transport errors. +Remote URL support is a separate future transport mode. It should not reuse the local same-user credential model without additional identity, encryption, replay protection, and remote approval/policy design. +## Login independence +Local-control validation is not tied to a logged-in Warp user. The selected Warp app process validates local-control requests using local protocol state: +- discovery records; +- secure local credential references; +- scoped safety grants; +- protocol version and request shape; +- allowlisted actions and typed parameters; +- deterministic target selectors. +The app must not call Warp cloud services to decide whether a local `warpctrl` request is allowed, and it must not require Firebase authentication, team membership, or a non-anonymous Warp account. This keeps scripting and local automation available to logged-out users and offline-capable core terminal workflows. +If a future action depends on cloud-backed state, such as a Warp Drive operation that requires network access, that action can return a state-specific error when unavailable. That should not turn the whole local-control protocol into a logged-in-user feature. +## Safety policy model +Safety grants are enforced in the app bridge after transport authentication and before target resolution or handler dispatch. This provides consistent “do not accidentally do more than requested” behavior for honest clients, not a sandbox for hostile same-user code. +The bridge must: +1. Parse the typed request envelope. +2. Verify protocol version compatibility. +3. Authenticate the credential. +4. Determine granted action tiers and target scopes. +5. Map the requested action to a required tier and action family. +6. Check optional target-family restrictions. +7. Reject requests that exceed the credential's grants with `insufficient_permissions`. +8. Only then resolve selectors and invoke the allowlisted handler. +The CLI frontend may provide helpful preflight errors, but those checks are advisory. Local app-side bridge enforcement is mandatory because other tools can bypass the official CLI and speak the protocol directly. +## Action risk tiers +Every action belongs to exactly one tier. These tiers describe risk and intended safety prompts; they are not a sandbox or a complete OS-level access-control model. +### Tier 1: read-only metadata +Returns app structure or configuration without terminal contents or user data from sessions. +Examples: +- `instance list`, `app active`, `app version`, `app ping`; +- `window list`, `tab list`, `pane list`, `session list`; +- `theme list`; +- allowlisted settings reads that expose configuration but not terminal contents. +Default unattended credentials may include this tier. +### Tier 2: read-only terminal data +Returns potentially sensitive terminal/session data without mutating state. +Examples: +- pane output or scrollback reads; +- current input buffer reads; +- command history reads; +- session replay or transcript reads. +This tier is separate from metadata because terminal content often contains secrets, file paths, command output, customer data, and other sensitive information. +### Tier 3: mutating non-destructive +Changes visible app state in reversible or low-risk ways without executing terminal content or destroying user state. +Examples: +- creating or activating tabs; +- moving, renaming, or coloring tabs; +- creating or focusing windows; +- splitting, focusing, navigating, maximizing, or resizing panes; +- theme, font, zoom, and allowlisted non-destructive settings changes; +- opening panels, palettes, and user-facing surfaces. +### Tier 4: mutating destructive or high-risk +Can destroy active work, inject terminal input, execute commands, or run user-authored content. +Examples: +- closing windows, tabs, panes, or sessions; +- clearing, replacing, or inserting terminal input; +- command execution in a session; +- switching input modes when it can change execution behavior; +- executing Warp Drive workflows or notebooks in a terminal session; +- broad Warp Drive object mutation. +This tier should require explicit user or policy approval for unattended automation and integrations. +## Target scoping and deterministic resolution +Targeting is part of security. The protocol must not convert ambiguous or stale selectors into best-effort mutations. +Rules: +- Instance selection happens before request dispatch and must be explicit when ambiguous. +- `active` selectors may be ergonomic defaults only when the active target is unambiguous. +- If no active target exists for a mutating request, return `missing_target` or `invalid_selector`. +- Explicit opaque IDs must resolve exactly or return `stale_target`. +- Index selectors must resolve to concrete IDs before execution and must not race into a different target silently. +- Session-scoped requests against non-terminal panes return `target_state_conflict`. +- File selectors use paths and must remain distinct from opaque UI IDs. +- Warp Drive selectors must include object type and resolve by opaque ID for automation stability, with name/path lookup only as an interactive convenience. +Target restrictions in credentials should be checked before invoking handlers. For example, a credential scoped to one session must not read another session's output even if the CLI can discover that session ID. +## Allowlisted handlers +The protocol must not expose arbitrary internal app actions by string. +Each supported command requires: +- a typed protocol action; +- typed parameters; +- validation rules; +- a documented risk tier; +- local app-side safety-grant checks; +- deterministic target resolution; +- a handler that reuses existing user-visible app behavior where possible; +- typed success and error responses. +Adding a new action should be additive and reviewable: extend the protocol enum, implement validation, map the action to a risk tier, add a handler, and add tests for authentication, safety-policy denial, selector failure, and success behavior. +## Browser and localhost protections +Loopback is not sufficient by itself because browsers can send requests to localhost. +Required protections: +- No permissive CORS on control endpoints. +- No JSONP or browser-readable fallback formats. +- Valid scoped credentials required for all sensitive endpoints. +- Credentials stored outside browser-readable locations. +- Preflight and error responses must not reveal credentials or sensitive target state. +- The protocol should avoid GET endpoints for mutating actions. +The control plane should assume a malicious webpage can guess common localhost ports and send blind requests. It should not be able to read discovery records or obtain credentials. +## Auditing and logging +High-risk action support should include auditability without leaking sensitive data. +Recommended audit fields: +- timestamp; +- instance ID; +- credential ID or grant profile; +- action name and risk tier; +- target type and opaque target ID when safe; +- success or structured error code. +Avoid logging: +- bearer tokens or scoped credentials; +- terminal output; +- command text for command execution unless explicitly approved by policy; +- input buffer contents; +- Warp Drive object contents; +- environment variable values. +Error-level logs should be used only for conditions that need developer attention, not normal denied requests or user-caused selector failures. +## Security- and safety-relevant errors +Structured errors are part of the security contract. +Important errors include: +- `unauthorized_local_client` for missing, malformed, expired, revoked, or invalid credentials; +- `insufficient_permissions` for valid credentials that lack the requested safety tier or target scope; +- `ambiguous_instance` when multiple compatible instances cannot be resolved safely; +- `invalid_selector` for malformed or unsupported selector syntax; +- `missing_target` when an active/default target does not exist; +- `stale_target` when an explicit target ID no longer exists; +- `unsupported_action` for actions not implemented by the selected instance; +- `not_allowlisted` for actions intentionally excluded from the public control surface; +- `invalid_params` for malformed parameters; +- `target_state_conflict` when the target exists but cannot support the requested action. +The app must not downgrade these failures into broader default actions, and the CLI must preserve structured server errors in both human-readable and JSON output. +## Required controls before full catalog expansion +Before shipping each action family, verify that these controls are implemented for that family: +- The action has a documented tier. +- The bridge maps the action to that tier locally in the selected Warp app process. +- The credential model can express the required grant. +- The handler checks optional target restrictions where relevant. +- Requests with invalid credentials or insufficient safety grants fail before selector resolution or mutation. +- The action does not require a logged-in Warp account unless the action itself inherently depends on cloud-backed state. +- Ambiguous, missing, and stale targets return structured errors. +- Tests cover allowed, insufficient-permission, and denied credential paths. +- Logs and errors do not expose credentials, terminal contents, command text, or sensitive settings. +- Operator docs distinguish available commands from planned catalog entries. +## Platform requirements +### macOS and Linux +Discovery files must be stored in a per-user directory with owner-only permissions. +On macOS, raw credential material should live in Keychain, not in the discovery record. Keychain access should be constrained to Warp-owned signed binaries or helpers using code-signing based access control. The discovery record should hold only metadata and a credential reference. +On Linux, raw credentials should prefer platform-secure storage where available; otherwise short-lived scoped credentials may live in owner-only local state with strict file and directory permissions. +Unix domain sockets with peer credential checks may be considered for stronger same-machine identity than bearer tokens alone. +### Windows +Discovery records and credential material must live under the current user's app data directory with ACLs restricted to the current user, Administrators, and SYSTEM. +Windows support for authenticated local control should not be considered complete until the implementation creates, validates, and tests those ACLs. +## Remote control is separate +The local architecture intentionally assumes same-machine, same-user control over a loopback listener. Future remote URLs must use a different security design that includes: +- transport encryption; +- remote identity and authentication; +- replay protection; +- explicit user or admin approval/policy; +- network exposure review; +- separate credential issuance from local discovery; +- remote-safe auditing and revocation. +Remote support should not be enabled by simply allowing `warpctrl` to point the existing local credential at an arbitrary URL. From 4dc7cc1ebbe6855878f76b9b5a3b7213efc27a33 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd Date: Fri, 22 May 2026 09:59:56 -0600 Subject: [PATCH 06/48] Update warpctrl security enablement model Co-Authored-By: Oz --- specs/warp-control-cli/SECURITY.md | 122 ++++++++++++++++++++++++++--- 1 file changed, 111 insertions(+), 11 deletions(-) diff --git a/specs/warp-control-cli/SECURITY.md b/specs/warp-control-cli/SECURITY.md index b390300aa9..ef9aecfebf 100644 --- a/specs/warp-control-cli/SECURITY.md +++ b/specs/warp-control-cli/SECURITY.md @@ -9,6 +9,8 @@ The action-tier model is primarily a safety and intent mechanism, not a hard sec - Prevent browser-origin JavaScript from becoming an ambient localhost control client. - Support multiple running Warp processes without a shared global mutating port or global credential. - Separate discovery metadata from control authority so enumerating an instance does not automatically grant full control. +- Require explicit in-app user enablement before local control scripting can issue credentials or accept control requests. +- Store the authoritative enablement state in protected local storage so external apps cannot enable local control by editing ordinary settings. - Keep raw credential material out of plaintext discovery records and protect it with platform secure storage where available. - Support least-privilege safety modes for automation and interactive use without relying on an unenforceable identity label. - Classify every action by risk tier and enforce the required tier in the local app bridge, not in the CLI frontend. @@ -16,6 +18,86 @@ The action-tier model is primarily a safety and intent mechanism, not a hard sec - Preserve deterministic targeting so a request never silently mutates or reads the wrong window, tab, pane, session, file, or Warp Drive object. - Keep the action surface allowlisted and typed rather than exposing arbitrary internal app dispatch. - Make high-risk operations auditable and configurable without logging sensitive terminal contents or credentials. +## Meaningful security boundaries +The most important security boundary is preventing control from places that should have no ambient authority over the user's Warp instance: +- arbitrary web apps running in a browser; +- other OS users on the same machine; +- unauthenticated clients that discover or guess the localhost control port; +- stale discovery records from exited Warp processes; +- malformed or unallowlisted direct protocol calls. +The local-control design can provide meaningful protection for those cases by binding only to loopback, avoiding permissive CORS, requiring local credentials, keeping credentials out of browser-readable and world-readable locations, pruning stale records, and validating every request in the local Warp app process. +The boundary is much weaker for a different local app running as the same OS user. Same-user local apps may already have access to user-owned files such as logs, may be able to observe the screen or UI through OS permissions such as Accessibility or Screen Recording, and can often invoke user-installed command-line tools. `warpctrl` should not imply strong isolation from such software. +For same-user local apps, the realistic goal is narrower: +- do not leave a raw bearer token in plaintext discovery records; +- prevent arbitrary direct HTTP calls to the localhost control listener by requiring a credential those apps cannot simply read; +- use platform secure storage, such as macOS Keychain, so raw credentials are accessible only to Warp-owned signed code where practical; +- make high-risk operations go through `warpctrl` or a Warp-owned helper where user approval, configured policy, and safety grants can be applied; +- avoid giving `warpctrl` ambient non-interactive full-control authority. +In other words, the security model can make arbitrary direct localhost protocol calls fail, and it can make direct credential theft harder. It cannot make a same-user malicious app safe if that app can invoke `warpctrl`, automate the user's desktop, read other local state, or wait for the user to approve prompts. +## Comparison with other local scripting models +Other developer tools expose local automation through a few recurring patterns. The `warpctrl` design should borrow the parts that match Warp's needs while avoiding designs that assume localhost or same-user access is enough by itself. +### VS Code +VS Code's `code` command is primarily a launch and routing CLI: it opens files, folders, diffs, merge views, chat sessions, extension-management commands, and remote/tunnel workflows. It is not a general unauthenticated localhost API for arbitrary UI control of an already-running desktop app. +VS Code's richer local automation runs through extension APIs and extension hosts. Extensions are installed into a trusted editor environment and run with broad access to the workspace or UI side depending on extension kind. Workspace Trust and remote extension placement help users reason about whether code should run locally, remotely, or in a browser sandbox, but they do not create a fine-grained same-user security boundary against arbitrary local software. +Lessons for `warpctrl`: +- a narrow, typed CLI command surface is safer to reason about than exposing arbitrary internal app commands; +- agent and script workflows should request explicit capabilities instead of inheriting ambient full-control authority; +- local UI control should remain distinct from remote/tunnel control because remote transports need stronger identity, approval, and network-security semantics. +### Chrome DevTools Protocol +Chrome DevTools Protocol is a powerful debugging and automation API. When Chrome is launched with remote debugging enabled, clients can discover targets over local HTTP endpoints and then control the browser over WebSocket. That protocol is intentionally high-power: it can inspect pages, navigate, execute JavaScript, observe network state, and interact with browser storage. +Chrome's security history is a useful warning for `warpctrl`: a local debugging port is dangerous if it becomes reachable by unexpected clients. Recent Chrome versions restrict remote debugging against the default user data directory and recommend isolated user data directories for automation, because debugging a real browser profile can expose sensitive cookies and credentials. Chrome also distinguishes command-line remote debugging from user-confirmed debugging flows. +Lessons for `warpctrl`: +- loopback binding is necessary but not sufficient; +- unauthenticated localhost endpoints should not expose powerful state or mutation; +- browser-origin protections matter because web pages can attempt localhost requests; +- high-power automation should prefer explicit, isolated, user-approved, or short-lived authority over a reusable full-profile control channel. +### Ghostty and macOS AppleScript +Ghostty exposes platform-native scripting on macOS through AppleScript. That model relies on macOS Automation/TCC prompts to decide whether one app may control another app, and Ghostty can disable AppleScript entirely with configuration. This is a good fit for macOS-native scripting, but it is platform-specific and inherits the limits of OS automation permission: once an app is allowed to automate another app, the boundary is not a per-action capability system. +Ghostty also supports terminal-oriented features such as shell integration and command-line window creation flows. Those are useful local automation conveniences, but they are not a general cross-platform authenticated control protocol with scoped credentials. +Lessons for `warpctrl`: +- use platform security mechanisms where they exist, such as macOS Keychain and Automation prompts; +- keep a user-visible kill switch or policy path for scripting/control surfaces; +- do not rely only on platform automation permission if Warp needs cross-platform, action-scoped safety grants. +### iTerm2 Python API +iTerm2's Python API is a close comparison for terminal automation. The API is disabled by default. When enabled, iTerm2 listens on a Unix domain socket and requires authentication by default. Scripts launched by iTerm2 receive a random cookie in the environment, while external programs can request a cookie through AppleScript so macOS Automation permission mediates access. iTerm2 also documents an administrator-gated escape hatch to allow unauthenticated local apps. +This model directly acknowledges that terminal contents are sensitive and that any local automation API can affect local and remote hosts connected through terminal sessions. +Lessons for `warpctrl`: +- default-off or policy-controlled high-power automation is reasonable for sensitive capabilities; +- random local credentials are useful, but the path that grants or unwraps them is just as important as the token itself; +- terminal-data reads and input/command execution should be treated as higher-risk than structural metadata reads; +- macOS Automation can be part of the approval path, but Warp still needs local app-side enforcement because direct protocol clients can bypass the official CLI. +### tmux +tmux is a useful lower-level comparison because its clients and server communicate through local sockets. The default socket lives in a per-user directory under `/tmp`, and that directory must not be world readable, writable, or executable. tmux control mode then exposes a text protocol where clients can issue normal tmux commands and receive asynchronous pane/session notifications. Newer tmux versions also have explicit server-access controls for sharing across users. +tmux's model is mostly an OS-user and socket-permission model. Once a client can access the socket with write authority, it can generally control the session. Read-only modes are useful operational guardrails but are not a reason to trust untrusted users or processes with the socket. +Lessons for `warpctrl`: +- per-user discovery directories and sockets protect meaningfully against other OS users; +- structured control protocols are scriptable and durable, but broad socket access quickly becomes broad control access; +- read-only and low-risk modes are valuable “do not accidentally interfere” controls, not a complete hostile-client sandbox. +### Overall direction for `warpctrl` +Compared with these systems, `warpctrl` should combine: +- tmux-style local filesystem/socket hygiene for protecting against other OS users; +- Chrome's lesson that local debugging/control endpoints need authentication and browser-origin hardening; +- iTerm2's use of explicit local credentials and macOS Automation-style approval for external control; +- Ghostty's use of platform-native scripting controls where available; +- VS Code's preference for typed public commands and separate treatment of remote control. +The resulting architecture should not claim to solve arbitrary same-user malicious-app isolation. Its real value is preventing web-origin and other-user access, preventing unauthenticated direct localhost calls, keeping raw credentials out of plaintext discovery, giving honest scripts and agents narrow safety grants, and routing high-risk operations through local Warp app validation and user/policy approval. +## Authoritative enablement model +Local control scripting must be explicitly enabled by the user inside the Warp app before `warpctrl` can control a running instance. The setting should be visible in Warp's settings UI as something like “Allow local control scripting,” and it should default to off. +The visible UI setting is not enough by itself. The authoritative enablement state must be stored in protected local storage that ordinary same-user apps cannot update by writing a plist, settings database row, registry key, JSON file, or synced cloud preference. This avoids turning local control into a feature that any process can silently enable before invoking `warpctrl`. +Enablement requirements: +- The setting is local-only and must not sync through Settings Sync, Warp Drive, or server-backed user preferences. +- Only the running Warp app, through an in-app user action, should be able to enable or disable the authoritative state. +- `warpctrl`, shell scripts, config files, command-line flags, registry edits, defaults writes, and direct local-control protocol requests must not be able to enable the setting. +- The app should require an intentional user gesture before enabling the setting, and the UI should explain that it allows local scripts and approved automation to control Warp. +- The setting should be easy to disable from the same UI, and disabling it should revoke or invalidate active local-control credentials. +- If enterprise or managed-device policy is added later, policy may force-disable local control or allow an administrator-controlled default, but policy should be separate from user-editable local settings. +Disabled-state behavior: +- Warp should not mint scoped local-control credentials while local control scripting is disabled. +- The control listener should either not start or should reject all sensitive requests with a structured disabled-state error before authentication, selector resolution, or handler dispatch. +- Discovery records should avoid publishing actionable endpoint or credential-reference metadata while disabled. If a minimal record is needed for UX, it should expose only non-sensitive status such as `local_control_enabled: false`. +- `warpctrl` may detect the disabled state and print instructions to enable local control in Warp settings, but it must not offer a command that flips the setting. +- Previously issued credentials must become unusable when local control is disabled, even if their original expiry has not elapsed. +This enablement gate does not create perfect same-user malicious-app isolation. A hostile process with Accessibility or Screen Recording permission might still try to automate the Warp UI. The gate is still important because it closes the much easier paths where external apps silently edit local preferences, call a config CLI, or write synced settings to enable a powerful control surface. ## Trust boundaries `warpctrl` has several distinct trust boundaries. ### Operating-system user boundary @@ -25,7 +107,7 @@ Same-user does not mean same authority. Interactive use and unattended automatio These scoped credentials are guardrails for well-behaved clients. They prevent accidental overreach and make user intent explicit, but they are not a defense against malicious same-user code that can automate the CLI, inspect the user's environment, or wait for user approvals. ### Application identity boundary On platforms with secure credential storage, especially macOS, the raw local-control credential should be readable only by Warp-owned, correctly signed code. On macOS this means storing raw credential material in Keychain with access constrained by Warp's signing identity, designated requirement, Keychain access group, or equivalent platform mechanism. This narrows token extraction from “any same-user process can read a file” to “only trusted Warp-signed code can unwrap the secret.” -This boundary protects the credential from direct theft, but it does not prove that the user personally intended the specific action. Any same-user process may still be able to invoke the trusted `warpctrl` binary. That confused-deputy risk is handled by scoped credential issuance, action-tier policy, and local app-side bridge enforcement. +This boundary protects the credential from direct theft and prevents arbitrary apps from making authenticated raw HTTP requests to the local-control listener. It also lets the authoritative enablement state be stored somewhere harder to modify than ordinary user preferences. It does not prove that the user personally intended the specific action. Any same-user process may still be able to invoke the trusted `warpctrl` binary or automate the Warp UI. That confused-deputy risk is reduced by explicit in-app enablement, scoped credential issuance, action-tier policy, and local app-side bridge enforcement, but it is not eliminated as a hard same-user security boundary. ### Action boundary Every action belongs to a risk tier. The bridge must map the requested action to a required tier and compare that tier to the presented credential before selector resolution or handler dispatch. ### Target boundary @@ -48,18 +130,20 @@ A valid credential for one instance or target must not imply authority over anot - Kernel, hypervisor, or administrator-level compromise. - Security semantics for remote URL control endpoints. Remote control requires a separate transport and identity design before it can ship. ## Architecture overview -The security model has six layers: -1. **Discovery:** Find compatible live Warp instances without granting broad authority. -2. **Secure credential storage:** Store raw secrets outside plaintext discovery records and restrict access to trusted Warp-owned code where the platform supports it. -3. **Credential issuance:** Issue scope-specific credentials with explicit grants and lifetimes. -4. **Transport authentication:** Reject unauthenticated requests before reading or mutating app state. -5. **Safety policy:** Enforce requested action tiers and target scopes locally in the app bridge for well-behaved clients. -6. **Deterministic dispatch:** Resolve targets exactly and invoke only allowlisted typed handlers. +The security model has seven layers: +1. **Protected enablement:** Require explicit in-app opt-in backed by protected local storage before local control is available. +2. **Discovery:** Find compatible live Warp instances without granting broad authority. +3. **Secure credential storage:** Store raw secrets outside plaintext discovery records and restrict access to trusted Warp-owned code where the platform supports it. +4. **Credential issuance:** Issue scope-specific credentials with explicit grants and lifetimes only when local control is enabled. +5. **Transport authentication:** Reject disabled or unauthenticated requests before reading or mutating app state. +6. **Safety policy:** Enforce requested action tiers and target scopes locally in the app bridge for well-behaved clients. +7. **Deterministic dispatch:** Resolve targets exactly and invoke only allowlisted typed handlers. ```mermaid sequenceDiagram participant Invoker as User / Automation participant CLI as warpctrl participant Registry as Per-user discovery registry + participant Enablement as Protected enablement state participant Broker as Credential broker participant Store as Secure credential storage participant HTTP as Warp control listener @@ -69,7 +153,13 @@ sequenceDiagram Invoker->>CLI: Invoke allowlisted command CLI->>Registry: Read instance metadata Registry-->>CLI: instance_id, endpoint, protocol version, credential reference + CLI->>Enablement: Check local control enabled status + Enablement-->>CLI: Enabled or disabled + alt Disabled + CLI-->>Invoker: local control disabled; enable in Warp settings + else Enabled CLI->>Broker: Request scoped credential for action + Broker->>Enablement: Verify protected enablement state Broker->>Store: Load or unwrap raw secret with Warp-signed access Store-->>Broker: Raw secret or credential capability Broker-->>CLI: Scoped credential with grants, scopes, expiry @@ -83,6 +173,7 @@ sequenceDiagram UI-->>Bridge: typed result or structured target error Bridge-->>CLI: response envelope end + end ``` ## Discovery registry Each participating Warp process writes a discovery record in a secure per-user local-control directory. Discovery records are metadata, not a full control-authority model. @@ -97,6 +188,7 @@ Discovery rules: - Records must be readable only by the owning user. - POSIX records must use owner-only permissions such as `0600` for files and a non-world-readable directory. - Windows records must live under the current user's app data directory with ACLs limited to the current user, Administrators, and SYSTEM. +- When local control scripting is disabled, records must not publish actionable control endpoints or credential references. A minimal disabled-status record is acceptable only if it contains no authority. - The CLI must prune or ignore stale records whose PID is gone or whose health/protocol check fails. - If multiple compatible instances are ambiguous, the CLI must require explicit `--instance` selection. - Discovery metadata must not expose terminal contents, environment variables, auth tokens for cloud services, raw local-control credentials, or mutating capability grants. @@ -116,6 +208,7 @@ A control credential should encode or reference: ### Credential issuance Warp should issue credentials through an app-owned local broker or equivalent trusted path. The broker decides which grants to issue based on the requested action tier, target scope, user configuration, execution context, and any explicit user approval. Recommended defaults: +- Credential issuance is unavailable unless the protected in-app enablement state says local control scripting is enabled. - Commands should start from least privilege and request only the grant needed for the requested action. - Unattended automation should default to read-only metadata unless policy or an explicit approval grants more. - Interactive use may receive broader local control only through an intentional approval or configured policy. @@ -134,6 +227,7 @@ This model does not make untrusted same-user software safe. A malicious local pr ### Credential storage Credential storage should be platform-appropriate: - Local discovery may store a credential reference rather than the credential itself. +- The authoritative local-control enablement state should use the same class of protected local storage as raw credential material, but it should be accessible to the Warp app for in-app settings UI and not writable by `warpctrl` or arbitrary external apps. - Raw long-lived credentials should prefer platform-secure storage such as macOS Keychain or Windows Credential Manager when practical. - On macOS, raw control secrets should be stored in Keychain and restricted to trusted Warp-signed code using a designated requirement, Keychain access group, trusted-application ACL, or equivalent code-signing based mechanism. Restricting by filesystem path alone is insufficient because paths can be replaced or wrapped. - Keychain item access should include the Warp app, the signed `warpctrl` binary, and any signed Warp-owned local broker/helper that needs to unwrap raw secrets. It should exclude arbitrary same-user applications. @@ -150,11 +244,13 @@ Mitigations: - Bind issued credentials to the requested instance, action tier, optional action family, optional target scope, and short expiry. - Let `warpctrl` preflight and request credentials, but require the local app bridge to enforce scopes because direct protocol clients can bypass the CLI. - Make denials structured and non-fatal for automation so callers can request narrower or user-approved grants rather than falling back to unsafe behavior. +These mitigations are about routing high-risk operations through intentional `warpctrl` flows rather than exposing a reusable localhost token to any process. They should not be documented as a guarantee that arbitrary same-user applications cannot cause Warp-visible actions. ## Transport authentication The default transport is an instance-local loopback listener bound to `127.0.0.1` on an ephemeral per-process port. Transport requirements: - Bind only to loopback for local control. - Do not set permissive CORS headers. +- Reject control requests while local control scripting is disabled, even if the request presents an otherwise valid credential. - Authenticate every control request locally in the selected Warp app process before selector resolution or action dispatch. - Reject missing, malformed, expired, revoked, or invalid credentials with structured authentication errors. - Keep unauthenticated health metadata minimal and non-sensitive. @@ -273,6 +369,7 @@ Error-level logs should be used only for conditions that need developer attentio ## Security- and safety-relevant errors Structured errors are part of the security contract. Important errors include: +- `local_control_disabled` when the user has not enabled local control scripting in Warp settings or has disabled it after credentials were issued; - `unauthorized_local_client` for missing, malformed, expired, revoked, or invalid credentials; - `insufficient_permissions` for valid credentials that lack the requested safety tier or target scope; - `ambiguous_instance` when multiple compatible instances cannot be resolved safely; @@ -286,6 +383,8 @@ Important errors include: The app must not downgrade these failures into broader default actions, and the CLI must preserve structured server errors in both human-readable and JSON output. ## Required controls before full catalog expansion Before shipping each action family, verify that these controls are implemented for that family: +- Local control scripting must be explicitly enabled in Warp's app UI before the action family can run. +- The authoritative enablement state is protected from external writes and is local-only rather than synced. - The action has a documented tier. - The bridge maps the action to that tier locally in the selected Warp app process. - The credential model can express the required grant. @@ -299,12 +398,13 @@ Before shipping each action family, verify that these controls are implemented f ## Platform requirements ### macOS and Linux Discovery files must be stored in a per-user directory with owner-only permissions. -On macOS, raw credential material should live in Keychain, not in the discovery record. Keychain access should be constrained to Warp-owned signed binaries or helpers using code-signing based access control. The discovery record should hold only metadata and a credential reference. -On Linux, raw credentials should prefer platform-secure storage where available; otherwise short-lived scoped credentials may live in owner-only local state with strict file and directory permissions. +On macOS, raw credential material and the authoritative local-control enablement state should live in Keychain, not in the discovery record or an ordinary preferences file. Keychain access should be constrained to Warp-owned signed binaries or helpers using code-signing based access control. The enablement state should be writable by the Warp app's in-app settings flow and not writable by `warpctrl`. The discovery record should hold only metadata and a credential reference when local control is enabled. +On Linux, raw credentials and the authoritative enablement state should prefer platform-secure storage where available; otherwise short-lived scoped credentials may live in owner-only local state with strict file and directory permissions. If the enablement state falls back to owner-only local state, the weaker same-user protection should be documented. Unix domain sockets with peer credential checks may be considered for stronger same-machine identity than bearer tokens alone. ### Windows Discovery records and credential material must live under the current user's app data directory with ACLs restricted to the current user, Administrators, and SYSTEM. -Windows support for authenticated local control should not be considered complete until the implementation creates, validates, and tests those ACLs. +The authoritative enablement state should use Credential Manager, DPAPI-backed protected storage, or an equivalent app-controlled protected store rather than a normal registry setting that arbitrary same-user processes can write. +Windows support for authenticated local control should not be considered complete until the implementation creates, validates, and tests those ACLs and the protected enablement-state behavior. ## Remote control is separate The local architecture intentionally assumes same-machine, same-user control over a loopback listener. Future remote URLs must use a different security design that includes: - transport encryption; From e4bb06b513715ad10927f691ccb71cae6b7b5350 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd Date: Fri, 22 May 2026 10:23:35 -0600 Subject: [PATCH 07/48] Update warpctrl permission model Co-Authored-By: Oz --- specs/warp-control-cli/PRODUCT.md | 91 ++++++++++++++++---------- specs/warp-control-cli/SECURITY.md | 100 +++++++++++++++++++++-------- specs/warp-control-cli/TECH.md | 95 ++++++++++++++++++++++----- 3 files changed, 213 insertions(+), 73 deletions(-) diff --git a/specs/warp-control-cli/PRODUCT.md b/specs/warp-control-cli/PRODUCT.md index a2a7920d98..6c00dead5e 100644 --- a/specs/warp-control-cli/PRODUCT.md +++ b/specs/warp-control-cli/PRODUCT.md @@ -152,7 +152,7 @@ Non-goals: - Result data or structured error data. 29. The protocol is versioned. Clients must be able to determine whether a running Warp process supports the protocol version and action they intend to call. 30. Multiple running Warp processes are a supported normal case, not an error state. A local stable build and local dev build, or multiple supported local app instances, can coexist; the CLI provides deterministic discovery and addressing rather than assuming one global server. -31. Requests should be scoped to local-user control of the running app. A command that fails authentication or local authorization reports that condition explicitly and does not degrade into a less-specific request. +31. Requests should be scoped to local-user control of the running app, with separate enforcement for actions that require a true logged-in Warp user. A command that fails local authentication, local authorization, execution-context checks, or authenticated-user checks reports that condition explicitly and does not degrade into a less-specific request. 32. If a selected action is valid in general but impossible in the current UI state, the CLI reports a state-specific failure. Examples include: - Splitting a pane that no longer exists. - Running a session-scoped command against a non-terminal pane. @@ -167,47 +167,72 @@ Non-goals: 34. Follow-up PRs should fill out the remaining catalog in parallelizable groups once the protocol, discovery model, target resolution, error model, `tab.create` action path, and standalone `warpctrl` packaging shape have been validated by the first slice. 35. The protocol transport should be designed so that the default target is localhost but the CLI can be extended in the future to target remote URLs (e.g., a Warp instance on another machine or a hosted control endpoint). This is not in scope for the first implementation but should not be precluded by the architecture. ## Action classification and permission model -Agents (Oz cloud agents, local agent mode, and third-party automation) are expected to be major consumers of the warpctrl CLI alongside human developers. The action catalog must support differentiated permission policies for human callers versus agent callers, and must clearly classify every action by its risk profile so that Warp can enforce appropriate guardrails at both the protocol and product level. +Agents, scripts, and human developers are expected to be major consumers of `warpctrl`. The action catalog must therefore classify every action by both risk tier and authenticated-user requirement so Warp can enforce local-control permissions in the app bridge. +Every action definition must include: +- a stable action name and namespace; +- a risk tier; +- whether a true logged-in Warp user is required; +- whether the action may run from external clients, verified Warp-terminal clients, or both; +- the required local-control permission category; +- any target-scope restrictions. +By default, new actions require an authenticated Warp user. An action may be marked logged-out-safe only after deliberate review confirms it does not touch Warp Drive, AI conversation traces, synced settings, team/account data, cloud-backed user state, terminal content, or other user-sensitive data. ### Classification tiers Every action in the catalog belongs to exactly one of the following tiers, from least to most sensitive: -1. **Read-only / metadata.** Actions that return app-level structural information without exposing terminal content. These are safe for any caller and should never require elevated permission. +1. **Read-only / metadata.** Actions that return local app structure or configuration without exposing terminal content or user-authenticated data. - Instance discovery and health: `instance list`, `app active`, `app version`, `app ping`. - Layout enumeration: `window list`, `tab list`, `pane list`, `session list`. - - Appearance/settings reads that return configuration values but not user data: `theme list`, `setting list`, `setting get`. -2. **Read-only / terminal data.** Actions that return content from a user's terminal sessions, command history, pane output buffers, or input editor state. These expose potentially sensitive user data and must be gated separately from structural metadata reads. - - Reading pane output or scrollback content (when implemented). + - Appearance reads that return local configuration values but not user data: `theme list`, selected `setting get` actions for logged-out-safe local settings. + These are the primary default actions for external clients. +2. **Read-only / terminal data.** Actions that return content from terminal sessions, command history, pane output buffers, input editor state, session replay, or terminal-derived traces. + - Reading pane output or scrollback content. - Reading the current input buffer contents. - Reading command history or session replay data. - Even though these are read-only, they cross a privacy boundary that metadata reads do not. An agent that can enumerate tabs should not automatically be able to read terminal output. -3. **Mutating / non-destructive.** Actions that change app state in ways that are visible but reversible or low-risk. They do not destroy user data or execute arbitrary commands. + Even though these are read-only, they cross a privacy boundary that metadata reads do not. +3. **Mutating / non-destructive.** Actions that change app state in visible, reversible, or low-risk ways without executing terminal content or destroying user state. - Layout mutations: `tab create`, `tab activate`, `tab move`, `tab rename`, `window create`, `window focus`, `pane split`, `pane focus`, `pane navigate`, `pane maximize`, `pane resize`. - Appearance mutations: `theme set`, `font-size increase/decrease/reset`, `zoom increase/decrease/reset`. - - Settings writes for allowlisted non-destructive settings: `setting set`, `setting toggle`. - - Panel/surface toggles: settings open, command palette, Warp Drive toggle, AI assistant toggle, etc. -4. **Mutating / destructive or high-risk.** Actions that destroy user state, close active work, or execute arbitrary content in a terminal session. These require the strongest permission gates and explicit review before agent use. + - Settings writes for allowlisted non-destructive local settings. + - Panel/surface toggles where they do not expose authenticated user data. +4. **Mutating / destructive or high-risk.** Actions that destroy user state, close active work, inject terminal input, execute commands, or execute user-authored content. - Closing targets: `tab close`, `window close`, `pane close`. - Terminal input injection: `input insert`, `input replace`, `input clear`. - - Command execution in a session (when implemented). + - Command execution in a session. - Input mode switching between terminal and agent modes. - Any action that can cause data loss (closing an unsaved session) or execute arbitrary code (injecting and running a shell command) belongs here regardless of how simple the API looks. -### Permission policies -The protocol and product should support per-caller permission policies keyed to these tiers: -- **Human interactive use** defaults to full access across all tiers, gated only by local authentication (the bearer token). -- **Agent use** should default to read-only metadata access and require explicit opt-in for each higher tier. The product should support: - - A baseline "read-only metadata" grant that lets agents discover and enumerate without accessing terminal content or mutating state. - - A "read terminal data" grant that additionally permits reading pane output, input buffers, and session content. - - A "mutate non-destructive" grant that additionally permits layout and appearance changes. - - A "mutate destructive" grant that additionally permits closing targets, injecting input, and executing commands. -- The permission model should be expressible in the protocol (e.g., a capability or scope field in the authentication material) so that the app bridge can enforce it server-side, not just client-side. -- When an agent attempts an action above its granted tier, the bridge should return a structured `insufficient_permissions` error that identifies the required tier, rather than silently downgrading or returning a generic failure. -### Scoped credentials and caller identity -The local discovery bearer token is not sufficient for differentiated human vs. agent permissions. If the same full-access token is readable by both humans and agents, the server cannot enforce agent-specific read-only defaults. Before agent-facing read/write or destructive actions ship, the product must introduce scoped credentials. -- The discovery record may expose a human CLI credential for same-user interactive use, but agent runtimes should not receive that full-access credential by default. -- Agent callers should receive a separate scoped credential minted by Warp or by a trusted local broker. The credential must identify the caller class (`human`, `local_agent`, `cloud_agent`, `third_party_automation`) and granted permission tiers. -- Scoped credentials should be bound to a specific Warp instance, protocol version, issued-at time, optional expiry time, and allowed action tiers. The bridge must validate those fields before action dispatch. -- The bridge, not the CLI frontend, enforces permission tiers by mapping every `ActionKind` to a required tier and comparing that tier to the credential's grants. -- If a process presents the human discovery credential, the bridge treats it as human interactive use. If a process presents an agent credential, the bridge applies the agent policy even if the process runs under the same local user account. -- The first implementation slice may ship a same-user human credential only because it implements discovery and `tab create`; any slice that exposes terminal data, input mutation, command execution, or other agent-consumable automation must add scoped credential issuance first. + - Executing Warp Drive workflows or notebooks in a terminal session. + Any action that can cause data loss or execute arbitrary code belongs here regardless of how simple the API looks. +### Authenticated-user requirement +An authenticated user means a true logged-in Warp user in the selected Warp app, not merely the local OS user or a `warpctrl` process authenticated to localhost. +The allowlist must clearly indicate `requires_authenticated_user` for every action: +- `false` only for logged-out-safe actions that operate on local app structure, local appearance metadata, or local-only settings that do not expose user-sensitive data. +- `true` for actions that read or mutate Warp Drive, AI conversation traces, synced settings, team/account data, user identity data, or any cloud-backed Warp state. +- `true` for actions that execute user-authored Warp Drive content, even if the execution target is a local terminal session. +If an authenticated-user action is invoked while the selected app has no logged-in user, the CLI reports a structured authenticated-user error. It must not silently return partial logged-out data as success. +### Execution context policy +`warpctrl` should distinguish verified invocations from inside Warp-managed terminal sessions from external invocations. +- **External invocation:** a `warpctrl` process started outside Warp's terminal. By default, it can receive only the smaller logged-out-safe local action set that does not touch user-authenticated data. Higher tiers require explicit local-control settings or approvals. +- **Verified Warp-terminal invocation:** a `warpctrl` process started inside a Warp-managed terminal session and able to present an app-issued execution-context proof. When the selected app has a logged-in Warp user, this context can receive authenticated-user grants if the user enabled that permission. +- The app must not trust a caller-declared label. Environment variables may help discover the context, but the broker must verify a session-bound capability or equivalent proof before issuing in-Warp-only grants. +### Granular local-control permissions +The product settings surface should expose granular permissions under the default-off local-control setting. Recommended controls: +- Allow local read-only metadata. +- Allow terminal data reads. +- Allow non-destructive local mutations. +- Allow destructive or execution actions. +- Allow authenticated-user actions from verified Warp terminals. +- Allow authenticated-user actions from external clients, default off and separate from the in-Warp permission. +These settings define the maximum grants the broker may issue. The app bridge still enforces the action's risk tier, authenticated-user requirement, execution-context requirement, and target scope for every request. +### Scoped credentials +The local discovery record must not expose a reusable full-access credential. `warpctrl` should request scoped credentials from an app-owned broker or equivalent trusted path. +Scoped credentials should include: +- the selected Warp instance; +- granted risk tiers; +- allowed action families; +- verified execution context; +- whether authenticated-user access is granted and for which logged-in user subject; +- optional target scopes; +- issuance and expiry metadata; +- revocation/audit identity. +The bridge, not the CLI frontend, enforces these grants. If a request exceeds its credential, the bridge returns `insufficient_permissions`, `authenticated_user_required`, `authenticated_user_unavailable`, or `execution_context_not_allowed` as appropriate. ### Future entity extensibility: files and Warp Drive objects The selector and action model should be designed to accommodate entity types beyond the current window/tab/pane/session hierarchy. Two important future entity families are **local files** and **Warp Drive objects** (workflows, notebooks, environment variables, prompts). Neither is in scope for the first implementation, but the protocol should not preclude them. **Files.** Warp already supports file opening via deep links and the built-in editor. A future `file` namespace could support: @@ -224,12 +249,12 @@ Drive object selectors should support both opaque IDs (for automation stability) **Design constraints for both:** - File and Drive selectors are orthogonal to the window/tab/pane hierarchy — a file open action targets an instance (which window to open in), not a specific pane. A Drive workflow execution targets a session (which pane to run in). - The `TargetSelector` type in the protocol should be extensible with optional fields for these new selector families without breaking existing requests that omit them. -- The action classification tiers apply: listing Drive objects is tier 1 (metadata), reading Drive object content is tier 1 or 2 depending on whether it contains user data, executing a Drive workflow is tier 4 (destructive/high-risk). +- The action classification tiers apply, and Drive actions require authenticated-user grants by default: listing Drive objects is tier 1 metadata plus authenticated user, reading Drive object content is tier 1 or 2 depending on whether it contains user data plus authenticated user, and executing a Drive workflow is tier 4 plus authenticated user. ### Settings: protocol-first Settings reads and writes should go through the local-control protocol like other actions, not bypass it via direct file manipulation. - `warpctrl setting get `, `warpctrl setting set `, and `warpctrl setting toggle ` send requests to the running Warp instance through the standard authenticated control endpoint. - The app bridge validates the key against the allowlist and the value against the expected type before applying the change. -- This keeps authorization enforcement consistent: the same permission tier checks and caller-type policies apply to settings mutations as to any other action, rather than creating a second unguarded path through the filesystem. +- This keeps authorization enforcement consistent: the same permission tier, execution-context, and authenticated-user policies apply to settings mutations as to any other action, rather than creating a second unguarded path through the filesystem. - The app owns the write to the settings file and any side effects (e.g., theme reload, layout reflow) as a single atomic operation, avoiding races between a CLI file write and the app's file watcher. - If a future need arises for offline settings manipulation (no running Warp process), a separate file-based path can be added later with its own validation, but it should not be the default. - The action classification still applies: settings reads are tier 1 (metadata), settings writes are tier 3 (non-destructive mutation). diff --git a/specs/warp-control-cli/SECURITY.md b/specs/warp-control-cli/SECURITY.md index ef9aecfebf..bb27363d6d 100644 --- a/specs/warp-control-cli/SECURITY.md +++ b/specs/warp-control-cli/SECURITY.md @@ -2,7 +2,7 @@ `warpctrl` is a local-control CLI for an already-running Warp app instance. Its security architecture is designed to support the full control catalog: discovery, structural reads, terminal-data reads, non-destructive mutations, settings changes, input manipulation, command execution, and destructive window/tab/pane operations. The correct architecture is not a single shared localhost bearer token with client-side conventions. The CLI, app bridge, and protocol must treat security as a local app-enforced capability system: discovery finds compatible instances, secure storage protects raw credential material, broker-issued credentials identify the granted scopes, the running Warp app's local-control bridge enforces action tiers before dispatch, and target resolution never silently retargets a request. The action-tier model is primarily a safety and intent mechanism, not a hard security boundary against malicious same-user software. It lets a user, script, or agent intentionally request read-only or low-risk access so it does not accidentally mutate state or execute commands. It should not be described as strong access control against a process that can already run arbitrary commands as the user. -`warpctrl` must not require the selected Warp instance to be logged in to a Warp account. All request authentication, protocol validation, safety checks, and action dispatch happen locally inside the running app process. Warp cloud services, Firebase identity, team membership, and account login state are not part of the local-control trust model. +`warpctrl` has two distinct authorization dimensions: local-control authority and Warp user authority. Local-control authority proves the request is allowed to control the local app. Warp user authority proves the selected Warp app has a real logged-in Warp user and the request is allowed to act on user-authenticated data such as Warp Drive objects, AI conversation traces, synced settings, or cloud-backed user state. Logged-out users should retain a smaller local-only control surface, but authenticated-user actions require a true logged-in Warp user in the selected app. ## Security goals - Allow trusted local users and approved automation to control a running Warp instance through a stable, scriptable interface. - Prevent unauthenticated localhost clients from invoking read or mutating control actions. @@ -12,8 +12,12 @@ The action-tier model is primarily a safety and intent mechanism, not a hard sec - Require explicit in-app user enablement before local control scripting can issue credentials or accept control requests. - Store the authoritative enablement state in protected local storage so external apps cannot enable local control by editing ordinary settings. - Keep raw credential material out of plaintext discovery records and protect it with platform secure storage where available. +- Distinguish verified `warpctrl` invocations that originate from a Warp-managed terminal session from external same-user invocations. +- Allow external invocations by default only for a smaller local-only action set that does not touch user-authenticated data. +- Allow in-Warp invocations to receive authenticated-user grants when the selected Warp app has a true logged-in user and the user's local-control settings permit that grant. - Support least-privilege safety modes for automation and interactive use without relying on an unenforceable identity label. - Classify every action by risk tier and enforce the required tier in the local app bridge, not in the CLI frontend. +- Classify every action by whether it requires an authenticated Warp user. New actions should default to requiring an authenticated user unless they are deliberately reviewed as safe for logged-out or external use. - Prevent `warpctrl` from becoming an ambient full-power confused deputy that any same-user process can invoke for high-risk actions. - Preserve deterministic targeting so a request never silently mutates or reads the wrong window, tab, pane, session, file, or Warp Drive object. - Keep the action surface allowlisted and typed rather than exposing arbitrary internal app dispatch. @@ -89,6 +93,7 @@ Enablement requirements: - Only the running Warp app, through an in-app user action, should be able to enable or disable the authoritative state. - `warpctrl`, shell scripts, config files, command-line flags, registry edits, defaults writes, and direct local-control protocol requests must not be able to enable the setting. - The app should require an intentional user gesture before enabling the setting, and the UI should explain that it allows local scripts and approved automation to control Warp. +- The UI should expose granular local-control permission settings rather than a single all-powerful switch. - The setting should be easy to disable from the same UI, and disabling it should revoke or invalidate active local-control credentials. - If enterprise or managed-device policy is added later, policy may force-disable local control or allow an administrator-controlled default, but policy should be separate from user-editable local settings. Disabled-state behavior: @@ -98,6 +103,15 @@ Disabled-state behavior: - `warpctrl` may detect the disabled state and print instructions to enable local control in Warp settings, but it must not offer a command that flips the setting. - Previously issued credentials must become unusable when local control is disabled, even if their original expiry has not elapsed. This enablement gate does not create perfect same-user malicious-app isolation. A hostile process with Accessibility or Screen Recording permission might still try to automate the Warp UI. The gate is still important because it closes the much easier paths where external apps silently edit local preferences, call a config CLI, or write synced settings to enable a powerful control surface. +### Granular permission settings +Once local control scripting is enabled, users should control which categories of `warpctrl` authority can be granted. Recommended independent permissions: +- **Local read-only metadata:** permit external and in-Warp clients to inspect non-sensitive local app structure such as instances, windows, tabs, panes, app version, and theme names. +- **Terminal data reads:** permit reads of terminal output, scrollback, input buffers, command history, and session traces. +- **Non-destructive local mutations:** permit reversible app-state changes such as creating tabs, focusing panes, changing theme, or opening panels. +- **Destructive and execution actions:** permit closing targets, injecting input, running commands, executing workflows, or other high-risk operations. +- **Authenticated-user actions from Warp terminals:** permit `warpctrl` invocations that originate from a verified Warp-managed terminal session to receive grants backed by the currently logged-in Warp user, enabling actions that read or mutate Warp Drive, AI conversation traces, synced settings, or other user-authenticated state. +- **Authenticated-user actions from external clients:** default off. If supported, this must be a separate explicit permission from in-Warp authenticated actions because external same-user processes are a weaker context than a Warp-managed terminal session. +Disabling any category should invalidate active credentials that include that category. The broker and app bridge must enforce these settings locally for every credential request and every presented credential. ## Trust boundaries `warpctrl` has several distinct trust boundaries. ### Operating-system user boundary @@ -105,6 +119,14 @@ The baseline local trust boundary is the OS user account. Discovery records and ### Invocation boundary Same-user does not mean same authority. Interactive use and unattended automation may both run commands under the same user account, but they should be able to intentionally request narrower capabilities. The protocol needs scoped credentials that encode concrete grants, target scopes, and lifetimes rather than an abstract caller type that the bridge cannot reliably verify. These scoped credentials are guardrails for well-behaved clients. They prevent accidental overreach and make user intent explicit, but they are not a defense against malicious same-user code that can automate the CLI, inspect the user's environment, or wait for user approvals. +### Warp-terminal execution context boundary +`warpctrl` should be able to receive special grants when it is invoked from within a Warp-managed terminal session, but the bridge must not trust a caller-supplied string such as `caller_class=warp_terminal`. The app should issue a session-bound execution-context proof to Warp-managed terminal sessions and have the broker verify that proof before minting in-Warp-only grants. +Acceptable designs include a short-lived per-session capability, an app-owned broker handshake tied to the terminal session, or an equivalent proof that arbitrary external processes cannot mint by setting an environment variable. Plain environment variables may be used as handles or hints, but they must not be the sole authority for in-Warp privileges because external processes can spoof them. +Verified in-Warp context can raise the maximum eligible grant set, especially for authenticated-user actions. It does not by itself bypass the user's granular local-control settings, action risk tiers, target scopes, or logged-in-user requirements. +### Warp user authentication boundary +Actions that touch user-authenticated Warp data require a true logged-in Warp user in the selected app. This includes Warp Drive object contents or mutation, AI conversation traces, cloud-backed user settings, team/account data, and any other surface whose normal app access depends on the user's Warp account. +The app bridge should execute these actions on behalf of the logged-in app user through existing app auth state. `warpctrl` should receive a local-control credential that carries an `authenticated_user` grant, the verified user identity or stable subject reference, and the allowed authenticated action families. It should not need to export raw Firebase, server, or cloud API tokens to shell scripts. +If the selected app has no logged-in user, authenticated-user actions must fail with a structured error rather than falling back to logged-out behavior. Logged-out users may still use the smaller local-only action set explicitly marked as not requiring an authenticated user. ### Application identity boundary On platforms with secure credential storage, especially macOS, the raw local-control credential should be readable only by Warp-owned, correctly signed code. On macOS this means storing raw credential material in Keychain with access constrained by Warp's signing identity, designated requirement, Keychain access group, or equivalent platform mechanism. This narrows token extraction from “any same-user process can read a file” to “only trusted Warp-signed code can unwrap the secret.” This boundary protects the credential from direct theft and prevents arbitrary apps from making authenticated raw HTTP requests to the local-control listener. It also lets the authoritative enablement state be stored somewhere harder to modify than ordinary user preferences. It does not prove that the user personally intended the specific action. Any same-user process may still be able to invoke the trusted `warpctrl` binary or automate the Warp UI. That confused-deputy risk is reduced by explicit in-app enablement, scoped credential issuance, action-tier policy, and local app-side bridge enforcement, but it is not eliminated as a hard same-user security boundary. @@ -119,6 +141,8 @@ A valid credential for one instance or target must not imply authority over anot - Same-user automation attempting actions without the required scoped grants. - Same-user processes attempting to extract plaintext credentials from local state. - Same-user processes invoking `warpctrl` as a confused deputy for actions the process could not authorize directly. +- External same-user processes attempting authenticated-user actions that should be limited to verified Warp-terminal invocations. +- Logged-out requests attempting actions that require a true logged-in Warp user. - Stale discovery records from exited Warp processes. - Multiple running Warp instances where ambiguous selection could target the wrong process. - Malformed clients attempting unknown, unsupported, unallowlisted, or invalid action payloads. @@ -130,22 +154,25 @@ A valid credential for one instance or target must not imply authority over anot - Kernel, hypervisor, or administrator-level compromise. - Security semantics for remote URL control endpoints. Remote control requires a separate transport and identity design before it can ship. ## Architecture overview -The security model has seven layers: +The security model has eight layers: 1. **Protected enablement:** Require explicit in-app opt-in backed by protected local storage before local control is available. 2. **Discovery:** Find compatible live Warp instances without granting broad authority. 3. **Secure credential storage:** Store raw secrets outside plaintext discovery records and restrict access to trusted Warp-owned code where the platform supports it. -4. **Credential issuance:** Issue scope-specific credentials with explicit grants and lifetimes only when local control is enabled. -5. **Transport authentication:** Reject disabled or unauthenticated requests before reading or mutating app state. -6. **Safety policy:** Enforce requested action tiers and target scopes locally in the app bridge for well-behaved clients. -7. **Deterministic dispatch:** Resolve targets exactly and invoke only allowlisted typed handlers. +4. **Execution context verification:** Distinguish verified Warp-terminal invocations from external same-user invocations without trusting caller-declared labels. +5. **Credential issuance:** Issue scope-specific credentials with explicit grants and lifetimes only when local control is enabled and the user's granular permissions allow the requested category. +6. **Transport authentication:** Reject disabled or unauthenticated requests before reading or mutating app state. +7. **Safety and user-auth policy:** Enforce action tiers, target scopes, execution-context requirements, and authenticated-user requirements locally in the app bridge. +8. **Deterministic dispatch:** Resolve targets exactly and invoke only allowlisted typed handlers. ```mermaid sequenceDiagram participant Invoker as User / Automation participant CLI as warpctrl participant Registry as Per-user discovery registry participant Enablement as Protected enablement state + participant Context as Execution context proof participant Broker as Credential broker participant Store as Secure credential storage + participant Auth as App auth state participant HTTP as Warp control listener participant Bridge as App bridge + safety policy participant UI as Warp app state @@ -160,12 +187,17 @@ sequenceDiagram else Enabled CLI->>Broker: Request scoped credential for action Broker->>Enablement: Verify protected enablement state + Broker->>Context: Verify external vs Warp-terminal context + opt Authenticated-user action + Broker->>Auth: Verify logged-in Warp user + setting + Auth-->>Broker: User subject or unavailable + end Broker->>Store: Load or unwrap raw secret with Warp-signed access Store-->>Broker: Raw secret or credential capability - Broker-->>CLI: Scoped credential with grants, scopes, expiry + Broker-->>CLI: Scoped credential with grants, context, user scope, expiry CLI->>HTTP: Authenticated typed request HTTP->>Bridge: Verify credential and protocol envelope - Bridge->>Bridge: Check action tier + target scope + Bridge->>Bridge: Check tier + context + authenticated-user + target scope alt Denied Bridge-->>CLI: structured safety-policy error else Allowed @@ -199,6 +231,9 @@ A control credential should encode or reference: - issuing Warp instance; - protocol version or accepted version range; - granted action tiers; +- verified execution context, such as external client or Warp-managed terminal session; +- whether the credential may act on behalf of an authenticated Warp user; +- authenticated Warp user subject or stable user reference when an authenticated-user grant is present; - optional allowed action families; - optional target restrictions, such as one session, one workspace, one file path, or one Warp Drive object type; - issued-at time; @@ -210,11 +245,13 @@ Warp should issue credentials through an app-owned local broker or equivalent tr Recommended defaults: - Credential issuance is unavailable unless the protected in-app enablement state says local control scripting is enabled. - Commands should start from least privilege and request only the grant needed for the requested action. -- Unattended automation should default to read-only metadata unless policy or an explicit approval grants more. -- Interactive use may receive broader local control only through an intentional approval or configured policy. +- External same-user invocations should default to the smaller logged-out-safe local action set unless policy or explicit approval grants more. +- Verified Warp-terminal invocations may receive broader local-control grants when the user's granular settings allow them. +- Authenticated-user grants are available only when the selected Warp app has a true logged-in Warp user and the requested execution context is allowed by local-control settings. - Terminal data reads require an explicit `read_terminal_data` grant. - Non-destructive mutations require an explicit `mutate_non_destructive` grant. - Destructive operations, input injection, and command execution require explicit high-risk grants. +- User-authenticated data reads or mutations require an explicit `authenticated_user` grant and an allowed authenticated action family. - Integrations should receive the narrowest grant needed for the configured workflow. The broker must not issue broad authority merely because the request came from the signed `warpctrl` binary. It should evaluate the requested action tier, target scope, configured policy, execution context, and whether user approval is required. The CLI must not mint its own authority. It can request, load, and present credentials, but the app bridge remains the enforcement point for these safety grants. ### Safety grants, not strong access control @@ -256,27 +293,32 @@ Transport requirements: - Keep unauthenticated health metadata minimal and non-sensitive. - Preserve structured error envelopes so the CLI does not collapse security failures into generic transport errors. Remote URL support is a separate future transport mode. It should not reuse the local same-user credential model without additional identity, encryption, replay protection, and remote approval/policy design. -## Login independence -Local-control validation is not tied to a logged-in Warp user. The selected Warp app process validates local-control requests using local protocol state: -- discovery records; -- secure local credential references; -- scoped safety grants; -- protocol version and request shape; -- allowlisted actions and typed parameters; -- deterministic target selectors. -The app must not call Warp cloud services to decide whether a local `warpctrl` request is allowed, and it must not require Firebase authentication, team membership, or a non-anonymous Warp account. This keeps scripting and local automation available to logged-out users and offline-capable core terminal workflows. -If a future action depends on cloud-backed state, such as a Warp Drive operation that requires network access, that action can return a state-specific error when unavailable. That should not turn the whole local-control protocol into a logged-in-user feature. +## Logged-in user requirements +Local-control validation always begins with local protocol state: discovery records, secure local credential references, scoped safety grants, execution-context proof, protocol version, request shape, allowlisted actions, typed parameters, and deterministic target selectors. +Some actions additionally require a true logged-in Warp user in the selected app. The action allowlist must declare this explicitly with a `requires_authenticated_user` field. +Default rule for new actions: +- New actions require an authenticated Warp user unless the implementer deliberately classifies them as logged-out-safe. +- The logged-out-safe set should remain meaningfully smaller and limited to local app structure, local appearance metadata, and other surfaces that do not depend on the user's cloud-backed Warp identity. +- Actions that read or mutate Warp Drive, AI conversation traces, synced settings, team/account data, or other user-authenticated state must require an authenticated user. +- Actions that can execute user-authored cloud-backed content, such as running Warp Drive workflows or inserting notebook commands, require both the authenticated-user grant and the appropriate high-risk action tier. +When an authenticated-user action is requested: +- the selected app must have an active logged-in Warp user; +- the presented local-control credential must include an `authenticated_user` grant for that user or stable subject; +- the user's granular settings must allow authenticated-user actions for the verified execution context; +- the app bridge should execute through the app's existing authenticated state rather than exporting raw cloud auth credentials to `warpctrl`. +If these conditions are not met, the app returns a structured error. It must not fall back to logged-out behavior or silently omit user-authenticated data from a result that claims success. ## Safety policy model Safety grants are enforced in the app bridge after transport authentication and before target resolution or handler dispatch. This provides consistent “do not accidentally do more than requested” behavior for honest clients, not a sandbox for hostile same-user code. The bridge must: 1. Parse the typed request envelope. 2. Verify protocol version compatibility. 3. Authenticate the credential. -4. Determine granted action tiers and target scopes. -5. Map the requested action to a required tier and action family. +4. Determine granted action tiers, execution context, target scopes, and authenticated-user grants. +5. Map the requested action to a required tier, action family, execution-context requirement, and authenticated-user requirement. 6. Check optional target-family restrictions. 7. Reject requests that exceed the credential's grants with `insufficient_permissions`. -8. Only then resolve selectors and invoke the allowlisted handler. +8. Reject authenticated-user actions without a logged-in user or authenticated-user grant with a structured authenticated-user error. +9. Only then resolve selectors and invoke the allowlisted handler. The CLI frontend may provide helpful preflight errors, but those checks are advisory. Local app-side bridge enforcement is mandatory because other tools can bypass the official CLI and speak the protocol directly. ## Action risk tiers Every action belongs to exactly one tier. These tiers describe risk and intended safety prompts; they are not a sandbox or a complete OS-level access-control model. @@ -334,11 +376,13 @@ Each supported command requires: - typed parameters; - validation rules; - a documented risk tier; +- a documented `requires_authenticated_user` value; +- a documented allowed execution context, including whether external clients can run it or whether it is limited to verified Warp-terminal invocations; - local app-side safety-grant checks; - deterministic target resolution; - a handler that reuses existing user-visible app behavior where possible; - typed success and error responses. -Adding a new action should be additive and reviewable: extend the protocol enum, implement validation, map the action to a risk tier, add a handler, and add tests for authentication, safety-policy denial, selector failure, and success behavior. +Adding a new action should be additive and reviewable: extend the protocol enum, implement validation, map the action to a risk tier, declare whether it requires an authenticated user, declare its allowed execution contexts, add a handler, and add tests for authentication, safety-policy denial, authenticated-user denial, selector failure, and success behavior. ## Browser and localhost protections Loopback is not sufficient by itself because browsers can send requests to localhost. Required protections: @@ -372,6 +416,9 @@ Important errors include: - `local_control_disabled` when the user has not enabled local control scripting in Warp settings or has disabled it after credentials were issued; - `unauthorized_local_client` for missing, malformed, expired, revoked, or invalid credentials; - `insufficient_permissions` for valid credentials that lack the requested safety tier or target scope; +- `authenticated_user_required` when an action requires a logged-in Warp user but the credential lacks an authenticated-user grant; +- `authenticated_user_unavailable` when the selected Warp app has no logged-in Warp user or cannot access the required authenticated user state; +- `execution_context_not_allowed` when the action or requested grant is not allowed from the verified invocation context, such as an external client attempting an in-Warp-only authenticated-user action; - `ambiguous_instance` when multiple compatible instances cannot be resolved safely; - `invalid_selector` for malformed or unsupported selector syntax; - `missing_target` when an active/default target does not exist; @@ -386,11 +433,14 @@ Before shipping each action family, verify that these controls are implemented f - Local control scripting must be explicitly enabled in Warp's app UI before the action family can run. - The authoritative enablement state is protected from external writes and is local-only rather than synced. - The action has a documented tier. +- The action has a documented `requires_authenticated_user` value. New actions default to `true` unless explicitly reviewed as logged-out-safe. +- The action documents allowed execution contexts and whether external clients may run it. - The bridge maps the action to that tier locally in the selected Warp app process. - The credential model can express the required grant. +- The credential model can express authenticated-user grants and verified execution context requirements when needed. - The handler checks optional target restrictions where relevant. - Requests with invalid credentials or insufficient safety grants fail before selector resolution or mutation. -- The action does not require a logged-in Warp account unless the action itself inherently depends on cloud-backed state. +- Requests that require authenticated-user access fail unless the selected app has a true logged-in Warp user and the credential includes an authenticated-user grant. - Ambiguous, missing, and stale targets return structured errors. - Tests cover allowed, insufficient-permission, and denied credential paths. - Logs and errors do not expose credentials, terminal contents, command text, or sensitive settings. diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index 434f7ce902..8d3c5cfff2 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -1,5 +1,6 @@ # Context `PRODUCT.md` defines a standalone local Warp control CLI binary, provisionally named `warpctrl`, with an allowlisted action catalog, deterministic addressing across multiple running Warp app processes, and an incremental implementation plan. +`SECURITY.md` is the normative security architecture for this feature. Implementation work must follow it for explicit in-app enablement, protected enablement storage, granular permissions, discovery metadata, credential storage, scoped safety grants, verified execution context, authenticated-user requirements, localhost/browser protections, action-tier enforcement, deterministic target resolution, and local app-side validation. If this technical plan and `SECURITY.md` disagree, update the plan before implementing rather than treating the security architecture as optional follow-up work. The existing app already has three relevant building blocks: - `crates/http_server/src/lib.rs (7-61)` runs a native-only loopback Axum server on fixed port `9277`. - `app/src/lib.rs (1993-2001)` registers that HTTP server in the native app and currently merges only installation-detection and profiling routers. @@ -22,10 +23,31 @@ The current Oz CLI build/distribution model is also directly relevant because th - `script/windows/windows-installer.iss (235-263)` shows the current Windows helper-wrapper pattern for CLI access. The most important constraint surfaced by this code is that the current fixed-port local HTTP server cannot be the entire solution for a multi-process control API. If multiple local Warp processes attempt to expose mutating routes through the same fixed port, only one can own it. The control design therefore needs explicit per-process discovery and addressing. ## 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: +- Local control scripting is disabled by default and can only be enabled by an in-app Warp settings flow. +- The authoritative enablement state is local-only, not Settings Sync'd, and stored in protected local storage rather than ordinary user-editable settings. +- `warpctrl`, direct protocol requests, shell scripts, config files, registry/plist edits, and server-backed preferences must not be able to enable local control scripting. +- Discovery records do not publish actionable endpoints or credential references while local control scripting is disabled. +- Credential issuance is unavailable while local control scripting is disabled. +- Raw credential material is kept out of plaintext discovery records and stored in platform secure storage where available. +- The broker distinguishes verified Warp-terminal invocations from external invocations using an app-issued execution-context proof, not a caller-declared label. +- External invocations default to a smaller logged-out-safe action set that does not touch user-authenticated data. +- Verified Warp-terminal invocations may receive authenticated-user grants only when the selected app has a true logged-in Warp user and local-control settings allow authenticated-user actions from Warp terminals. +- The app rejects disabled, unauthenticated, expired, revoked, insufficient-scope, unsupported, malformed, ambiguous, missing-target, and stale-target requests with structured errors. +- Every action has a documented risk tier and the app bridge enforces the required tier locally before selector resolution or handler dispatch. +- Every action has a documented `requires_authenticated_user` value and allowed execution contexts. New actions default to requiring an authenticated user unless explicitly reviewed as logged-out-safe. +- Granular local-control settings gate the maximum grants for metadata reads, terminal-data reads, non-destructive mutations, destructive/execution actions, authenticated-user actions from Warp terminals, and authenticated-user actions from external clients. +- Safety tiers are treated as user-intent and accident-prevention guardrails, not as strong same-user malicious-app isolation. +- Remote control remains out of scope for the local same-machine credential model. +The first implementation slice should include the protected enablement gate, credential issuance checks, and app-side tier enforcement even if the only mutating action initially implemented is `tab.create`. Shipping `tab.create` without the enablement and validation architecture would create the wrong foundation for the full catalog. ### 1. Protocol crate and stable envelope Create a small shared protocol crate or equivalent shared module used by both the app server and standalone CLI client. It should define: - Protocol version metadata. - Discovery/health response types. +- Execution-context proof/request types for verified Warp-terminal invocations versus external invocations. +- Action metadata describing risk tier, required grant, `requires_authenticated_user`, allowed execution contexts, and target families. - Selector types: - `InstanceSelector` - `WindowSelector` @@ -62,7 +84,7 @@ Recommended response shape: "result": {} } ``` -Error payloads should include a stable code such as `no_instance`, `ambiguous_instance`, `stale_target`, `invalid_selector`, `unsupported_action`, `not_allowlisted`, `invalid_params`, `target_state_conflict`, or `unauthorized_local_client`. +Error payloads should include stable codes defined in `SECURITY.md`, including `local_control_disabled`, `unauthorized_local_client`, `insufficient_permissions`, `authenticated_user_required`, `authenticated_user_unavailable`, `execution_context_not_allowed`, `ambiguous_instance`, `stale_target`, `invalid_selector`, `unsupported_action`, `not_allowlisted`, `invalid_params`, `target_state_conflict`, `missing_target`, and `no_instance`. ### 2. Per-process discovery instead of fixed-port-only routing Keep the existing fixed-port HTTP behavior intact for installation detection/profiling compatibility. Add a separate local-control listener that follows the same native Axum/Tokio pattern but supports multiple local Warp app processes. Recommended design: @@ -75,20 +97,27 @@ Recommended design: - control-listener endpoint - protocol version - start timestamp - - local-auth material reference or token metadata + - credential metadata or secure-storage references only when local control scripting is enabled - The CLI loads discovery records, removes or ignores stale records after health checks, and chooses an instance using the product selector rules. - `warpctrl instance list` is a CLI-first projection of this discovery registry plus health responses. +When local control scripting is disabled, discovery must follow `SECURITY.md`: either publish no actionable local-control record or publish only a minimal disabled-status record with no endpoint authority or credential reference. This design preserves the current `9277` behavior while avoiding cross-process port contention for the new control API. -### 3. Local authentication boundary +### 3. Local authentication, enablement, and safety boundary Mutating localhost routes should not copy the permissive CORS posture of `/install_detection`. Recommended local trust model: - No browser-readable CORS allowance on control endpoints. -- Per-instance random bearer token or equivalent local credential stored in the discovery record or adjacent secure local state with user-only permissions. -- CLI automatically loads and presents the credential. -- The app rejects missing/invalid local credentials before action resolution. -- Health metadata exposed without credentials, if needed for stale-record pruning, must not reveal mutating capabilities or sensitive target state. +- Local control scripting must be explicitly enabled in the Warp app before credentials are minted or sensitive control requests are accepted. +- The authoritative enablement bit must live in protected local storage and must not be writable by `warpctrl` or ordinary same-user preference/config edits. +- Per-instance raw credential material must be kept out of plaintext discovery records and stored in platform secure storage where practical. +- The CLI may load or request scoped credentials through an app-owned broker/helper, but it must not mint authority itself. +- The broker verifies whether the invocation originated from a Warp-managed terminal session before issuing in-Warp-only grants. +- The broker issues authenticated-user grants only when the selected app has a true logged-in Warp user and the relevant local-control permission is enabled. +- The app rejects disabled-state, missing, malformed, invalid, expired, or revoked credentials before selector resolution or mutation. +- The app maps every action to a risk tier and rejects insufficient grants before selector resolution or mutation. +- The app maps every action to a `requires_authenticated_user` value and allowed execution contexts, rejecting mismatches before selector resolution or mutation. +- Health metadata exposed without credentials, if needed for stale-record pruning, must not reveal mutating capabilities, credentials, or sensitive target state. This keeps the protocol local and scriptable without creating an ambient browser-to-localhost control surface. -For agent-facing permissions, local authentication must evolve from a single bearer token into scoped credentials before higher-risk surfaces ship. A scoped credential should encode caller class, instance binding, granted permission tiers, issue time, and optional expiry. The app bridge must authenticate the credential and enforce the required action tier server-side before selector resolution or mutation. The first slice can use the discovery bearer token for human same-user CLI use only because it is limited to discovery and `tab.create`. +Do not ship the first slice as a plaintext discovery bearer token, even for same-user human CLI use. The first slice is the foundation for higher-risk terminal data, input injection, command execution, and destructive operations, so it must establish the protected enablement, credential storage, scoped grant, and app-side enforcement model from `SECURITY.md`. ### 4. App-side request bridge onto the UI/application context The HTTP handler runs on a Tokio runtime thread owned by the local-control server. It cannot directly access or mutate Warp's UI models, views, or app context because all WarpUI state is single-threaded and owned by the main app event loop. The bridge solves this by sending a closure from the Tokio handler thread to the main thread, executing it in the model's context, and returning the result to the waiting HTTP handler. #### Thread model @@ -109,7 +138,8 @@ The bridge uses WarpUI's `ModelSpawner` mechanism, which is the standard way ``` HTTP handler (Tokio thread) │ - ├─ verify auth token + ├─ verify local control scripting is enabled + ├─ verify credential, execution context, safety grant, and authenticated-user grant ├─ deserialize RequestEnvelope ├─ call bridge_spawner.spawn(move |bridge, ctx| { │ bridge.handle_request(request, ctx) // runs on main thread @@ -119,6 +149,10 @@ HTTP handler (Tokio thread) LocalControlBridge::handle_request (main thread) │ + ├─ verify protected enablement state is still enabled + ├─ map action to required risk tier + ├─ map action to authenticated-user and execution-context requirements + ├─ verify presented credential grants that tier, target family, execution context, and authenticated-user access ├─ match request.action.kind │ └─ ActionKind::TabCreate │ ├─ validate_tab_create_target(&request.target) @@ -143,9 +177,11 @@ LocalControlBridge::handle_request (main thread) #### Adding new action handlers To add a new action to the bridge: 1. Add a variant to `ActionKind` in `crates/local_control/src/protocol.rs`. -2. Add a match arm in `LocalControlBridge::handle_request` in `app/src/local_control/mod.rs`. -3. Inside the match arm, use `ctx` (which is a `&mut ModelContext` that derefs to `&mut AppContext`) to resolve selectors and dispatch the action onto existing app types. -4. Return a `ResponseEnvelope::ok(...)` or `ResponseEnvelope::error(...)` with the result. +2. Document its `SECURITY.md` risk tier, required grant, `requires_authenticated_user` value, and allowed execution contexts. +3. Add a match arm in `LocalControlBridge::handle_request` in `app/src/local_control/mod.rs`. +4. Before selector resolution or dispatch, verify local control is enabled and the presented credential grants the action tier, target family, execution context, and authenticated-user access if required. +5. Inside the match arm, use `ctx` (which is a `&mut ModelContext` that derefs to `&mut AppContext`) to resolve selectors and dispatch the action onto existing app types. +6. Return a `ResponseEnvelope::ok(...)` or `ResponseEnvelope::error(...)` with the result. The bridge closure has access to the full `AppContext` API surface, including `ctx.windows()`, `ctx.window_ids()`, `ctx.views_of_type::(window_id)`, `handle.update(ctx, ...)`, and `handle.read(ctx, ...)`. This makes it straightforward to wire new actions to existing UI behavior without introducing new concurrency concerns. ### 5. Target resolution model Implement target resolution as a reusable component rather than scattering lookup logic across handlers. @@ -160,6 +196,7 @@ Selector behavior: - Explicit opaque IDs must resolve exactly or return `stale_target`. - Index selectors are allowed only for user-visible indexed concepts such as tabs and should resolve to a concrete opaque ID before execution. - A session-scoped request against a non-terminal pane returns `target_state_conflict`. +Target resolution must happen after protected enablement, authentication, and safety-grant checks. This prevents denied requests from learning more target state than necessary and keeps enforcement centralized. Implementation references: - Window-level active selection already exists inside the app through `WindowManager`. - Pane scoping can build on the conceptual model of `PaneViewLocator` in `app/src/workspace/util.rs (12-18)`. @@ -183,15 +220,21 @@ Do not use a generic “dispatch action by string” endpoint. Every handler sho ### 7. First slice: prove discovery and `tab.create` The first `warpctrl` implementation slice should land the minimum cross-cutting architecture plus a single representative tab mutation: - Shared protocol types and error envelopes. +- Protected in-app enablement setting and protected local-only enablement storage. +- Granular local-control permission storage for at least metadata, non-destructive local mutations, and authenticated-user-action categories. - Discovery registry and CLI instance selection. - A standalone `warpctrl` binary or artifact path that runs control commands without starting the GUI app runtime. -- Per-process authenticated local-control server. +- Per-process authenticated local-control server that refuses sensitive work when local control scripting is disabled. +- Scoped credential issuance/storage with no raw credentials in plaintext discovery records, including execution-context fields and authenticated-user grant fields. - App-side request bridge and selector resolver. +- Action-tier mapping and app-side safety-grant enforcement. +- Action metadata for `tab.create` that deliberately classifies it as a logged-out-safe non-destructive local mutation only when the user's granular local-control settings allow that category. - Read-only `ping/version` plus `warpctrl instance list` or equivalent minimal discovery command. - End-to-end `warpctrl tab create` for the selected instance, reusing the same app behavior as the user-visible new-terminal-tab action. Why `tab.create` first: - It proves a UI/layout action can be targeted and executed against live app state. - It exercises process discovery, local authentication, request bridging, selector defaults, app-context dispatch, and structured success/error output without introducing higher-risk terminal input execution. +- It exercises the protected enablement and scoped-grant model before higher-risk action families depend on it. - It gives operators a concise end-to-end smoke test: discover a running instance, create a tab, and confirm the live app changed. The PR should also introduce the shell-facing CLI command grammar that the remainder of the protocol will reuse and establish a lightweight CLI startup path distinct from GUI startup. ### 8. Follow-up slices: fill out the remaining protocol in parallel @@ -232,6 +275,7 @@ sequenceDiagram participant CLI as Warp control CLI participant REG as Local discovery registry participant PROC as Selected Warp process + participant BROKER as Credential broker participant HTTP as Local control listener participant BRIDGE as App request bridge participant RES as Target resolver @@ -242,8 +286,12 @@ sequenceDiagram CLI->>PROC: Health/protocol check for candidates PROC-->>CLI: Instance metadata + compatibility CLI->>CLI: Resolve instance selector + CLI->>BROKER: Request scoped credential for action + execution context + BROKER-->>CLI: Grant or structured denial CLI->>HTTP: Authenticated POST tab.create request + HTTP->>HTTP: Verify enablement + credential + execution context HTTP->>BRIDGE: Typed request + response channel + BRIDGE->>BRIDGE: Recheck enablement + tier + auth-user policy BRIDGE->>RES: Resolve window/tab/pane/session selectors RES-->>BRIDGE: Concrete target handles or typed error BRIDGE->>ACT: Execute allowlisted ControlAction @@ -256,10 +304,19 @@ sequenceDiagram ``` ## Testing and validation Map tests directly to `PRODUCT.md` behavior. +- Security architecture: + - Protected enablement tests proving disabled state rejects credential issuance, sensitive discovery, and mutating requests with `local_control_disabled`. + - Tests proving discovery in disabled state exposes no actionable endpoint authority or credential reference. + - Credential-storage tests proving raw credentials are not written into plaintext discovery records. + - Execution-context tests proving external clients cannot receive grants reserved for verified Warp-terminal invocations. + - Tier-enforcement tests proving insufficient grants fail with `insufficient_permissions` before selector resolution or handler dispatch. + - Authenticated-user tests proving user-authenticated actions fail without a logged-in app user or authenticated-user grant. + - Granular-permission tests proving disabled categories invalidate credentials and prevent new grants. + - Structured-error tests for disabled, unauthenticated, expired, revoked, insufficient-scope, execution-context-denied, authenticated-user-required, authenticated-user-unavailable, unsupported, malformed, ambiguous, missing-target, stale-target, and invalid-selector requests. - Behavior 1-6, 29-31: - Protocol version/unit tests. - Discovery-registry tests with zero, one, multiple, stale, and incompatible instance records. - - Local-auth tests for missing/invalid/valid credentials. + - Local-auth tests for missing, invalid, expired, revoked, and valid credentials. - Behavior 7-13: - Selector-resolution unit tests for active, explicit ID, index, stale target, ambiguous target, and non-terminal session target. - Tests that no lower-level selector silently retargets after an explicit stale selector fails. @@ -305,7 +362,15 @@ flowchart LR - Fixed-port server assumptions: - Mitigation: leave current `9277` endpoints undisturbed and use a per-process control listener plus discovery registry. - Browser-to-localhost abuse: - - Mitigation: no permissive CORS, explicit local auth, and mutating routes gated before selector resolution. + - Mitigation: no permissive CORS, protected in-app enablement, explicit local auth, scoped grants, and mutating routes gated before selector resolution. +- External apps silently enabling local control: + - Mitigation: authoritative enablement state lives in protected local storage, is local-only, is not Settings Sync'd, and is not writable through `warpctrl`, config files, registry/plist preference edits, or server-backed settings. +- External apps obtaining in-Warp authenticated-user grants: + - Mitigation: require an app-issued execution-context proof for Warp-terminal-only grants, do not trust caller-declared labels or plain environment variables as sole authority, and keep external authenticated-user grants behind a separate default-off permission. +- Logged-out requests touching user-authenticated data: + - Mitigation: every action declares `requires_authenticated_user`, new actions default to true, and the bridge returns authenticated-user errors before selector resolution or dispatch. +- Implementation drift from `SECURITY.md`: + - Mitigation: treat `SECURITY.md` as normative for security behavior; update this technical plan before implementation when there is disagreement, and include tests for the security architecture in the first slice. - Action catalog drift from real UI behavior: - Mitigation: each control action reuses or factors existing UI action paths rather than duplicating behavior. - Leaking internal unstable identifiers: From 2151b02ee2fdf7eeda0eddac3f882a5dbb88a8dd Mon Sep 17 00:00:00 2001 From: Zachary Lloyd Date: Fri, 22 May 2026 10:37:14 -0600 Subject: [PATCH 08/48] Add warpctrl scripting enablement settings Co-Authored-By: Oz --- specs/warp-control-cli/PRODUCT.md | 14 ++++-- specs/warp-control-cli/SECURITY.md | 75 ++++++++++++++++-------------- specs/warp-control-cli/TECH.md | 42 +++++++++-------- 3 files changed, 72 insertions(+), 59 deletions(-) diff --git a/specs/warp-control-cli/PRODUCT.md b/specs/warp-control-cli/PRODUCT.md index 6c00dead5e..d83b347d06 100644 --- a/specs/warp-control-cli/PRODUCT.md +++ b/specs/warp-control-cli/PRODUCT.md @@ -173,6 +173,7 @@ Every action definition must include: - a risk tier; - whether a true logged-in Warp user is required; - whether the action may run from external clients, verified Warp-terminal clients, or both; +- whether inside-Warp and outside-Warp scripting settings can enable the action; - the required local-control permission category; - any target-scope restrictions. By default, new actions require an authenticated Warp user. An action may be marked logged-out-safe only after deliberate review confirms it does not touch Warp Drive, AI conversation traces, synced settings, team/account data, cloud-backed user state, terminal content, or other user-sensitive data. @@ -182,7 +183,7 @@ Every action in the catalog belongs to exactly one of the following tiers, from - Instance discovery and health: `instance list`, `app active`, `app version`, `app ping`. - Layout enumeration: `window list`, `tab list`, `pane list`, `session list`. - Appearance reads that return local configuration values but not user data: `theme list`, selected `setting get` actions for logged-out-safe local settings. - These are the primary default actions for external clients. + These are the primary initial actions for external clients after outside-Warp control is explicitly enabled. 2. **Read-only / terminal data.** Actions that return content from terminal sessions, command history, pane output buffers, input editor state, session replay, or terminal-derived traces. - Reading pane output or scrollback content. - Reading the current input buffer contents. @@ -209,11 +210,16 @@ The allowlist must clearly indicate `requires_authenticated_user` for every acti If an authenticated-user action is invoked while the selected app has no logged-in user, the CLI reports a structured authenticated-user error. It must not silently return partial logged-out data as success. ### Execution context policy `warpctrl` should distinguish verified invocations from inside Warp-managed terminal sessions from external invocations. -- **External invocation:** a `warpctrl` process started outside Warp's terminal. By default, it can receive only the smaller logged-out-safe local action set that does not touch user-authenticated data. Higher tiers require explicit local-control settings or approvals. -- **Verified Warp-terminal invocation:** a `warpctrl` process started inside a Warp-managed terminal session and able to present an app-issued execution-context proof. When the selected app has a logged-in Warp user, this context can receive authenticated-user grants if the user enabled that permission. +- **Verified Warp-terminal invocation:** a `warpctrl` process started inside a Warp-managed terminal session and able to present an app-issued execution-context proof. The top-level setting for this context should default to on. When the selected app has a logged-in Warp user, this context can receive authenticated-user grants if the user's Scripting permissions allow that grant. +- **External invocation:** a `warpctrl` process started outside Warp's terminal, such as from another terminal app, launch agent, IDE, or background script. The top-level setting for this context must default to off. When disabled, external invocations receive no local-control credentials, including logged-out-safe metadata credentials. - The app must not trust a caller-declared label. Environment variables may help discover the context, but the broker must verify a session-bound capability or equivalent proof before issuing in-Warp-only grants. +### Settings surface +Warp should add a new top-level Settings pane page named **Scripting**. This page should own settings for local scripting and automation surfaces, including Warp control. For Warp control, it should include two top-level toggles: +- **Allow Warp control from inside Warp:** default on. Controls `warpctrl` invocations from verified Warp-managed terminal sessions. +- **Allow Warp control from outside Warp:** default off. Controls `warpctrl` invocations from external terminals, scripts, IDEs, launch agents, and other same-user processes. +The Scripting page should explain that inside-Warp control is scoped to commands launched from Warp-managed terminals, while outside-Warp control allows other local apps and scripts to talk to Warp's control plane. Disabling either top-level toggle should invalidate credentials for that invocation context. ### Granular local-control permissions -The product settings surface should expose granular permissions under the default-off local-control setting. Recommended controls: +The Scripting settings page should expose granular permissions beneath the inside-Warp and outside-Warp toggles. Recommended controls: - Allow local read-only metadata. - Allow terminal data reads. - Allow non-destructive local mutations. diff --git a/specs/warp-control-cli/SECURITY.md b/specs/warp-control-cli/SECURITY.md index bb27363d6d..cf9a3a6446 100644 --- a/specs/warp-control-cli/SECURITY.md +++ b/specs/warp-control-cli/SECURITY.md @@ -9,11 +9,12 @@ The action-tier model is primarily a safety and intent mechanism, not a hard sec - Prevent browser-origin JavaScript from becoming an ambient localhost control client. - Support multiple running Warp processes without a shared global mutating port or global credential. - Separate discovery metadata from control authority so enumerating an instance does not automatically grant full control. -- Require explicit in-app user enablement before local control scripting can issue credentials or accept control requests. -- Store the authoritative enablement state in protected local storage so external apps cannot enable local control by editing ordinary settings. +- Require explicit in-app user enablement before local control scripting from outside Warp can issue credentials or accept control requests. +- Allow local control scripting from verified Warp-managed terminal sessions by default, subject to granular permission settings. +- Store the authoritative enablement states in protected local storage so external apps cannot enable outside-Warp control by editing ordinary settings. - Keep raw credential material out of plaintext discovery records and protect it with platform secure storage where available. - Distinguish verified `warpctrl` invocations that originate from a Warp-managed terminal session from external same-user invocations. -- Allow external invocations by default only for a smaller local-only action set that does not touch user-authenticated data. +- When outside-Warp control is enabled, allow external invocations only for a smaller local-only action set by default that does not touch user-authenticated data. - Allow in-Warp invocations to receive authenticated-user grants when the selected Warp app has a true logged-in user and the user's local-control settings permit that grant. - Support least-privilege safety modes for automation and interactive use without relying on an unenforceable identity label. - Classify every action by risk tier and enforce the required tier in the local app bridge, not in the CLI frontend. @@ -86,32 +87,36 @@ Compared with these systems, `warpctrl` should combine: - VS Code's preference for typed public commands and separate treatment of remote control. The resulting architecture should not claim to solve arbitrary same-user malicious-app isolation. Its real value is preventing web-origin and other-user access, preventing unauthenticated direct localhost calls, keeping raw credentials out of plaintext discovery, giving honest scripts and agents narrow safety grants, and routing high-risk operations through local Warp app validation and user/policy approval. ## Authoritative enablement model -Local control scripting must be explicitly enabled by the user inside the Warp app before `warpctrl` can control a running instance. The setting should be visible in Warp's settings UI as something like “Allow local control scripting,” and it should default to off. -The visible UI setting is not enough by itself. The authoritative enablement state must be stored in protected local storage that ordinary same-user apps cannot update by writing a plist, settings database row, registry key, JSON file, or synced cloud preference. This avoids turning local control into a feature that any process can silently enable before invoking `warpctrl`. +Warp control has two top-level enablement states based on invocation context: +- **Allow scripting from inside Warp:** controls `warpctrl` invocations from verified Warp-managed terminal sessions. This should default to on so commands run inside Warp can use local control subject to granular permissions. +- **Allow scripting from outside Warp:** controls `warpctrl` invocations from external terminals, scripts, launch agents, IDEs, or other same-user processes. This must default to off. +Both controls should live in a new top-level Settings pane page named **Scripting**. The Scripting page owns the user-facing controls for local scripting surfaces, including Warp control, and should explain the difference between commands run inside Warp and commands run from other apps. +The visible UI settings are not enough by themselves. The authoritative enablement states must be stored in protected local storage that ordinary same-user apps cannot update by writing a plist, settings database row, registry key, JSON file, or synced cloud preference. This avoids turning outside-Warp control into a feature that any process can silently enable before invoking `warpctrl`. Enablement requirements: -- The setting is local-only and must not sync through Settings Sync, Warp Drive, or server-backed user preferences. -- Only the running Warp app, through an in-app user action, should be able to enable or disable the authoritative state. -- `warpctrl`, shell scripts, config files, command-line flags, registry edits, defaults writes, and direct local-control protocol requests must not be able to enable the setting. -- The app should require an intentional user gesture before enabling the setting, and the UI should explain that it allows local scripts and approved automation to control Warp. -- The UI should expose granular local-control permission settings rather than a single all-powerful switch. -- The setting should be easy to disable from the same UI, and disabling it should revoke or invalidate active local-control credentials. -- If enterprise or managed-device policy is added later, policy may force-disable local control or allow an administrator-controlled default, but policy should be separate from user-editable local settings. +- The settings are local-only and must not sync through Settings Sync, Warp Drive, or server-backed user preferences. +- Only the running Warp app, through the Settings > Scripting UI, should be able to enable or disable the authoritative states. +- `warpctrl`, shell scripts, config files, command-line flags, registry edits, defaults writes, and direct local-control protocol requests must not be able to enable either setting. +- The in-Warp setting may default to enabled, but turning it off should prevent verified Warp-terminal invocations from receiving local-control grants. +- The outside-Warp setting defaults to disabled and should require an intentional user gesture before enabling; the UI should explain that it allows scripts and automation from other apps to control Warp. +- The Scripting page should expose granular local-control permission settings rather than a single all-powerful switch. +- Each setting should be easy to disable from the same UI, and disabling either setting should revoke or invalidate active local-control credentials for that invocation context. +- If enterprise or managed-device policy is added later, policy may force-disable either setting or allow an administrator-controlled default, but policy should be separate from user-editable local settings. Disabled-state behavior: -- Warp should not mint scoped local-control credentials while local control scripting is disabled. -- The control listener should either not start or should reject all sensitive requests with a structured disabled-state error before authentication, selector resolution, or handler dispatch. -- Discovery records should avoid publishing actionable endpoint or credential-reference metadata while disabled. If a minimal record is needed for UX, it should expose only non-sensitive status such as `local_control_enabled: false`. -- `warpctrl` may detect the disabled state and print instructions to enable local control in Warp settings, but it must not offer a command that flips the setting. -- Previously issued credentials must become unusable when local control is disabled, even if their original expiry has not elapsed. -This enablement gate does not create perfect same-user malicious-app isolation. A hostile process with Accessibility or Screen Recording permission might still try to automate the Warp UI. The gate is still important because it closes the much easier paths where external apps silently edit local preferences, call a config CLI, or write synced settings to enable a powerful control surface. +- Warp should not mint scoped local-control credentials for a request whose invocation context is disabled. +- The control listener should reject requests from disabled contexts with a structured disabled-state error before authentication, selector resolution, or handler dispatch. +- Discovery records should avoid publishing actionable endpoint or credential-reference metadata for disabled outside-Warp control. If a minimal record is needed for UX, it should expose only non-sensitive status such as `outside_warp_control_enabled: false`. +- `warpctrl` may detect a disabled context and print instructions to enable it in Settings > Scripting, but it must not offer a command that flips the setting. +- Previously issued credentials must become unusable when their invocation context is disabled, even if their original expiry has not elapsed. +These enablement gates do not create perfect same-user malicious-app isolation. A hostile process with Accessibility or Screen Recording permission might still try to automate the Warp UI. The outside-Warp gate is still important because it closes the much easier paths where external apps silently edit local preferences, call a config CLI, or write synced settings to enable a powerful control surface. ### Granular permission settings -Once local control scripting is enabled, users should control which categories of `warpctrl` authority can be granted. Recommended independent permissions: +Once the relevant inside-Warp or outside-Warp enablement setting allows a request context, users should control which categories of `warpctrl` authority can be granted. These permissions should appear under Settings > Scripting. Recommended independent permissions: - **Local read-only metadata:** permit external and in-Warp clients to inspect non-sensitive local app structure such as instances, windows, tabs, panes, app version, and theme names. - **Terminal data reads:** permit reads of terminal output, scrollback, input buffers, command history, and session traces. - **Non-destructive local mutations:** permit reversible app-state changes such as creating tabs, focusing panes, changing theme, or opening panels. - **Destructive and execution actions:** permit closing targets, injecting input, running commands, executing workflows, or other high-risk operations. - **Authenticated-user actions from Warp terminals:** permit `warpctrl` invocations that originate from a verified Warp-managed terminal session to receive grants backed by the currently logged-in Warp user, enabling actions that read or mutate Warp Drive, AI conversation traces, synced settings, or other user-authenticated state. - **Authenticated-user actions from external clients:** default off. If supported, this must be a separate explicit permission from in-Warp authenticated actions because external same-user processes are a weaker context than a Warp-managed terminal session. -Disabling any category should invalidate active credentials that include that category. The broker and app bridge must enforce these settings locally for every credential request and every presented credential. +Granular permissions should be independently configurable for inside-Warp and outside-Warp contexts where the distinction matters. Disabling any category should invalidate active credentials that include that category. The broker and app bridge must enforce these settings locally for every credential request and every presented credential. ## Trust boundaries `warpctrl` has several distinct trust boundaries. ### Operating-system user boundary @@ -155,11 +160,11 @@ A valid credential for one instance or target must not imply authority over anot - Security semantics for remote URL control endpoints. Remote control requires a separate transport and identity design before it can ship. ## Architecture overview The security model has eight layers: -1. **Protected enablement:** Require explicit in-app opt-in backed by protected local storage before local control is available. +1. **Protected enablement:** Use protected local storage for separate inside-Warp and outside-Warp enablement states, with inside-Warp on by default and outside-Warp off by default. 2. **Discovery:** Find compatible live Warp instances without granting broad authority. 3. **Secure credential storage:** Store raw secrets outside plaintext discovery records and restrict access to trusted Warp-owned code where the platform supports it. 4. **Execution context verification:** Distinguish verified Warp-terminal invocations from external same-user invocations without trusting caller-declared labels. -5. **Credential issuance:** Issue scope-specific credentials with explicit grants and lifetimes only when local control is enabled and the user's granular permissions allow the requested category. +5. **Credential issuance:** Issue scope-specific credentials with explicit grants and lifetimes only when the request's invocation context is enabled and the user's granular permissions allow the requested category. 6. **Transport authentication:** Reject disabled or unauthenticated requests before reading or mutating app state. 7. **Safety and user-auth policy:** Enforce action tiers, target scopes, execution-context requirements, and authenticated-user requirements locally in the app bridge. 8. **Deterministic dispatch:** Resolve targets exactly and invoke only allowlisted typed handlers. @@ -180,10 +185,10 @@ sequenceDiagram Invoker->>CLI: Invoke allowlisted command CLI->>Registry: Read instance metadata Registry-->>CLI: instance_id, endpoint, protocol version, credential reference - CLI->>Enablement: Check local control enabled status + CLI->>Enablement: Check inside/outside context enablement Enablement-->>CLI: Enabled or disabled alt Disabled - CLI-->>Invoker: local control disabled; enable in Warp settings + CLI-->>Invoker: context disabled; enable in Settings > Scripting else Enabled CLI->>Broker: Request scoped credential for action Broker->>Enablement: Verify protected enablement state @@ -220,7 +225,7 @@ Discovery rules: - Records must be readable only by the owning user. - POSIX records must use owner-only permissions such as `0600` for files and a non-world-readable directory. - Windows records must live under the current user's app data directory with ACLs limited to the current user, Administrators, and SYSTEM. -- When local control scripting is disabled, records must not publish actionable control endpoints or credential references. A minimal disabled-status record is acceptable only if it contains no authority. +- When outside-Warp control is disabled, records must not publish actionable control endpoints or credential references for external clients. A minimal disabled-status record is acceptable only if it contains no authority. - The CLI must prune or ignore stale records whose PID is gone or whose health/protocol check fails. - If multiple compatible instances are ambiguous, the CLI must require explicit `--instance` selection. - Discovery metadata must not expose terminal contents, environment variables, auth tokens for cloud services, raw local-control credentials, or mutating capability grants. @@ -243,7 +248,7 @@ A control credential should encode or reference: ### Credential issuance Warp should issue credentials through an app-owned local broker or equivalent trusted path. The broker decides which grants to issue based on the requested action tier, target scope, user configuration, execution context, and any explicit user approval. Recommended defaults: -- Credential issuance is unavailable unless the protected in-app enablement state says local control scripting is enabled. +- Credential issuance is unavailable unless the protected enablement state allows the request's invocation context: inside Warp or outside Warp. - Commands should start from least privilege and request only the grant needed for the requested action. - External same-user invocations should default to the smaller logged-out-safe local action set unless policy or explicit approval grants more. - Verified Warp-terminal invocations may receive broader local-control grants when the user's granular settings allow them. @@ -264,7 +269,7 @@ This model does not make untrusted same-user software safe. A malicious local pr ### Credential storage Credential storage should be platform-appropriate: - Local discovery may store a credential reference rather than the credential itself. -- The authoritative local-control enablement state should use the same class of protected local storage as raw credential material, but it should be accessible to the Warp app for in-app settings UI and not writable by `warpctrl` or arbitrary external apps. +- The authoritative local-control enablement states for inside-Warp and outside-Warp scripting should use the same class of protected local storage as raw credential material, but they should be accessible to the Warp app for the Settings > Scripting UI and not writable by `warpctrl` or arbitrary external apps. - Raw long-lived credentials should prefer platform-secure storage such as macOS Keychain or Windows Credential Manager when practical. - On macOS, raw control secrets should be stored in Keychain and restricted to trusted Warp-signed code using a designated requirement, Keychain access group, trusted-application ACL, or equivalent code-signing based mechanism. Restricting by filesystem path alone is insufficient because paths can be replaced or wrapped. - Keychain item access should include the Warp app, the signed `warpctrl` binary, and any signed Warp-owned local broker/helper that needs to unwrap raw secrets. It should exclude arbitrary same-user applications. @@ -287,7 +292,7 @@ The default transport is an instance-local loopback listener bound to `127.0.0.1 Transport requirements: - Bind only to loopback for local control. - Do not set permissive CORS headers. -- Reject control requests while local control scripting is disabled, even if the request presents an otherwise valid credential. +- Reject control requests when their inside-Warp or outside-Warp invocation context is disabled, even if the request presents an otherwise valid credential. - Authenticate every control request locally in the selected Warp app process before selector resolution or action dispatch. - Reject missing, malformed, expired, revoked, or invalid credentials with structured authentication errors. - Keep unauthenticated health metadata minimal and non-sensitive. @@ -413,7 +418,7 @@ Error-level logs should be used only for conditions that need developer attentio ## Security- and safety-relevant errors Structured errors are part of the security contract. Important errors include: -- `local_control_disabled` when the user has not enabled local control scripting in Warp settings or has disabled it after credentials were issued; +- `local_control_disabled` when the relevant inside-Warp or outside-Warp scripting context is disabled in Settings > Scripting or has been disabled after credentials were issued; - `unauthorized_local_client` for missing, malformed, expired, revoked, or invalid credentials; - `insufficient_permissions` for valid credentials that lack the requested safety tier or target scope; - `authenticated_user_required` when an action requires a logged-in Warp user but the credential lacks an authenticated-user grant; @@ -430,8 +435,8 @@ Important errors include: The app must not downgrade these failures into broader default actions, and the CLI must preserve structured server errors in both human-readable and JSON output. ## Required controls before full catalog expansion Before shipping each action family, verify that these controls are implemented for that family: -- Local control scripting must be explicitly enabled in Warp's app UI before the action family can run. -- The authoritative enablement state is protected from external writes and is local-only rather than synced. +- Local control scripting must be enabled for the request's invocation context before the action family can run; inside-Warp control defaults on and outside-Warp control defaults off. +- The authoritative enablement states live under Settings > Scripting, are protected from external writes, and are local-only rather than synced. - The action has a documented tier. - The action has a documented `requires_authenticated_user` value. New actions default to `true` unless explicitly reviewed as logged-out-safe. - The action documents allowed execution contexts and whether external clients may run it. @@ -448,13 +453,13 @@ Before shipping each action family, verify that these controls are implemented f ## Platform requirements ### macOS and Linux Discovery files must be stored in a per-user directory with owner-only permissions. -On macOS, raw credential material and the authoritative local-control enablement state should live in Keychain, not in the discovery record or an ordinary preferences file. Keychain access should be constrained to Warp-owned signed binaries or helpers using code-signing based access control. The enablement state should be writable by the Warp app's in-app settings flow and not writable by `warpctrl`. The discovery record should hold only metadata and a credential reference when local control is enabled. -On Linux, raw credentials and the authoritative enablement state should prefer platform-secure storage where available; otherwise short-lived scoped credentials may live in owner-only local state with strict file and directory permissions. If the enablement state falls back to owner-only local state, the weaker same-user protection should be documented. +On macOS, raw credential material and the authoritative local-control enablement states should live in Keychain, not in the discovery record or an ordinary preferences file. Keychain access should be constrained to Warp-owned signed binaries or helpers using code-signing based access control. The enablement states should be writable by the Warp app's Settings > Scripting flow and not writable by `warpctrl`. The discovery record should hold only metadata and a credential reference when the relevant inside-Warp or outside-Warp context is enabled. +On Linux, raw credentials and the authoritative enablement states should prefer platform-secure storage where available; otherwise short-lived scoped credentials may live in owner-only local state with strict file and directory permissions. If an enablement state falls back to owner-only local state, the weaker same-user protection should be documented. Unix domain sockets with peer credential checks may be considered for stronger same-machine identity than bearer tokens alone. ### Windows Discovery records and credential material must live under the current user's app data directory with ACLs restricted to the current user, Administrators, and SYSTEM. -The authoritative enablement state should use Credential Manager, DPAPI-backed protected storage, or an equivalent app-controlled protected store rather than a normal registry setting that arbitrary same-user processes can write. -Windows support for authenticated local control should not be considered complete until the implementation creates, validates, and tests those ACLs and the protected enablement-state behavior. +The authoritative enablement states should use Credential Manager, DPAPI-backed protected storage, or an equivalent app-controlled protected store rather than normal registry settings that arbitrary same-user processes can write. +Windows support for authenticated local control should not be considered complete until the implementation creates, validates, and tests those ACLs and the protected enablement-state behavior for both inside-Warp and outside-Warp settings. ## Remote control is separate The local architecture intentionally assumes same-machine, same-user control over a loopback listener. Future remote URLs must use a different security design that includes: - transport encryption; diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index 8d3c5cfff2..60d9acbe1a 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -1,6 +1,6 @@ # Context `PRODUCT.md` defines a standalone local Warp control CLI binary, provisionally named `warpctrl`, with an allowlisted action catalog, deterministic addressing across multiple running Warp app processes, and an incremental implementation plan. -`SECURITY.md` is the normative security architecture for this feature. Implementation work must follow it for explicit in-app enablement, protected enablement storage, granular permissions, discovery metadata, credential storage, scoped safety grants, verified execution context, authenticated-user requirements, localhost/browser protections, action-tier enforcement, deterministic target resolution, and local app-side validation. If this technical plan and `SECURITY.md` disagree, update the plan before implementing rather than treating the security architecture as optional follow-up work. +`SECURITY.md` is the normative security architecture for this feature. Implementation work must follow it for separate inside-Warp and outside-Warp enablement, the top-level Settings > Scripting surface, protected enablement storage, granular permissions, discovery metadata, credential storage, scoped safety grants, verified execution context, authenticated-user requirements, localhost/browser protections, action-tier enforcement, deterministic target resolution, and local app-side validation. If this technical plan and `SECURITY.md` disagree, update the plan before implementing rather than treating the security architecture as optional follow-up work. The existing app already has three relevant building blocks: - `crates/http_server/src/lib.rs (7-61)` runs a native-only loopback Axum server on fixed port `9277`. - `app/src/lib.rs (1993-2001)` registers that HTTP server in the native app and currently merges only installation-detection and profiling routers. @@ -26,11 +26,12 @@ The most important constraint surfaced by this code is that the current fixed-po ### 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: -- Local control scripting is disabled by default and can only be enabled by an in-app Warp settings flow. -- The authoritative enablement state is local-only, not Settings Sync'd, and stored in protected local storage rather than ordinary user-editable settings. -- `warpctrl`, direct protocol requests, shell scripts, config files, registry/plist edits, and server-backed preferences must not be able to enable local control scripting. -- Discovery records do not publish actionable endpoints or credential references while local control scripting is disabled. -- Credential issuance is unavailable while local control scripting is disabled. +- Local control scripting has separate inside-Warp and outside-Warp enablement states. Inside-Warp control for verified Warp-managed terminal sessions defaults on; outside-Warp control for external terminals, scripts, IDEs, launch agents, and other same-user processes defaults off. +- Both controls live under a new top-level Settings pane page named **Scripting**. +- The authoritative enablement states are local-only, not Settings Sync'd, and stored in protected local storage rather than ordinary user-editable settings. +- `warpctrl`, direct protocol requests, shell scripts, config files, registry/plist edits, defaults writes, and server-backed preferences must not be able to enable either setting. +- Discovery records do not publish actionable endpoints or credential references for disabled outside-Warp control. +- Credential issuance is unavailable when the request's invocation context is disabled. - Raw credential material is kept out of plaintext discovery records and stored in platform secure storage where available. - The broker distinguishes verified Warp-terminal invocations from external invocations using an app-issued execution-context proof, not a caller-declared label. - External invocations default to a smaller logged-out-safe action set that does not touch user-authenticated data. @@ -38,7 +39,7 @@ Required security gates: - The app rejects disabled, unauthenticated, expired, revoked, insufficient-scope, unsupported, malformed, ambiguous, missing-target, and stale-target requests with structured errors. - Every action has a documented risk tier and the app bridge enforces the required tier locally before selector resolution or handler dispatch. - Every action has a documented `requires_authenticated_user` value and allowed execution contexts. New actions default to requiring an authenticated user unless explicitly reviewed as logged-out-safe. -- Granular local-control settings gate the maximum grants for metadata reads, terminal-data reads, non-destructive mutations, destructive/execution actions, authenticated-user actions from Warp terminals, and authenticated-user actions from external clients. +- Granular local-control settings under Settings > Scripting gate the maximum grants for metadata reads, terminal-data reads, non-destructive mutations, destructive/execution actions, authenticated-user actions from Warp terminals, and authenticated-user actions from external clients. - Safety tiers are treated as user-intent and accident-prevention guardrails, not as strong same-user malicious-app isolation. - Remote control remains out of scope for the local same-machine credential model. The first implementation slice should include the protected enablement gate, credential issuance checks, and app-side tier enforcement even if the only mutating action initially implemented is `tab.create`. Shipping `tab.create` without the enablement and validation architecture would create the wrong foundation for the full catalog. @@ -97,16 +98,16 @@ Recommended design: - control-listener endpoint - protocol version - start timestamp - - credential metadata or secure-storage references only when local control scripting is enabled + - credential metadata or secure-storage references only when the relevant inside-Warp or outside-Warp context is enabled - The CLI loads discovery records, removes or ignores stale records after health checks, and chooses an instance using the product selector rules. - `warpctrl instance list` is a CLI-first projection of this discovery registry plus health responses. -When local control scripting is disabled, discovery must follow `SECURITY.md`: either publish no actionable local-control record or publish only a minimal disabled-status record with no endpoint authority or credential reference. +When outside-Warp control is disabled, discovery must follow `SECURITY.md`: either publish no actionable local-control record for external clients or publish only a minimal disabled-status record with no endpoint authority or credential reference. This design preserves the current `9277` behavior while avoiding cross-process port contention for the new control API. ### 3. Local authentication, enablement, and safety boundary Mutating localhost routes should not copy the permissive CORS posture of `/install_detection`. Recommended local trust model: - No browser-readable CORS allowance on control endpoints. -- Local control scripting must be explicitly enabled in the Warp app before credentials are minted or sensitive control requests are accepted. +- The relevant inside-Warp or outside-Warp Scripting setting must allow the request context before credentials are minted or sensitive control requests are accepted. - The authoritative enablement bit must live in protected local storage and must not be writable by `warpctrl` or ordinary same-user preference/config edits. - Per-instance raw credential material must be kept out of plaintext discovery records and stored in platform secure storage where practical. - The CLI may load or request scoped credentials through an app-owned broker/helper, but it must not mint authority itself. @@ -138,7 +139,7 @@ The bridge uses WarpUI's `ModelSpawner` mechanism, which is the standard way ``` HTTP handler (Tokio thread) │ - ├─ verify local control scripting is enabled + ├─ verify inside-Warp or outside-Warp context is enabled ├─ verify credential, execution context, safety grant, and authenticated-user grant ├─ deserialize RequestEnvelope ├─ call bridge_spawner.spawn(move |bridge, ctx| { @@ -149,7 +150,7 @@ HTTP handler (Tokio thread) LocalControlBridge::handle_request (main thread) │ - ├─ verify protected enablement state is still enabled + ├─ verify protected context-specific enablement state is still enabled ├─ map action to required risk tier ├─ map action to authenticated-user and execution-context requirements ├─ verify presented credential grants that tier, target family, execution context, and authenticated-user access @@ -220,11 +221,12 @@ Do not use a generic “dispatch action by string” endpoint. Every handler sho ### 7. First slice: prove discovery and `tab.create` The first `warpctrl` implementation slice should land the minimum cross-cutting architecture plus a single representative tab mutation: - Shared protocol types and error envelopes. -- Protected in-app enablement setting and protected local-only enablement storage. -- Granular local-control permission storage for at least metadata, non-destructive local mutations, and authenticated-user-action categories. +- New top-level Settings > Scripting page with separate protected inside-Warp and outside-Warp enablement states. +- Protected local-only enablement storage where inside-Warp control defaults on and outside-Warp control defaults off. +- Granular local-control permission storage under Settings > Scripting for at least metadata, non-destructive local mutations, and authenticated-user-action categories. - Discovery registry and CLI instance selection. - A standalone `warpctrl` binary or artifact path that runs control commands without starting the GUI app runtime. -- Per-process authenticated local-control server that refuses sensitive work when local control scripting is disabled. +- Per-process authenticated local-control server that refuses sensitive work when the request's inside-Warp or outside-Warp context is disabled. - Scoped credential issuance/storage with no raw credentials in plaintext discovery records, including execution-context fields and authenticated-user grant fields. - App-side request bridge and selector resolver. - Action-tier mapping and app-side safety-grant enforcement. @@ -289,7 +291,7 @@ sequenceDiagram CLI->>BROKER: Request scoped credential for action + execution context BROKER-->>CLI: Grant or structured denial CLI->>HTTP: Authenticated POST tab.create request - HTTP->>HTTP: Verify enablement + credential + execution context + HTTP->>HTTP: Verify context-specific enablement + credential + execution context HTTP->>BRIDGE: Typed request + response channel BRIDGE->>BRIDGE: Recheck enablement + tier + auth-user policy BRIDGE->>RES: Resolve window/tab/pane/session selectors @@ -305,13 +307,13 @@ sequenceDiagram ## Testing and validation Map tests directly to `PRODUCT.md` behavior. - Security architecture: - - Protected enablement tests proving disabled state rejects credential issuance, sensitive discovery, and mutating requests with `local_control_disabled`. + - Protected enablement tests proving inside-Warp control defaults on, outside-Warp control defaults off, and disabled contexts reject credential issuance, sensitive discovery, and mutating requests with `local_control_disabled`. - Tests proving discovery in disabled state exposes no actionable endpoint authority or credential reference. - Credential-storage tests proving raw credentials are not written into plaintext discovery records. - Execution-context tests proving external clients cannot receive grants reserved for verified Warp-terminal invocations. - Tier-enforcement tests proving insufficient grants fail with `insufficient_permissions` before selector resolution or handler dispatch. - Authenticated-user tests proving user-authenticated actions fail without a logged-in app user or authenticated-user grant. - - Granular-permission tests proving disabled categories invalidate credentials and prevent new grants. + - Settings > Scripting tests proving both top-level toggles and granular disabled categories invalidate credentials and prevent new grants. - Structured-error tests for disabled, unauthenticated, expired, revoked, insufficient-scope, execution-context-denied, authenticated-user-required, authenticated-user-unavailable, unsupported, malformed, ambiguous, missing-target, stale-target, and invalid-selector requests. - Behavior 1-6, 29-31: - Protocol version/unit tests. @@ -363,8 +365,8 @@ flowchart LR - Mitigation: leave current `9277` endpoints undisturbed and use a per-process control listener plus discovery registry. - Browser-to-localhost abuse: - Mitigation: no permissive CORS, protected in-app enablement, explicit local auth, scoped grants, and mutating routes gated before selector resolution. -- External apps silently enabling local control: - - Mitigation: authoritative enablement state lives in protected local storage, is local-only, is not Settings Sync'd, and is not writable through `warpctrl`, config files, registry/plist preference edits, or server-backed settings. +- External apps silently enabling outside-Warp local control: + - Mitigation: the outside-Warp enablement state defaults off, lives in protected local storage behind Settings > Scripting, is local-only, is not Settings Sync'd, and is not writable through `warpctrl`, config files, registry/plist preference edits, defaults writes, or server-backed settings. - External apps obtaining in-Warp authenticated-user grants: - Mitigation: require an app-issued execution-context proof for Warp-terminal-only grants, do not trust caller-declared labels or plain environment variables as sole authority, and keep external authenticated-user grants behind a separate default-off permission. - Logged-out requests touching user-authenticated data: From 6b9f0db906fc27a428e16306a457b66d0d1635d0 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd Date: Fri, 22 May 2026 17:48:00 -0600 Subject: [PATCH 09/48] Update warpctrl permission taxonomy specs Co-Authored-By: Oz --- specs/warp-control-cli/PRODUCT.md | 237 ++++++++++++++++++++++++----- specs/warp-control-cli/SECURITY.md | 150 +++++++++--------- specs/warp-control-cli/TECH.md | 157 ++++++++++++++----- 3 files changed, 394 insertions(+), 150 deletions(-) diff --git a/specs/warp-control-cli/PRODUCT.md b/specs/warp-control-cli/PRODUCT.md index d83b347d06..5ade7bbd5d 100644 --- a/specs/warp-control-cli/PRODUCT.md +++ b/specs/warp-control-cli/PRODUCT.md @@ -166,41 +166,192 @@ Non-goals: The first slice should include the minimum health/introspection commands needed to discover a running instance and exercise `tab.create`. 34. Follow-up PRs should fill out the remaining catalog in parallelizable groups once the protocol, discovery model, target resolution, error model, `tab.create` action path, and standalone `warpctrl` packaging shape have been validated by the first slice. 35. The protocol transport should be designed so that the default target is localhost but the CLI can be extended in the future to target remote URLs (e.g., a Warp instance on another machine or a hosted control endpoint). This is not in scope for the first implementation but should not be precluded by the architecture. +## API command surface +The public `warpctrl` API is organized around nouns that map to stable user-facing entities. Command names are intentionally not a dump of every internal `WorkspaceAction`, `TerminalAction`, keybinding, or command-palette binding. Internal actions inform the catalog, but a command is added only when it has a stable user-facing behavior, typed parameters, deterministic target resolution, and an explicit risk classification. +### State and data taxonomy +The product surface must distinguish what kind of state a command touches. This distinction is part of the public API and the permission model, not just an implementation detail. +- **Metadata reads** inspect app structure or configuration metadata without exposing user content: instances, windows, tabs, panes, sessions, capability metadata, action metadata, keybinding metadata, theme names, setting keys, current project identity, and other structural state. +- **Underlying data reads** expose user content or data-bearing state without changing it: terminal output, block contents, command history, input buffer contents, file contents, Warp Drive object contents, AI conversation content, and any other content that could contain user data or secrets. +- **App-state mutations** change visible local Warp UI state without directly changing user data: opening or focusing windows, creating or closing tabs, splitting panes, focusing panes, opening panels, opening command surfaces, opening files in Warp, and editing the input buffer without submitting it. +- **Metadata/configuration mutations** change persistent configuration or metadata, but not primary user content: changing themes, font size, zoom, allowlisted settings, keybindings, tab names, pane names, and tab colors. +- **Underlying data mutations** can change user data or cause external side effects: executing terminal commands, writing/creating/deleting files, running workflows that execute commands, CRUD operations on Warp Drive objects, mutating AI conversation history, and any action that can modify data outside transient app UI state. +A command that touches multiple categories must require the strongest applicable permission. For example, `file open` is an app-state mutation, while `file write` is an underlying data mutation; `input insert` is an app-state mutation, while `input run` is an underlying data mutation because it executes a command in the target session. +### Targeting flags +All commands that address a running app target accept the same selector flags where meaningful: +- `--instance ` selects a running Warp process from `warpctrl instance list`. +- `--pid ` is a convenience instance selector and conflicts with `--instance`. +- `--window ` selects a window inside the instance. +- `--tab ` selects a tab inside the window. +- `--pane ` selects a pane inside the tab or pane-group context. +- `--session ` selects a terminal or agent session inside the pane when the command is session-scoped. +- `--output-format ` controls output shape and remains globally available. +Omitted lower-level selectors use active defaults only when that active target is unambiguous. Explicit IDs must resolve exactly or fail with `stale_target`. +### Read-only command set +The read-only branch `zach/warp-cli-readonly` should implement the following commands before mutating catalog expansion begins. Read-only does not mean one permission: metadata reads and underlying data reads are separate grant categories. +Metadata and capability reads: +- `warpctrl instance list` +- `warpctrl instance inspect [--instance |--pid ]` +- `warpctrl app ping [selectors]` +- `warpctrl app version [selectors]` +- `warpctrl app active [selectors]` +- `warpctrl capability list [selectors]` +- `warpctrl capability inspect [selectors]` +Window, tab, pane, and session reads: +- `warpctrl window list [selectors]` +- `warpctrl window inspect [--window ] [selectors]` +- `warpctrl tab list [--window ] [selectors]` +- `warpctrl tab inspect [--tab ] [selectors]` +- `warpctrl pane list [--tab ] [selectors]` +- `warpctrl pane inspect [--pane ] [selectors]` +- `warpctrl session list [--pane ] [selectors]` +- `warpctrl session inspect [--session ] [selectors]` +Underlying data reads, gated separately from structural metadata reads: +- `warpctrl block list [--pane ] [--limit ] [selectors]` +- `warpctrl block inspect [selectors]` +- `warpctrl block output [--plain|--ansi|--json] [selectors]` +- `warpctrl input get [--session ] [selectors]` +- `warpctrl history list [--session ] [--limit ] [selectors]` +Appearance, settings, and command-surface reads: +- `warpctrl theme list [selectors]` +- `warpctrl theme get [selectors]` +- `warpctrl appearance get [selectors]` +- `warpctrl setting list [--namespace ] [selectors]` +- `warpctrl setting get [selectors]` +- `warpctrl keybinding list [selectors]` +- `warpctrl keybinding get [selectors]` +- `warpctrl action list [selectors]` +- `warpctrl action inspect [selectors]` +Local file and project reads that expose only app/editor state, not arbitrary filesystem traversal: +- `warpctrl file list [selectors]` +- `warpctrl project active [selectors]` +- `warpctrl project list [selectors]` +Authenticated read-only Warp Drive metadata and data reads, enabled only when the selected app has a logged-in Warp user and the grant allows authenticated reads. Listing is metadata; inspecting object content is an underlying data read: +- `warpctrl drive list --type [selectors]` +- `warpctrl drive inspect [selectors]` +### Mutating command set +The stacked branch `zach/warp-cli-read-write` should build on `zach/warp-cli-readonly` and implement the following mutating commands. Mutating commands are split by what they mutate: app-state, metadata/configuration, or underlying data. Underlying data mutations require a separate and stronger permission than app-state or metadata/configuration mutations. +App-state mutations for app, window, and surfaces: +- `warpctrl app focus [selectors]` +- `warpctrl window create [--shell ] [selectors]` +- `warpctrl window focus --window [selectors]` +- `warpctrl window close --window [selectors]` +- `warpctrl surface settings open [--page ] [--query ] [selectors]` +- `warpctrl surface command-palette open [--query ] [selectors]` +- `warpctrl surface command-search open [--query ] [selectors]` +- `warpctrl surface warp-drive open [selectors]` +- `warpctrl surface warp-drive toggle [selectors]` +- `warpctrl surface resource-center toggle [selectors]` +- `warpctrl surface ai-assistant toggle [selectors]` +- `warpctrl surface code-review toggle [selectors]` +- `warpctrl surface left-panel toggle [selectors]` +- `warpctrl surface right-panel toggle [selectors]` +- `warpctrl surface vertical-tabs toggle [selectors]` +App-state mutations for tabs: +- `warpctrl tab create [--type terminal|agent|cloud-agent|default] [--shell ] [selectors]` +- `warpctrl tab activate --tab [selectors]` +- `warpctrl tab activate --previous [selectors]` +- `warpctrl tab activate --next [selectors]` +- `warpctrl tab activate --last [selectors]` +- `warpctrl tab move --tab --direction [selectors]` +- `warpctrl tab close --tab [selectors]` +- `warpctrl tab close --active [selectors]` +- `warpctrl tab close --others --tab [selectors]` +- `warpctrl tab close --right-of --tab [selectors]` +Metadata mutations for tabs: +- `warpctrl tab rename --tab [selectors]` +- `warpctrl tab reset-name --tab <selector> [selectors]` +- `warpctrl tab color set --tab <selector> <color> [selectors]` +- `warpctrl tab color clear --tab <selector> [selectors]` +App-state mutations for panes: +- `warpctrl pane split --direction <left|right|up|down> [--shell <name>] [selectors]` +- `warpctrl pane focus --pane <selector> [selectors]` +- `warpctrl pane navigate --direction <left|right|up|down|previous|next> [selectors]` +- `warpctrl pane resize --direction <left|right|up|down> [--amount <cells>] [selectors]` +- `warpctrl pane maximize [--pane <selector>] [selectors]` +- `warpctrl pane unmaximize [selectors]` +- `warpctrl pane close --pane <selector> [selectors]` +Metadata mutations for panes: +- `warpctrl pane rename --pane <selector> <title> [selectors]` +- `warpctrl pane reset-name --pane <selector> [selectors]` +App-state mutations for sessions and input buffers: +- `warpctrl session activate --session <selector> [selectors]` +- `warpctrl session previous [selectors]` +- `warpctrl session next [selectors]` +- `warpctrl session reopen-closed [selectors]` +- `warpctrl input insert <text> [--session <selector>] [selectors]` +- `warpctrl input replace <text> [--session <selector>] [selectors]` +- `warpctrl input clear [--session <selector>] [selectors]` +- `warpctrl input mode set <terminal|agent> [--session <selector>] [selectors]` +Underlying data mutations for terminal execution: +- `warpctrl input run <command> [--session <selector>] [selectors]` +Metadata/configuration mutations for appearance and settings: +- `warpctrl theme set <theme_name> [selectors]` +- `warpctrl theme system set <true|false> [selectors]` +- `warpctrl theme light set <theme_name> [selectors]` +- `warpctrl theme dark set <theme_name> [selectors]` +- `warpctrl appearance font-size increase [selectors]` +- `warpctrl appearance font-size decrease [selectors]` +- `warpctrl appearance font-size reset [selectors]` +- `warpctrl appearance zoom increase [selectors]` +- `warpctrl appearance zoom decrease [selectors]` +- `warpctrl appearance zoom reset [selectors]` +- `warpctrl setting set <key> <value> [selectors]` +- `warpctrl setting toggle <key> [selectors]` +App-state mutations for files, projects, and Warp Drive views: +- `warpctrl file open <path> [--line <line>] [--column <column>] [--new-tab] [selectors]` +- `warpctrl project open <path> [selectors]` +- `warpctrl drive open <id> [selectors]` +- `warpctrl drive notebook open <id> [selectors]` +- `warpctrl drive env-var-collection open <id> [selectors]` +Underlying data mutations for files and authenticated Warp Drive objects: +- `warpctrl file create <path> [--content <text>] [selectors]` +- `warpctrl file write <path> --content <text> [selectors]` +- `warpctrl file append <path> --content <text> [selectors]` +- `warpctrl file delete <path> [selectors]` +- `warpctrl drive workflow run <id> [--arg <name=value>...] [selectors]` +- `warpctrl drive object create --type <workflow|notebook|env-var-collection|prompt|folder> [selectors]` +- `warpctrl drive object update <id> [selectors]` +- `warpctrl drive object trash <id> [selectors]` +- `warpctrl drive object restore <id> [selectors]` +### Excluded from the public command surface +The command surface must continue to exclude debug-only, crash-only, auth-token, heap-dump, and arbitrary internal dispatch actions even when those actions are available in command palette or keybinding registries. Examples that remain excluded are app crash/panic helpers, access-token copy helpers, heap profile dumps, debug reset actions, raw view-tree debugging, and broad internal action-by-string execution. +## Built-in Warp Agent skill +Warp should include a built-in Agent skill for `warpctrl`, analogous to the bundled `oz-platform` skill. The skill should teach Warp Agent when to use `warpctrl`, how to discover and target running instances, how to prefer read-only commands before mutating commands, how to request explicit user approval for underlying data mutations, and how to interpret structured errors. The skill should document the stable command hierarchy above, include concise recipes for common automation tasks, and avoid instructing agents to bypass the CLI by calling local-control HTTP endpoints directly. ## Action classification and permission model -Agents, scripts, and human developers are expected to be major consumers of `warpctrl`. The action catalog must therefore classify every action by both risk tier and authenticated-user requirement so Warp can enforce local-control permissions in the app bridge. +Agents, scripts, and human developers are expected to be major consumers of `warpctrl`. The action catalog must therefore classify every action by risk posture, state/data category, permission category, and authenticated-user requirement so Warp can enforce local-control permissions in the app bridge. Every action definition must include: - a stable action name and namespace; -- a risk tier; +- a risk posture; +- a state/data category: metadata read, underlying data read, app-state mutation, metadata/configuration mutation, or underlying data mutation; - whether a true logged-in Warp user is required; - whether the action may run from external clients, verified Warp-terminal clients, or both; - whether inside-Warp and outside-Warp scripting settings can enable the action; - the required local-control permission category; - any target-scope restrictions. By default, new actions require an authenticated Warp user. An action may be marked logged-out-safe only after deliberate review confirms it does not touch Warp Drive, AI conversation traces, synced settings, team/account data, cloud-backed user state, terminal content, or other user-sensitive data. -### Classification tiers -Every action in the catalog belongs to exactly one of the following tiers, from least to most sensitive: -1. **Read-only / metadata.** Actions that return local app structure or configuration without exposing terminal content or user-authenticated data. +### Permission categories +Every action in the catalog belongs to exactly one of the following permission categories, from least to most sensitive: +1. **Read-only / metadata.** Actions that return local app structure, app state, or configuration metadata without exposing terminal content, file content, Warp Drive object content, AI conversation content, or other user data. - Instance discovery and health: `instance list`, `app active`, `app version`, `app ping`. - Layout enumeration: `window list`, `tab list`, `pane list`, `session list`. - - Appearance reads that return local configuration values but not user data: `theme list`, selected `setting get` actions for logged-out-safe local settings. - These are the primary initial actions for external clients after outside-Warp control is explicitly enabled. -2. **Read-only / terminal data.** Actions that return content from terminal sessions, command history, pane output buffers, input editor state, session replay, or terminal-derived traces. - - Reading pane output or scrollback content. - - Reading the current input buffer contents. - - Reading command history or session replay data. - Even though these are read-only, they cross a privacy boundary that metadata reads do not. -3. **Mutating / non-destructive.** Actions that change app state in visible, reversible, or low-risk ways without executing terminal content or destroying user state. - - Layout mutations: `tab create`, `tab activate`, `tab move`, `tab rename`, `window create`, `window focus`, `pane split`, `pane focus`, `pane navigate`, `pane maximize`, `pane resize`. - - Appearance mutations: `theme set`, `font-size increase/decrease/reset`, `zoom increase/decrease/reset`. - - Settings writes for allowlisted non-destructive local settings. - - Panel/surface toggles where they do not expose authenticated user data. -4. **Mutating / destructive or high-risk.** Actions that destroy user state, close active work, inject terminal input, execute commands, or execute user-authored content. - - Closing targets: `tab close`, `window close`, `pane close`. - - Terminal input injection: `input insert`, `input replace`, `input clear`. - - Command execution in a session. - - Input mode switching between terminal and agent modes. - - Executing Warp Drive workflows or notebooks in a terminal session. - Any action that can cause data loss or execute arbitrary code belongs here regardless of how simple the API looks. + - Metadata reads: `theme list`, `setting list`, `keybinding list`, `action list`, `project active`, and Drive object listing that returns object IDs/names/types but not content. +2. **Read-only / underlying data.** Actions that return user content or data-bearing state without changing it. + - Terminal reads: block output, scrollback, command history, input editor contents, session replay, or terminal-derived traces. + - File reads, Warp Drive object content reads, AI conversation reads, and any authenticated-user data read. + This category is separate from metadata because read-only content can contain secrets, source code, customer data, command output, or other sensitive data. +3. **Mutating / app state.** Actions that change visible local Warp UI state without directly changing underlying user data. + - Layout and focus: `window create`, `window focus`, `tab create`, `tab activate`, `tab move`, `window close`, `tab close`, `pane split`, `pane focus`, `pane navigate`, `pane maximize`, `pane resize`, and panel/surface toggles. + - Input-buffer staging: `input insert`, `input replace`, and `input clear` as long as they do not submit or execute the buffer. + - Opening views: opening settings, command palette, command search, Warp Drive, code review, files, projects, notebooks, and env-var collections. +4. **Mutating / metadata or configuration.** Actions that change persistent metadata or configuration but do not directly mutate primary user data. + - Tab and pane names, tab colors, themes, system-theme settings, font size, zoom, allowlisted app settings, and keybindings. + Metadata/configuration writes need a stronger permission than app-state-only changes because they persist beyond the current UI interaction, but they are still distinct from data writes. +5. **Mutating / underlying data.** Actions that can change user data, execute code, or cause external side effects. + - Terminal execution: `input run`, workflow execution in a terminal session, and any command execution path. + - File writes: create, write, append, delete, rename, or otherwise modify local files. + - Warp Drive CRUD: create, update, trash, restore, permanently delete, or otherwise mutate workflows, notebooks, prompts, env-var collections, folders, or other Drive objects. + - AI conversation history mutation and any action that modifies cloud-backed user content. + This category must be explicitly separate from app-state mutation. A client allowed to open or focus Warp UI must not automatically be allowed to execute commands, write files, or mutate Warp Drive content. ### Authenticated-user requirement An authenticated user means a true logged-in Warp user in the selected Warp app, not merely the local OS user or a `warpctrl` process authenticated to localhost. The allowlist must clearly indicate `requires_authenticated_user` for every action: @@ -220,18 +371,19 @@ Warp should add a new top-level Settings pane page named **Scripting**. This pag The Scripting page should explain that inside-Warp control is scoped to commands launched from Warp-managed terminals, while outside-Warp control allows other local apps and scripts to talk to Warp's control plane. Disabling either top-level toggle should invalidate credentials for that invocation context. ### Granular local-control permissions The Scripting settings page should expose granular permissions beneath the inside-Warp and outside-Warp toggles. Recommended controls: -- Allow local read-only metadata. -- Allow terminal data reads. -- Allow non-destructive local mutations. -- Allow destructive or execution actions. +- Allow metadata reads. +- Allow underlying data reads. +- Allow app-state mutations. +- Allow metadata/configuration mutations. +- Allow underlying data mutations. - Allow authenticated-user actions from verified Warp terminals. - Allow authenticated-user actions from external clients, default off and separate from the in-Warp permission. -These settings define the maximum grants the broker may issue. The app bridge still enforces the action's risk tier, authenticated-user requirement, execution-context requirement, and target scope for every request. +These settings define the maximum grants the broker may issue. The app bridge still enforces the action's risk posture, state/data category, authenticated-user requirement, execution-context requirement, and target scope for every request. Enabling app-state mutation must not imply permission to mutate underlying data. ### Scoped credentials The local discovery record must not expose a reusable full-access credential. `warpctrl` should request scoped credentials from an app-owned broker or equivalent trusted path. Scoped credentials should include: - the selected Warp instance; -- granted risk tiers; +- granted permission categories; - allowed action families; - verified execution context; - whether authenticated-user access is granted and for which logged-in user subject; @@ -242,25 +394,28 @@ The bridge, not the CLI frontend, enforces these grants. If a request exceeds it ### Future entity extensibility: files and Warp Drive objects The selector and action model should be designed to accommodate entity types beyond the current window/tab/pane/session hierarchy. Two important future entity families are **local files** and **Warp Drive objects** (workflows, notebooks, environment variables, prompts). Neither is in scope for the first implementation, but the protocol should not preclude them. **Files.** Warp already supports file opening via deep links and the built-in editor. A future `file` namespace could support: -- `warpctrl file open <path>` — open a file in a Warp editor tab, equivalent to clicking a file link. -- `warpctrl file open <path> --line <n>` — open at a specific line. -- `warpctrl file list` — list files currently open in editor tabs across the instance. +- `warpctrl file open <path>` — app-state mutation that opens a file in a Warp editor tab, equivalent to clicking a file link. +- `warpctrl file open <path> --line <n>` — app-state mutation that opens at a specific line. +- `warpctrl file list` — metadata read that lists files currently open in editor tabs across the instance. +- `warpctrl file read <path>` — underlying data read that returns file contents. +- `warpctrl file create|write|append|delete <path>` — underlying data mutations that modify the filesystem. File selectors would use filesystem paths (absolute or relative to the working directory of the target pane/session). Unlike window/tab/pane selectors, file selectors are not opaque IDs — they are user-visible paths. The protocol should support a `file` field in the target selector that accepts a path string, distinct from the opaque ID selectors used for windows, tabs, and panes. **Warp Drive objects.** Warp Drive stores typed objects (workflows, notebooks, environment variable sets, prompts) that users can reference, execute, and share. A future `drive` namespace could support: -- `warpctrl drive list --type workflow` — list Warp Drive objects by type. -- `warpctrl drive get <id>` — retrieve a specific Drive object by its opaque ID or by name/path. -- `warpctrl drive run <workflow-id>` — execute a workflow in a target session, equivalent to invoking it from the command palette. -- `warpctrl drive insert <notebook-id>` — insert a notebook's runnable commands into the active input. -Drive object selectors should support both opaque IDs (for automation stability) and human-friendly name/path lookups (for interactive use). The type field (`workflow`, `notebook`, `env_var`, `prompt`) acts as a namespace filter. Drive actions that execute content in a terminal session (e.g., running a workflow) inherit the destructive/high-risk tier from the action classification model. +- `warpctrl drive list --type workflow` — authenticated metadata read that lists Warp Drive objects by type. +- `warpctrl drive inspect <id>` — authenticated underlying data read when it returns object content. +- `warpctrl drive workflow run <workflow-id>` — authenticated underlying data mutation that executes a workflow in a target session. +- `warpctrl drive object create|update|trash|restore <id>` — authenticated underlying data mutations that change cloud-backed user content. +- `warpctrl drive notebook open <notebook-id>` — app-state mutation that opens a view of an existing notebook without modifying it. +Drive object selectors should support both opaque IDs (for automation stability) and human-friendly name/path lookups (for interactive use). The type field (`workflow`, `notebook`, `env_var`, `prompt`) acts as a namespace filter. Drive actions that execute content in a terminal session (e.g., running a workflow) inherit the underlying-data-mutation permission from the action classification model. **Design constraints for both:** - File and Drive selectors are orthogonal to the window/tab/pane hierarchy — a file open action targets an instance (which window to open in), not a specific pane. A Drive workflow execution targets a session (which pane to run in). - The `TargetSelector` type in the protocol should be extensible with optional fields for these new selector families without breaking existing requests that omit them. -- The action classification tiers apply, and Drive actions require authenticated-user grants by default: listing Drive objects is tier 1 metadata plus authenticated user, reading Drive object content is tier 1 or 2 depending on whether it contains user data plus authenticated user, and executing a Drive workflow is tier 4 plus authenticated user. +- The action classification categories apply, and Drive actions require authenticated-user grants by default: listing Drive objects is metadata plus authenticated user, reading Drive object content is underlying-data-read plus authenticated user, opening an existing Drive object in the app is app-state mutation plus authenticated user, and executing or changing a Drive object is underlying-data-mutation plus authenticated user. ### Settings: protocol-first Settings reads and writes should go through the local-control protocol like other actions, not bypass it via direct file manipulation. - `warpctrl setting get <key>`, `warpctrl setting set <key> <value>`, and `warpctrl setting toggle <key>` send requests to the running Warp instance through the standard authenticated control endpoint. - The app bridge validates the key against the allowlist and the value against the expected type before applying the change. -- This keeps authorization enforcement consistent: the same permission tier, execution-context, and authenticated-user policies apply to settings mutations as to any other action, rather than creating a second unguarded path through the filesystem. +- This keeps authorization enforcement consistent: the same permission category, execution-context, and authenticated-user policies apply to settings mutations as to any other action, rather than creating a second unguarded path through the filesystem. - The app owns the write to the settings file and any side effects (e.g., theme reload, layout reflow) as a single atomic operation, avoiding races between a CLI file write and the app's file watcher. - If a future need arises for offline settings manipulation (no running Warp process), a separate file-based path can be added later with its own validation, but it should not be the default. -- The action classification still applies: settings reads are tier 1 (metadata), settings writes are tier 3 (non-destructive mutation). +- The action classification still applies: settings reads are metadata reads, and settings writes are metadata/configuration mutations. Settings writes must not be authorized by app-state mutation permission alone. diff --git a/specs/warp-control-cli/SECURITY.md b/specs/warp-control-cli/SECURITY.md index cf9a3a6446..24297d8eb3 100644 --- a/specs/warp-control-cli/SECURITY.md +++ b/specs/warp-control-cli/SECURITY.md @@ -1,7 +1,7 @@ # warpctrl security architecture -`warpctrl` is a local-control CLI for an already-running Warp app instance. Its security architecture is designed to support the full control catalog: discovery, structural reads, terminal-data reads, non-destructive mutations, settings changes, input manipulation, command execution, and destructive window/tab/pane operations. -The correct architecture is not a single shared localhost bearer token with client-side conventions. The CLI, app bridge, and protocol must treat security as a local app-enforced capability system: discovery finds compatible instances, secure storage protects raw credential material, broker-issued credentials identify the granted scopes, the running Warp app's local-control bridge enforces action tiers before dispatch, and target resolution never silently retargets a request. -The action-tier model is primarily a safety and intent mechanism, not a hard security boundary against malicious same-user software. It lets a user, script, or agent intentionally request read-only or low-risk access so it does not accidentally mutate state or execute commands. It should not be described as strong access control against a process that can already run arbitrary commands as the user. +`warpctrl` is a local-control CLI for an already-running Warp app instance. Its security architecture is designed to support the full control catalog: discovery, structural metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, underlying data mutations, input-buffer staging, command execution, file operations, and Warp Drive operations. +The correct architecture is not a single shared localhost bearer token with client-side conventions. The CLI, app bridge, and protocol must treat security as a local app-enforced capability system: discovery finds compatible instances, secure storage protects raw credential material, broker-issued credentials identify the granted scopes, the running Warp app's local-control bridge enforces action categories before dispatch, and target resolution never silently retargets a request. +The action-category model is primarily a safety and intent mechanism, not a hard security boundary against malicious same-user software. It lets a user, script, or agent intentionally request metadata-only, data-read, app-state mutation, metadata/configuration mutation, or underlying-data mutation access so it does not accidentally mutate state, expose sensitive content, or execute commands. It should not be described as strong access control against a process that can already run arbitrary commands as the user. `warpctrl` has two distinct authorization dimensions: local-control authority and Warp user authority. Local-control authority proves the request is allowed to control the local app. Warp user authority proves the selected Warp app has a real logged-in Warp user and the request is allowed to act on user-authenticated data such as Warp Drive objects, AI conversation traces, synced settings, or cloud-backed user state. Logged-out users should retain a smaller local-only control surface, but authenticated-user actions require a true logged-in Warp user in the selected app. ## Security goals - Allow trusted local users and approved automation to control a running Warp instance through a stable, scriptable interface. @@ -17,7 +17,7 @@ The action-tier model is primarily a safety and intent mechanism, not a hard sec - When outside-Warp control is enabled, allow external invocations only for a smaller local-only action set by default that does not touch user-authenticated data. - Allow in-Warp invocations to receive authenticated-user grants when the selected Warp app has a true logged-in user and the user's local-control settings permit that grant. - Support least-privilege safety modes for automation and interactive use without relying on an unenforceable identity label. -- Classify every action by risk tier and enforce the required tier in the local app bridge, not in the CLI frontend. +- Classify every action by state/data category and enforce the required permission category in the local app bridge, not in the CLI frontend. - Classify every action by whether it requires an authenticated Warp user. New actions should default to requiring an authenticated user unless they are deliberately reviewed as safe for logged-out or external use. - Prevent `warpctrl` from becoming an ambient full-power confused deputy that any same-user process can invoke for high-risk actions. - Preserve deterministic targeting so a request never silently mutates or reads the wrong window, tab, pane, session, file, or Warp Drive object. @@ -69,7 +69,7 @@ This model directly acknowledges that terminal contents are sensitive and that a Lessons for `warpctrl`: - default-off or policy-controlled high-power automation is reasonable for sensitive capabilities; - random local credentials are useful, but the path that grants or unwraps them is just as important as the token itself; -- terminal-data reads and input/command execution should be treated as higher-risk than structural metadata reads; +- underlying data reads and input/command execution should be treated as higher-risk than structural metadata reads; - macOS Automation can be part of the approval path, but Warp still needs local app-side enforcement because direct protocol clients can bypass the official CLI. ### tmux tmux is a useful lower-level comparison because its clients and server communicate through local sockets. The default socket lives in a per-user directory under `/tmp`, and that directory must not be world readable, writable, or executable. tmux control mode then exposes a text protocol where clients can issue normal tmux commands and receive asynchronous pane/session notifications. Newer tmux versions also have explicit server-access controls for sharing across users. @@ -110,13 +110,14 @@ Disabled-state behavior: These enablement gates do not create perfect same-user malicious-app isolation. A hostile process with Accessibility or Screen Recording permission might still try to automate the Warp UI. The outside-Warp gate is still important because it closes the much easier paths where external apps silently edit local preferences, call a config CLI, or write synced settings to enable a powerful control surface. ### Granular permission settings Once the relevant inside-Warp or outside-Warp enablement setting allows a request context, users should control which categories of `warpctrl` authority can be granted. These permissions should appear under Settings > Scripting. Recommended independent permissions: -- **Local read-only metadata:** permit external and in-Warp clients to inspect non-sensitive local app structure such as instances, windows, tabs, panes, app version, and theme names. -- **Terminal data reads:** permit reads of terminal output, scrollback, input buffers, command history, and session traces. -- **Non-destructive local mutations:** permit reversible app-state changes such as creating tabs, focusing panes, changing theme, or opening panels. -- **Destructive and execution actions:** permit closing targets, injecting input, running commands, executing workflows, or other high-risk operations. +- **Metadata reads:** permit external and in-Warp clients to inspect non-sensitive local app structure and configuration metadata such as instances, windows, tabs, panes, app version, theme names, setting keys, action metadata, and Drive object IDs/names/types without content. +- **Underlying data reads:** permit reads of terminal output, scrollback, input buffers, command history, session traces, file contents, Warp Drive object contents, AI conversation content, and other content-bearing state. +- **App-state mutations:** permit local UI/layout/focus changes such as opening windows, creating tabs, closing tabs, focusing panes, splitting panes, opening panels, opening files/projects/views, and staging text in the input buffer without executing it. +- **Metadata/configuration mutations:** permit persistent metadata or configuration changes such as tab/pane names, tab colors, themes, font size, zoom, allowlisted settings, and keybindings. +- **Underlying data mutations:** permit terminal command execution, file create/write/append/delete, Warp Drive workflow execution, Warp Drive object CRUD, AI conversation mutations, and any other action that can change user data or cause external side effects. - **Authenticated-user actions from Warp terminals:** permit `warpctrl` invocations that originate from a verified Warp-managed terminal session to receive grants backed by the currently logged-in Warp user, enabling actions that read or mutate Warp Drive, AI conversation traces, synced settings, or other user-authenticated state. - **Authenticated-user actions from external clients:** default off. If supported, this must be a separate explicit permission from in-Warp authenticated actions because external same-user processes are a weaker context than a Warp-managed terminal session. -Granular permissions should be independently configurable for inside-Warp and outside-Warp contexts where the distinction matters. Disabling any category should invalidate active credentials that include that category. The broker and app bridge must enforce these settings locally for every credential request and every presented credential. +Granular permissions should be independently configurable for inside-Warp and outside-Warp contexts where the distinction matters. Disabling any category should invalidate active credentials that include that category. The broker and app bridge must enforce these settings locally for every credential request and every presented credential. App-state mutation permission must not imply metadata/configuration mutation or underlying data mutation permission. ## Trust boundaries `warpctrl` has several distinct trust boundaries. ### Operating-system user boundary @@ -127,16 +128,16 @@ These scoped credentials are guardrails for well-behaved clients. They prevent a ### Warp-terminal execution context boundary `warpctrl` should be able to receive special grants when it is invoked from within a Warp-managed terminal session, but the bridge must not trust a caller-supplied string such as `caller_class=warp_terminal`. The app should issue a session-bound execution-context proof to Warp-managed terminal sessions and have the broker verify that proof before minting in-Warp-only grants. Acceptable designs include a short-lived per-session capability, an app-owned broker handshake tied to the terminal session, or an equivalent proof that arbitrary external processes cannot mint by setting an environment variable. Plain environment variables may be used as handles or hints, but they must not be the sole authority for in-Warp privileges because external processes can spoof them. -Verified in-Warp context can raise the maximum eligible grant set, especially for authenticated-user actions. It does not by itself bypass the user's granular local-control settings, action risk tiers, target scopes, or logged-in-user requirements. +Verified in-Warp context can raise the maximum eligible grant set, especially for authenticated-user actions. It does not by itself bypass the user's granular local-control settings, action categories, target scopes, or logged-in-user requirements. ### Warp user authentication boundary Actions that touch user-authenticated Warp data require a true logged-in Warp user in the selected app. This includes Warp Drive object contents or mutation, AI conversation traces, cloud-backed user settings, team/account data, and any other surface whose normal app access depends on the user's Warp account. The app bridge should execute these actions on behalf of the logged-in app user through existing app auth state. `warpctrl` should receive a local-control credential that carries an `authenticated_user` grant, the verified user identity or stable subject reference, and the allowed authenticated action families. It should not need to export raw Firebase, server, or cloud API tokens to shell scripts. If the selected app has no logged-in user, authenticated-user actions must fail with a structured error rather than falling back to logged-out behavior. Logged-out users may still use the smaller local-only action set explicitly marked as not requiring an authenticated user. ### Application identity boundary On platforms with secure credential storage, especially macOS, the raw local-control credential should be readable only by Warp-owned, correctly signed code. On macOS this means storing raw credential material in Keychain with access constrained by Warp's signing identity, designated requirement, Keychain access group, or equivalent platform mechanism. This narrows token extraction from “any same-user process can read a file” to “only trusted Warp-signed code can unwrap the secret.” -This boundary protects the credential from direct theft and prevents arbitrary apps from making authenticated raw HTTP requests to the local-control listener. It also lets the authoritative enablement state be stored somewhere harder to modify than ordinary user preferences. It does not prove that the user personally intended the specific action. Any same-user process may still be able to invoke the trusted `warpctrl` binary or automate the Warp UI. That confused-deputy risk is reduced by explicit in-app enablement, scoped credential issuance, action-tier policy, and local app-side bridge enforcement, but it is not eliminated as a hard same-user security boundary. +This boundary protects the credential from direct theft and prevents arbitrary apps from making authenticated raw HTTP requests to the local-control listener. It also lets the authoritative enablement state be stored somewhere harder to modify than ordinary user preferences. It does not prove that the user personally intended the specific action. Any same-user process may still be able to invoke the trusted `warpctrl` binary or automate the Warp UI. That confused-deputy risk is reduced by explicit in-app enablement, scoped credential issuance, action-category policy, and local app-side bridge enforcement, but it is not eliminated as a hard same-user security boundary. ### Action boundary -Every action belongs to a risk tier. The bridge must map the requested action to a required tier and compare that tier to the presented credential before selector resolution or handler dispatch. +Every action belongs to a state/data category. The bridge must map the requested action to a required permission category and compare that category to the presented credential before selector resolution or handler dispatch. ### Target boundary A valid credential for one instance or target must not imply authority over another. Credentials should be bound to the issuing Warp instance and may be further scoped to target families such as terminal sessions, files, or Warp Drive objects when those surfaces are exposed. ## Threat model @@ -151,7 +152,7 @@ A valid credential for one instance or target must not imply authority over anot - Stale discovery records from exited Warp processes. - Multiple running Warp instances where ambiguous selection could target the wrong process. - Malformed clients attempting unknown, unsupported, unallowlisted, or invalid action payloads. -- Valid clients attempting actions above their granted tier. +- Valid clients attempting actions above their granted permission category. - Explicit target IDs that become stale between discovery and execution. - Future handlers that expose terminal data, settings writes, input mutation, command execution, file intents, or Warp Drive object operations. ### Out of scope @@ -166,7 +167,7 @@ The security model has eight layers: 4. **Execution context verification:** Distinguish verified Warp-terminal invocations from external same-user invocations without trusting caller-declared labels. 5. **Credential issuance:** Issue scope-specific credentials with explicit grants and lifetimes only when the request's invocation context is enabled and the user's granular permissions allow the requested category. 6. **Transport authentication:** Reject disabled or unauthenticated requests before reading or mutating app state. -7. **Safety and user-auth policy:** Enforce action tiers, target scopes, execution-context requirements, and authenticated-user requirements locally in the app bridge. +7. **Safety and user-auth policy:** Enforce permission categories, target scopes, execution-context requirements, and authenticated-user requirements locally in the app bridge. 8. **Deterministic dispatch:** Resolve targets exactly and invoke only allowlisted typed handlers. ```mermaid sequenceDiagram @@ -202,7 +203,7 @@ sequenceDiagram Broker-->>CLI: Scoped credential with grants, context, user scope, expiry CLI->>HTTP: Authenticated typed request HTTP->>Bridge: Verify credential and protocol envelope - Bridge->>Bridge: Check tier + context + authenticated-user + target scope + Bridge->>Bridge: Check permission category + context + authenticated-user + target scope alt Denied Bridge-->>CLI: structured safety-policy error else Allowed @@ -230,12 +231,12 @@ Discovery rules: - If multiple compatible instances are ambiguous, the CLI must require explicit `--instance` selection. - Discovery metadata must not expose terminal contents, environment variables, auth tokens for cloud services, raw local-control credentials, or mutating capability grants. ## Credential model -The full `warpctrl` catalog requires scoped credentials. A single shared full-power bearer token is not sufficient once automation, terminal data, command execution, and destructive actions are supported. +The full `warpctrl` catalog requires scoped credentials. A single shared full-power bearer token is not sufficient once automation, underlying data reads, app-state mutations, metadata/configuration mutations, and underlying data mutations are supported. ### Credential properties A control credential should encode or reference: - issuing Warp instance; - protocol version or accepted version range; -- granted action tiers; +- granted permission categories; - verified execution context, such as external client or Warp-managed terminal session; - whether the credential may act on behalf of an authenticated Warp user; - authenticated Warp user subject or stable user reference when an authenticated-user grant is present; @@ -246,26 +247,29 @@ A control credential should encode or reference: - unique credential ID for revocation and auditing; - integrity protection so callers cannot forge or widen grants. ### Credential issuance -Warp should issue credentials through an app-owned local broker or equivalent trusted path. The broker decides which grants to issue based on the requested action tier, target scope, user configuration, execution context, and any explicit user approval. +Warp should issue credentials through an app-owned local broker or equivalent trusted path. The broker decides which grants to issue based on the requested permission category, target scope, user configuration, execution context, and any explicit user approval. Recommended defaults: - Credential issuance is unavailable unless the protected enablement state allows the request's invocation context: inside Warp or outside Warp. - Commands should start from least privilege and request only the grant needed for the requested action. - External same-user invocations should default to the smaller logged-out-safe local action set unless policy or explicit approval grants more. - Verified Warp-terminal invocations may receive broader local-control grants when the user's granular settings allow them. - Authenticated-user grants are available only when the selected Warp app has a true logged-in Warp user and the requested execution context is allowed by local-control settings. -- Terminal data reads require an explicit `read_terminal_data` grant. -- Non-destructive mutations require an explicit `mutate_non_destructive` grant. -- Destructive operations, input injection, and command execution require explicit high-risk grants. -- User-authenticated data reads or mutations require an explicit `authenticated_user` grant and an allowed authenticated action family. +- Metadata reads require an explicit `read_metadata` grant. +- Underlying data reads require an explicit `read_underlying_data` grant. +- App-state mutations require an explicit `mutate_app_state` grant. +- Metadata/configuration mutations require an explicit `mutate_metadata` or `mutate_configuration` grant. +- Underlying data mutations require an explicit `mutate_underlying_data` grant and should require approval or policy for unattended automation. +- User-authenticated data reads or mutations require an explicit `authenticated_user` grant and an allowed authenticated action family in addition to the data-category grant. - Integrations should receive the narrowest grant needed for the configured workflow. -The broker must not issue broad authority merely because the request came from the signed `warpctrl` binary. It should evaluate the requested action tier, target scope, configured policy, execution context, and whether user approval is required. The CLI must not mint its own authority. It can request, load, and present credentials, but the app bridge remains the enforcement point for these safety grants. +The broker must not issue broad authority merely because the request came from the signed `warpctrl` binary. It should evaluate the requested permission category, target scope, configured policy, execution context, and whether user approval is required. The CLI must not mint its own authority. It can request, load, and present credentials, but the app bridge remains the enforcement point for these safety grants. ### Safety grants, not strong access control -The tier system should be understood as a user-intent and accident-prevention mechanism: -- A user can ask an agent or script to operate with read-only metadata grants so it can inspect structure but cannot accidentally mutate state. -- A workflow can request terminal-data reads separately from structural metadata reads because terminal contents are more sensitive. -- A script can request non-destructive mutation without also receiving command-execution capability. -- Destructive actions and command execution can require an explicit approval or configured policy so surprising operations pause before they happen. -This model does not make untrusted same-user software safe. A malicious local process may invoke `warpctrl`, simulate user workflows, or use other OS-level capabilities outside `warpctrl`. The tier model is still valuable because it lets honest clients, agents, and scripts constrain themselves and gives Warp a structured point to prompt, deny, or audit risky actions. +The category system should be understood as a user-intent and accident-prevention mechanism: +- A user can ask an agent or script to operate with metadata-read grants so it can inspect structure but cannot read terminal content or mutate state. +- A workflow can request underlying-data reads separately from structural metadata reads because terminal output, files, Drive object content, and AI conversations can contain sensitive data. +- A script can request app-state mutation without also receiving permission to change persistent settings, execute commands, write files, or mutate Warp Drive objects. +- Metadata/configuration mutations can be allowed without granting underlying data mutation. +- Underlying data mutations can require explicit approval or configured policy so surprising operations pause before they execute commands or change user data. +This model does not make untrusted same-user software safe. A malicious local process may invoke `warpctrl`, simulate user workflows, or use other OS-level capabilities outside `warpctrl`. The category model is still valuable because it lets honest clients, agents, and scripts constrain themselves and gives Warp a structured point to prompt, deny, or audit risky actions. ### Credential storage Credential storage should be platform-appropriate: - Local discovery may store a credential reference rather than the credential itself. @@ -281,9 +285,9 @@ For example, if `warpctrl` can silently unwrap a full-power credential and execu Mitigations: - Do not give `warpctrl` ambient non-interactive access to an unrestricted full-control credential. - Prefer action-scoped or session-scoped credentials minted just in time by the broker. -- Require explicit user approval or preconfigured policy for Tier 4 actions and other sensitive grants. +- Require explicit user approval or preconfigured policy for underlying data mutations and other sensitive grants. - Distinguish user-approved credential requests from ambient unattended invocations through explicit approval prompts, configured policy, terminal/session context, or narrow credential request flows. -- Bind issued credentials to the requested instance, action tier, optional action family, optional target scope, and short expiry. +- Bind issued credentials to the requested instance, permission category, optional action family, optional target scope, and short expiry. - Let `warpctrl` preflight and request credentials, but require the local app bridge to enforce scopes because direct protocol clients can bypass the CLI. - Make denials structured and non-fatal for automation so callers can request narrower or user-approved grants rather than falling back to unsafe behavior. These mitigations are about routing high-risk operations through intentional `warpctrl` flows rather than exposing a reusable localhost token to any process. They should not be documented as a guarantee that arbitrary same-user applications cannot cause Warp-visible actions. @@ -305,7 +309,7 @@ Default rule for new actions: - New actions require an authenticated Warp user unless the implementer deliberately classifies them as logged-out-safe. - The logged-out-safe set should remain meaningfully smaller and limited to local app structure, local appearance metadata, and other surfaces that do not depend on the user's cloud-backed Warp identity. - Actions that read or mutate Warp Drive, AI conversation traces, synced settings, team/account data, or other user-authenticated state must require an authenticated user. -- Actions that can execute user-authored cloud-backed content, such as running Warp Drive workflows or inserting notebook commands, require both the authenticated-user grant and the appropriate high-risk action tier. +- Actions that can execute user-authored cloud-backed content, such as running Warp Drive workflows or inserting notebook commands, require both the authenticated-user grant and the appropriate high-risk action category. When an authenticated-user action is requested: - the selected app must have an active logged-in Warp user; - the presented local-control credential must include an `authenticated_user` grant for that user or stable subject; @@ -318,50 +322,54 @@ The bridge must: 1. Parse the typed request envelope. 2. Verify protocol version compatibility. 3. Authenticate the credential. -4. Determine granted action tiers, execution context, target scopes, and authenticated-user grants. -5. Map the requested action to a required tier, action family, execution-context requirement, and authenticated-user requirement. +4. Determine granted permission categories, execution context, target scopes, and authenticated-user grants. +5. Map the requested action to a required permission category, action family, execution-context requirement, and authenticated-user requirement. 6. Check optional target-family restrictions. 7. Reject requests that exceed the credential's grants with `insufficient_permissions`. 8. Reject authenticated-user actions without a logged-in user or authenticated-user grant with a structured authenticated-user error. 9. Only then resolve selectors and invoke the allowlisted handler. The CLI frontend may provide helpful preflight errors, but those checks are advisory. Local app-side bridge enforcement is mandatory because other tools can bypass the official CLI and speak the protocol directly. -## Action risk tiers -Every action belongs to exactly one tier. These tiers describe risk and intended safety prompts; they are not a sandbox or a complete OS-level access-control model. -### Tier 1: read-only metadata -Returns app structure or configuration without terminal contents or user data from sessions. +## Action permission categories +Every action belongs to exactly one state/data category for permission enforcement. These categories describe risk and intended safety prompts; they are not a sandbox or a complete OS-level access-control model. +### Metadata reads +Return app structure, app state, or configuration metadata without exposing terminal content, file content, Warp Drive object content, AI conversation content, or other user data. Examples: - `instance list`, `app active`, `app version`, `app ping`; - `window list`, `tab list`, `pane list`, `session list`; -- `theme list`; -- allowlisted settings reads that expose configuration but not terminal contents. -Default unattended credentials may include this tier. -### Tier 2: read-only terminal data -Returns potentially sensitive terminal/session data without mutating state. +- `theme list`, `setting list`, `keybinding list`, and action/capability metadata; +- Drive object listing that returns object IDs, names, and types but not content. +Default unattended credentials may include this category. +### Underlying data reads +Return user content or data-bearing state without mutating state. Examples: -- pane output or scrollback reads; -- current input buffer reads; -- command history reads; -- session replay or transcript reads. -This tier is separate from metadata because terminal content often contains secrets, file paths, command output, customer data, and other sensitive information. -### Tier 3: mutating non-destructive -Changes visible app state in reversible or low-risk ways without executing terminal content or destroying user state. +- pane output, scrollback, current input buffer, command history, session replay, or transcript reads; +- file content reads; +- Warp Drive object content reads; +- AI conversation content reads. +This category is separate from metadata because content often contains secrets, source code, file paths, command output, customer data, and other sensitive information. +### App-state mutations +Change visible local Warp UI state without directly changing underlying user data. Examples: -- creating or activating tabs; -- moving, renaming, or coloring tabs; -- creating or focusing windows; -- splitting, focusing, navigating, maximizing, or resizing panes; -- theme, font, zoom, and allowlisted non-destructive settings changes; -- opening panels, palettes, and user-facing surfaces. -### Tier 4: mutating destructive or high-risk -Can destroy active work, inject terminal input, execute commands, or run user-authored content. +- creating, focusing, activating, moving, or closing windows, tabs, panes, or sessions; +- splitting, navigating, maximizing, or resizing panes; +- opening panels, palettes, files, projects, notebooks, and other user-facing surfaces; +- inserting, replacing, or clearing staged input buffer text without submitting or executing it. +### Metadata/configuration mutations +Change persistent metadata or configuration without directly mutating primary user content. +Examples: +- renaming tabs or panes; +- changing tab colors; +- theme, font, zoom, keybinding, and allowlisted settings writes. +This category should not authorize terminal command execution, file writes, or Warp Drive CRUD. +### Underlying data mutations +Can change user data, execute code, or cause external side effects. Examples: -- closing windows, tabs, panes, or sessions; -- clearing, replacing, or inserting terminal input; - command execution in a session; -- switching input modes when it can change execution behavior; -- executing Warp Drive workflows or notebooks in a terminal session; -- broad Warp Drive object mutation. -This tier should require explicit user or policy approval for unattended automation and integrations. +- executing Warp Drive workflows or other user-authored runnable content; +- file create/write/append/delete operations; +- Warp Drive object create/update/trash/restore/permanent-delete operations; +- AI conversation history mutation or other cloud-backed content mutation. +This category should require explicit user or policy approval for unattended automation and integrations. It must remain separate from app-state mutation so a client that can open or focus Warp UI cannot automatically execute commands, write files, or mutate Warp Drive content. ## Target scoping and deterministic resolution Targeting is part of security. The protocol must not convert ambiguous or stale selectors into best-effort mutations. Rules: @@ -380,14 +388,14 @@ Each supported command requires: - a typed protocol action; - typed parameters; - validation rules; -- a documented risk tier; +- a documented state/data category and permission category; - a documented `requires_authenticated_user` value; - a documented allowed execution context, including whether external clients can run it or whether it is limited to verified Warp-terminal invocations; - local app-side safety-grant checks; - deterministic target resolution; - a handler that reuses existing user-visible app behavior where possible; - typed success and error responses. -Adding a new action should be additive and reviewable: extend the protocol enum, implement validation, map the action to a risk tier, declare whether it requires an authenticated user, declare its allowed execution contexts, add a handler, and add tests for authentication, safety-policy denial, authenticated-user denial, selector failure, and success behavior. +Adding a new action should be additive and reviewable: extend the protocol enum, implement validation, map the action to a state/data category, declare whether it requires an authenticated user, declare its allowed execution contexts, add a handler, and add tests for authentication, safety-policy denial, authenticated-user denial, selector failure, and success behavior. ## Browser and localhost protections Loopback is not sufficient by itself because browsers can send requests to localhost. Required protections: @@ -404,7 +412,7 @@ Recommended audit fields: - timestamp; - instance ID; - credential ID or grant profile; -- action name and risk tier; +- action name, state/data category, and permission category; - target type and opaque target ID when safe; - success or structured error code. Avoid logging: @@ -420,7 +428,7 @@ Structured errors are part of the security contract. Important errors include: - `local_control_disabled` when the relevant inside-Warp or outside-Warp scripting context is disabled in Settings > Scripting or has been disabled after credentials were issued; - `unauthorized_local_client` for missing, malformed, expired, revoked, or invalid credentials; -- `insufficient_permissions` for valid credentials that lack the requested safety tier or target scope; +- `insufficient_permissions` for valid credentials that lack the requested permission category or target scope; - `authenticated_user_required` when an action requires a logged-in Warp user but the credential lacks an authenticated-user grant; - `authenticated_user_unavailable` when the selected Warp app has no logged-in Warp user or cannot access the required authenticated user state; - `execution_context_not_allowed` when the action or requested grant is not allowed from the verified invocation context, such as an external client attempting an in-Warp-only authenticated-user action; @@ -437,10 +445,10 @@ The app must not downgrade these failures into broader default actions, and the Before shipping each action family, verify that these controls are implemented for that family: - Local control scripting must be enabled for the request's invocation context before the action family can run; inside-Warp control defaults on and outside-Warp control defaults off. - The authoritative enablement states live under Settings > Scripting, are protected from external writes, and are local-only rather than synced. -- The action has a documented tier. +- The action has a documented state/data category and required permission category. - The action has a documented `requires_authenticated_user` value. New actions default to `true` unless explicitly reviewed as logged-out-safe. - The action documents allowed execution contexts and whether external clients may run it. -- The bridge maps the action to that tier locally in the selected Warp app process. +- The bridge maps the action to that permission category locally in the selected Warp app process. - The credential model can express the required grant. - The credential model can express authenticated-user grants and verified execution context requirements when needed. - The handler checks optional target restrictions where relevant. diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index 60d9acbe1a..d9703d25f5 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -1,6 +1,6 @@ # Context `PRODUCT.md` defines a standalone local Warp control CLI binary, provisionally named `warpctrl`, with an allowlisted action catalog, deterministic addressing across multiple running Warp app processes, and an incremental implementation plan. -`SECURITY.md` is the normative security architecture for this feature. Implementation work must follow it for separate inside-Warp and outside-Warp enablement, the top-level Settings > Scripting surface, protected enablement storage, granular permissions, discovery metadata, credential storage, scoped safety grants, verified execution context, authenticated-user requirements, localhost/browser protections, action-tier enforcement, deterministic target resolution, and local app-side validation. If this technical plan and `SECURITY.md` disagree, update the plan before implementing rather than treating the security architecture as optional follow-up work. +`SECURITY.md` is the normative security architecture for this feature. Implementation work must follow it for separate inside-Warp and outside-Warp enablement, the top-level Settings > Scripting surface, protected enablement storage, granular permissions, discovery metadata, credential storage, scoped safety grants, verified execution context, authenticated-user requirements, localhost/browser protections, permission-category enforcement, deterministic target resolution, and local app-side validation. If this technical plan and `SECURITY.md` disagree, update the plan before implementing rather than treating the security architecture as optional follow-up work. The existing app already has three relevant building blocks: - `crates/http_server/src/lib.rs (7-61)` runs a native-only loopback Axum server on fixed port `9277`. - `app/src/lib.rs (1993-2001)` registers that HTTP server in the native app and currently merges only installation-detection and profiling routers. @@ -37,18 +37,18 @@ Required security gates: - External invocations default to a smaller logged-out-safe action set that does not touch user-authenticated data. - Verified Warp-terminal invocations may receive authenticated-user grants only when the selected app has a true logged-in Warp user and local-control settings allow authenticated-user actions from Warp terminals. - The app rejects disabled, unauthenticated, expired, revoked, insufficient-scope, unsupported, malformed, ambiguous, missing-target, and stale-target requests with structured errors. -- Every action has a documented risk tier and the app bridge enforces the required tier locally before selector resolution or handler dispatch. +- Every action has a documented state/data category and the app bridge enforces the required permission category locally before selector resolution or handler dispatch. - Every action has a documented `requires_authenticated_user` value and allowed execution contexts. New actions default to requiring an authenticated user unless explicitly reviewed as logged-out-safe. -- Granular local-control settings under Settings > Scripting gate the maximum grants for metadata reads, terminal-data reads, non-destructive mutations, destructive/execution actions, authenticated-user actions from Warp terminals, and authenticated-user actions from external clients. -- Safety tiers are treated as user-intent and accident-prevention guardrails, not as strong same-user malicious-app isolation. +- Granular local-control settings under Settings > Scripting gate the maximum grants for metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, underlying data mutations, authenticated-user actions from Warp terminals, and authenticated-user actions from external clients. +- Permission categories are treated as user-intent and accident-prevention guardrails, not as strong same-user malicious-app isolation. - Remote control remains out of scope for the local same-machine credential model. -The first implementation slice should include the protected enablement gate, credential issuance checks, and app-side tier enforcement even if the only mutating action initially implemented is `tab.create`. Shipping `tab.create` without the enablement and validation architecture would create the wrong foundation for the full catalog. +The first implementation slice should include the protected enablement gate, credential issuance checks, and app-side permission-category enforcement even if the only mutating action initially implemented is `tab.create`. Shipping `tab.create` without the enablement and validation architecture would create the wrong foundation for the full catalog. ### 1. Protocol crate and stable envelope Create a small shared protocol crate or equivalent shared module used by both the app server and standalone CLI client. It should define: - Protocol version metadata. - Discovery/health response types. - Execution-context proof/request types for verified Warp-terminal invocations versus external invocations. -- Action metadata describing risk tier, required grant, `requires_authenticated_user`, allowed execution contexts, and target families. +- Action metadata describing state/data category, required permission grant, `requires_authenticated_user`, allowed execution contexts, and target families. - Selector types: - `InstanceSelector` - `WindowSelector` @@ -114,11 +114,11 @@ Recommended local trust model: - The broker verifies whether the invocation originated from a Warp-managed terminal session before issuing in-Warp-only grants. - The broker issues authenticated-user grants only when the selected app has a true logged-in Warp user and the relevant local-control permission is enabled. - The app rejects disabled-state, missing, malformed, invalid, expired, or revoked credentials before selector resolution or mutation. -- The app maps every action to a risk tier and rejects insufficient grants before selector resolution or mutation. +- The app maps every action to a state/data category and rejects insufficient grants before selector resolution or mutation. - The app maps every action to a `requires_authenticated_user` value and allowed execution contexts, rejecting mismatches before selector resolution or mutation. - Health metadata exposed without credentials, if needed for stale-record pruning, must not reveal mutating capabilities, credentials, or sensitive target state. This keeps the protocol local and scriptable without creating an ambient browser-to-localhost control surface. -Do not ship the first slice as a plaintext discovery bearer token, even for same-user human CLI use. The first slice is the foundation for higher-risk terminal data, input injection, command execution, and destructive operations, so it must establish the protected enablement, credential storage, scoped grant, and app-side enforcement model from `SECURITY.md`. +Do not ship the first slice as a plaintext discovery bearer token, even for same-user human CLI use. The first slice is the foundation for underlying data reads, app-state mutations, metadata/configuration mutations, and underlying data mutations, so it must establish the protected enablement, credential storage, scoped grant, and app-side enforcement model from `SECURITY.md`. ### 4. App-side request bridge onto the UI/application context The HTTP handler runs on a Tokio runtime thread owned by the local-control server. It cannot directly access or mutate Warp's UI models, views, or app context because all WarpUI state is single-threaded and owned by the main app event loop. The bridge solves this by sending a closure from the Tokio handler thread to the main thread, executing it in the model's context, and returning the result to the waiting HTTP handler. #### Thread model @@ -151,9 +151,9 @@ HTTP handler (Tokio thread) LocalControlBridge::handle_request (main thread) │ ├─ verify protected context-specific enablement state is still enabled - ├─ map action to required risk tier + ├─ map action to required permission category ├─ map action to authenticated-user and execution-context requirements - ├─ verify presented credential grants that tier, target family, execution context, and authenticated-user access + ├─ verify presented credential grants that category, target family, execution context, and authenticated-user access ├─ match request.action.kind │ └─ ActionKind::TabCreate │ ├─ validate_tab_create_target(&request.target) @@ -178,9 +178,9 @@ LocalControlBridge::handle_request (main thread) #### Adding new action handlers To add a new action to the bridge: 1. Add a variant to `ActionKind` in `crates/local_control/src/protocol.rs`. -2. Document its `SECURITY.md` risk tier, required grant, `requires_authenticated_user` value, and allowed execution contexts. +2. Document its `SECURITY.md` state/data category, required permission grant, `requires_authenticated_user` value, and allowed execution contexts. 3. Add a match arm in `LocalControlBridge::handle_request` in `app/src/local_control/mod.rs`. -4. Before selector resolution or dispatch, verify local control is enabled and the presented credential grants the action tier, target family, execution context, and authenticated-user access if required. +4. Before selector resolution or dispatch, verify local control is enabled and the presented credential grants the action category, target family, execution context, and authenticated-user access if required. 5. Inside the match arm, use `ctx` (which is a `&mut ModelContext<LocalControlBridge>` that derefs to `&mut AppContext`) to resolve selectors and dispatch the action onto existing app types. 6. Return a `ResponseEnvelope::ok(...)` or `ResponseEnvelope::error(...)` with the result. The bridge closure has access to the full `AppContext` API surface, including `ctx.windows()`, `ctx.window_ids()`, `ctx.views_of_type::<T>(window_id)`, `handle.update(ctx, ...)`, and `handle.read(ctx, ...)`. This makes it straightforward to wire new actions to existing UI behavior without introducing new concurrency concerns. @@ -221,16 +221,17 @@ Do not use a generic “dispatch action by string” endpoint. Every handler sho ### 7. First slice: prove discovery and `tab.create` The first `warpctrl` implementation slice should land the minimum cross-cutting architecture plus a single representative tab mutation: - Shared protocol types and error envelopes. -- New top-level Settings > Scripting page with separate protected inside-Warp and outside-Warp enablement states. +- `FeatureFlag::WarpControlCli` and Cargo feature `warp_control_cli`, with app-side runtime gating for settings, discovery, bridge registration, and local-control endpoints. +- New top-level Settings > Scripting page with separate protected inside-Warp and outside-Warp enablement states, rendered only while `FeatureFlag::WarpControlCli` is enabled. - Protected local-only enablement storage where inside-Warp control defaults on and outside-Warp control defaults off. -- Granular local-control permission storage under Settings > Scripting for at least metadata, non-destructive local mutations, and authenticated-user-action categories. +- Granular local-control permission storage under Settings > Scripting for metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, underlying data mutations, and authenticated-user-action categories. - Discovery registry and CLI instance selection. - A standalone `warpctrl` binary or artifact path that runs control commands without starting the GUI app runtime. - Per-process authenticated local-control server that refuses sensitive work when the request's inside-Warp or outside-Warp context is disabled. - Scoped credential issuance/storage with no raw credentials in plaintext discovery records, including execution-context fields and authenticated-user grant fields. - App-side request bridge and selector resolver. -- Action-tier mapping and app-side safety-grant enforcement. -- Action metadata for `tab.create` that deliberately classifies it as a logged-out-safe non-destructive local mutation only when the user's granular local-control settings allow that category. +- Action-category mapping and app-side safety-grant enforcement. +- Action metadata for `tab.create` that deliberately classifies it as a logged-out-safe app-state mutation only when the user's granular local-control settings allow app-state mutation. - Read-only `ping/version` plus `warpctrl instance list` or equivalent minimal discovery command. - End-to-end `warpctrl tab create` for the selected instance, reusing the same app behavior as the user-visible new-terminal-tab action. Why `tab.create` first: @@ -271,6 +272,79 @@ Startup and dependency expectations: Naming decision: - Product examples use provisional `warpctrl ...` command lines for the standalone local-control binary. - Final artifact filenames, channelized aliases, and installer exposure should be chosen before broad rollout to avoid churn in bundle scripts, docs, shell completions, and release workflow files. +## Implementation Plan +### Branch stack +Use raw git for the stack; do not use Graphite for these branches. +The intended stack is: +1. `zach/warp-cli-specs` — spec-only branch. This branch owns `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs. It should not contain implementation changes. +2. `zach/warp-cli` — first implementation branch. This stays as the core scaffolding slice: protocol crate, discovery/auth scaffolding, Scripting settings surface, local-control server/bridge, standalone `warpctrl` binary, packaging hooks, and only the single `warpctrl tab create` mutation needed to prove the end-to-end path. +3. `zach/warp-cli-readonly` — create this branch directly from `zach/warp-cli`. It implements the read-only command set from `PRODUCT.md` without adding additional mutations beyond the existing `tab create` proof command. +4. `zach/warp-cli-read-write` — create this branch directly from `zach/warp-cli-readonly`. It implements the mutating command set from `PRODUCT.md` after the read-only branch has established selectors, metadata result shapes, and inspection APIs. +Recommended raw-git setup: +```bash +git fetch origin +git checkout zach/warp-cli +git checkout -b zach/warp-cli-readonly +git push -u origin zach/warp-cli-readonly +git checkout -b zach/warp-cli-read-write +git push -u origin zach/warp-cli-read-write +``` +If `zach/warp-cli-readonly` changes after `zach/warp-cli-read-write` exists, rebase the read-write branch onto the updated read-only branch with raw git (`git checkout zach/warp-cli-read-write && git rebase zach/warp-cli-readonly`) and resolve conflicts by preserving both read-only API shape and mutating handlers. +### Feature flag and rollout gate +The entire feature should be gated behind a Warp feature flag, proposed as `FeatureFlag::WarpControlCli` with Cargo feature `warp_control_cli`. +Implementation should follow the existing runtime feature-flag conventions: +- Add `warp_control_cli = []` under `[features]` in `app/Cargo.toml`, not under the default feature set until launch. +- Add `WarpControlCli` to the `FeatureFlag` enum in `crates/warp_features/src/lib.rs`. +- Add the `#[cfg(feature = "warp_control_cli")] FeatureFlag::WarpControlCli` entry in `app/src/features.rs` so the compile-time feature initializes the runtime flag. +- Enable the flag for dogfood or preview by adding it to `DOGFOOD_FLAGS` or `PREVIEW_FLAGS` only when the rollout plan calls for that exposure. +- Prefer runtime checks with `FeatureFlag::WarpControlCli.is_enabled()` over broad `#[cfg]` gates except where code cannot compile without the Cargo feature. +When `FeatureFlag::WarpControlCli` is disabled in the Warp app: +- the Scripting settings page should not expose Warp control settings; +- `LocalControlSettings` should not register user-visible controls for Warp control; +- the app should not create `LocalControlBridge` or `LocalControlServer`; +- no local-control discovery record should be written; +- no `/v1/control` or `/v1/control/credentials` local server endpoints should be exposed; +- command-palette/keybinding entries related specifically to installing, configuring, or using `warpctrl` should be hidden; +- tests should cover both enabled and disabled flag states with the repo's normal feature-flag override helpers. +The standalone `warpctrl` binary can still exist in a build where the app feature is disabled, but it should find no compatible enabled app instance and should return a structured no-instance or feature-disabled error rather than relying on hidden server state. +### Read-only branch sharding with Oz cloud agents +Use Oz cloud agents to shard `zach/warp-cli-readonly` by API family. Each agent should work from `zach/warp-cli-readonly` or a short-lived shard branch based on it, push a branch or return a patch, and report changed files plus validation results. A lead integrator merges/cherry-picks accepted shard work into `zach/warp-cli-readonly` using raw git. +Suggested shards: +- `readonly-protocol-cli` owns `crates/local_control` action variants, typed read-only params/results, CLI parser/output for read-only commands, and serde tests. +- `readonly-app-targets` owns app-side handlers and target resolvers for `app`, `window`, `tab`, `pane`, and `session` structural metadata. +- `readonly-underlying-data` owns read-only underlying-data commands such as block listing/output, input-buffer reads, history reads, file content reads if added, and Drive object content reads, with extra tests that content is denied without underlying-data-read permission. +- `readonly-settings-appearance` owns theme, appearance, settings, keybinding, and action/capability inspection commands. +- `readonly-files-drive-skill-docs` owns app-state file/project reads, authenticated Warp Drive read-only commands, operator docs, and the first version of the built-in `warpctrl` Agent skill. +Read-only branch acceptance criteria: +- all read-only commands in `PRODUCT.md` parse and serialize stable request envelopes; +- structural metadata reads return opaque protocol IDs and do not expose terminal, file, Drive object, or AI conversation content; +- underlying-data reads require the separate underlying-data-read grant; +- authenticated Warp Drive metadata reads require a logged-in user and authenticated-user grant, and object-content reads additionally require the underlying-data-read grant; +- disabled feature flag state exposes no settings, discovery records, or endpoints; +- `cargo nextest run --no-fail-fast --workspace <relevant tests>` and targeted `cargo check` pass for the changed crates. +### Read-write branch sharding with Oz cloud agents +Start `zach/warp-cli-read-write` only after the read-only branch has a coherent target-resolution and result-shape baseline. Each mutating shard should add action metadata, typed params/results, CLI parser surface, app bridge handlers, permission checks, and tests for allowed and denied paths. +Suggested shards: +- `mutate-window-tab-pane` owns window creation/focus/close, tab create/activate/move/rename/color/close, and pane split/focus/navigate/resize/maximize/rename/close. +- `mutate-input-session` owns session activation/cycling/reopen, input insert/replace/clear, and terminal-vs-agent input mode changes as app-state mutations. It may define `input run`, but execution must be classified as an underlying data mutation and can be handed to `mutate-underlying-data` if that keeps review cleaner. +- `mutate-metadata-config` owns theme selection, system theme toggles, font/zoom controls, tab/pane metadata updates, and allowlisted setting set/toggle commands as metadata/configuration mutations. +- `mutate-surfaces-files-drive` owns settings/palette/search/panel surface commands and file/project/Drive open commands as app-state mutations. +- `mutate-underlying-data` owns terminal command execution, file create/write/append/delete commands, Warp Drive workflow execution, and Warp Drive object CRUD. This shard must require the underlying-data-mutation permission and authenticated-user grants where applicable. +- `mutate-tests-docs-skill` owns cross-family integration tests, command help/shell completion checks, README updates, and updates to the built-in `warpctrl` Agent skill once the mutating command surface stabilizes. +Read-write branch acceptance criteria: +- every mutating command in `PRODUCT.md` has an explicit state/data category, required permission category, and authenticated-user classification; +- app-state mutations, metadata/configuration mutations, and underlying-data mutations require distinct permissions; +- command execution, file writes, Warp Drive CRUD, and other underlying-data mutations require the underlying-data-mutation permission, not merely read-write/app-state permission; +- selector resolution happens after auth and permission checks and never silently retargets stale explicit selectors; +- all mutating handlers reuse existing user-visible app behavior rather than duplicating business logic; +- disabled feature flag state continues to hide settings and withhold endpoints; +- the branch can be reviewed as a stacked PR whose base is `zach/warp-cli-readonly`. +### Merge and review strategy +Keep PR boundaries aligned with the stack: +- PR1: `zach/warp-cli` into `master` for core scaffolding plus `tab create` only. +- PR2: `zach/warp-cli-readonly` into `zach/warp-cli` or the merged successor of PR1 for read-only command expansion. +- PR3: `zach/warp-cli-read-write` into `zach/warp-cli-readonly` or the merged successor of PR2 for mutating command expansion. +If PR1 merges before PR2 is ready, rebase `zach/warp-cli-readonly` onto the new `master`. If PR2 merges before PR3 is ready, rebase `zach/warp-cli-read-write` onto the new `master` or onto the updated read-only branch, depending on the active review base. Use raw git for all rebases, conflict resolution, and pushes. ## End-to-end flow ```mermaid sequenceDiagram @@ -293,7 +367,7 @@ sequenceDiagram CLI->>HTTP: Authenticated POST tab.create request HTTP->>HTTP: Verify context-specific enablement + credential + execution context HTTP->>BRIDGE: Typed request + response channel - BRIDGE->>BRIDGE: Recheck enablement + tier + auth-user policy + BRIDGE->>BRIDGE: Recheck enablement + permission + auth-user policy BRIDGE->>RES: Resolve window/tab/pane/session selectors RES-->>BRIDGE: Concrete target handles or typed error BRIDGE->>ACT: Execute allowlisted ControlAction @@ -311,7 +385,7 @@ Map tests directly to `PRODUCT.md` behavior. - Tests proving discovery in disabled state exposes no actionable endpoint authority or credential reference. - Credential-storage tests proving raw credentials are not written into plaintext discovery records. - Execution-context tests proving external clients cannot receive grants reserved for verified Warp-terminal invocations. - - Tier-enforcement tests proving insufficient grants fail with `insufficient_permissions` before selector resolution or handler dispatch. + - Permission-category enforcement tests proving insufficient grants fail with `insufficient_permissions` before selector resolution or handler dispatch, including separate denial cases for app-state mutation, metadata/configuration mutation, and underlying-data mutation. - Authenticated-user tests proving user-authenticated actions fail without a logged-in app user or authenticated-user grant. - Settings > Scripting tests proving both top-level toggles and granular disabled categories invalidate credentials and prevent new grants. - Structured-error tests for disabled, unauthenticated, expired, revoked, insufficient-scope, execution-context-denied, authenticated-user-required, authenticated-user-unavailable, unsupported, malformed, ambiguous, missing-target, stale-target, and invalid-selector requests. @@ -336,29 +410,36 @@ Map tests directly to `PRODUCT.md` behavior. - Startup-path tests or focused checks confirming `warpctrl` dispatches commands without entering GUI-app launch code. - Shell completions/help output checks once final command naming is selected. ## Parallelization -The first slice should stay mostly sequential because protocol envelope, discovery, authentication, selector resolution, and `tab.create` are tightly coupled and need one coherent architecture. -The follow-up catalog expansion is a strong fit for remote Oz cloud-agent fan-out after the first slice lands. Proposed parallel workstreams: -- `control-window-tab-pane` — remote agent owns window/tab/pane action expansion, including CLI syntax, protocol variants, app handlers, and tests. Branch suggestion: `zach/warp-control-cli-window-tab-pane`. -- `control-settings-appearance` — remote agent owns settings/theme/font/zoom allowlist expansion and validation. Branch suggestion: `zach/warp-control-cli-settings-appearance`. -- `control-input-surfaces` — remote agent owns session/input plus panel/palette/settings-surface commands, with extra care around command execution risk. Branch suggestion: `zach/warp-control-cli-input-surfaces`. -- `control-introspection-packaging` — remote agent owns richer list/read commands, documentation/examples, and any follow-on bundle/release plumbing not completed in PR1. Branch suggestion: `zach/warp-control-cli-introspection-packaging`. +The first slice on `zach/warp-cli` should stay mostly sequential because protocol envelope, discovery, authentication, feature-flag gating, selector resolution, and `tab.create` are tightly coupled and need one coherent architecture. +The read-only and read-write follow-up branches are strong fits for Oz cloud-agent fan-out, but the fan-out should happen inside the stacked branch strategy from `## Implementation Plan`, not as unrelated sibling feature branches. +Read-only fan-out: +- Launch agents against `zach/warp-cli-readonly` or short-lived shard branches based on it. +- Use the shard names from `### Read-only branch sharding with Oz cloud agents`: `readonly-protocol-cli`, `readonly-app-targets`, `readonly-underlying-data`, `readonly-settings-appearance`, and `readonly-files-drive-skill-docs`. +- The lead integrator merges accepted work into `zach/warp-cli-readonly` with raw git, then validates the full read-only API before the branch is used as the base for mutations. +Read-write fan-out: +- Launch agents against `zach/warp-cli-read-write` only after read-only target resolution and result shapes are stable. +- Use the shard names from `### Read-write branch sharding with Oz cloud agents`: `mutate-window-tab-pane`, `mutate-input-session`, `mutate-metadata-config`, `mutate-surfaces-files-drive`, `mutate-underlying-data`, and `mutate-tests-docs-skill`. +- The lead integrator merges accepted work into `zach/warp-cli-read-write` with raw git and keeps the branch rebased on the current read-only base. Merge strategy: -- Each remote agent works from the first slice’s merged baseline or a designated follow-up integration base. -- Each returns a branch or compact patch plus validation notes. -- A lead integrator folds accepted slices into one combined second PR so the public protocol remains coherent. +- One stacked PR per durable branch: `zach/warp-cli`, then `zach/warp-cli-readonly`, then `zach/warp-cli-read-write`. +- Shard branches should not become independent long-lived PRs unless the lead intentionally splits review; their default purpose is to feed the durable stacked branch. +- Use raw git for branch creation, merges, cherry-picks, rebases, conflict resolution, and pushes. Do not use Graphite commands. ```mermaid flowchart LR - P1["First slice merged<br/>protocol + discovery + bridge + tab.create"] --> Launch["Launch follow-up cloud agents"] - Launch --> A["control-window-tab-pane"] - Launch --> B["control-settings-appearance"] - Launch --> C["control-input-surfaces"] - Launch --> D["control-introspection-packaging"] - A --> Merge["Lead integrates protocol additions"] - B --> Merge - C --> Merge - D --> Merge - Merge --> Validate["Full validation + docs review"] - Validate --> P2["Single PR2 with remaining allowlist"] + Specs["zach/warp-cli-specs<br/>spec-only"] --> Core["zach/warp-cli<br/>core + tab.create"] + Core --> RO["zach/warp-cli-readonly<br/>read-only API"] + RO --> RW["zach/warp-cli-read-write<br/>mutating API"] + ROShardA["readonly-protocol-cli"] --> RO + ROShardB["readonly-app-targets"] --> RO + ROShardC["readonly-underlying-data"] --> RO + ROShardD["readonly-settings-appearance"] --> RO + ROShardE["readonly-files-drive-skill-docs"] --> RO + RWShardA["mutate-window-tab-pane"] --> RW + RWShardB["mutate-input-session"] --> RW + RWShardC["mutate-metadata-config"] --> RW + RWShardD["mutate-surfaces-files-drive"] --> RW + RWShardE["mutate-underlying-data"] --> RW + RWShardF["mutate-tests-docs-skill"] --> RW ``` ## Risks and mitigations - Fixed-port server assumptions: From 2464e444a0e521cfdf654693cc99d542307530fc Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Fri, 22 May 2026 18:03:32 -0600 Subject: [PATCH 10/48] Add warpctrl CLI verification plan Co-Authored-By: Oz <oz-agent@warp.dev> --- specs/warp-control-cli/TECH.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index d9703d25f5..8c1396d958 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -409,6 +409,20 @@ Map tests directly to `PRODUCT.md` behavior. - `--artifact cli`-style bundle smoke tests or script-level checks for each supported platform path touched by the first slice. - Startup-path tests or focused checks confirming `warpctrl` dispatches commands without entering GUI-app launch code. - Shell completions/help output checks once final command naming is selected. +### Computer-use CLI verification +Before any stacked PR is considered ready for review, run an end-to-end computer-use verification pass against a Claude-built validation artifact from that branch. The validation artifact is a Warp app build and standalone `warpctrl` binary built by the validation agent from the exact commit under review, with `warp_control_cli` compiled in and `FeatureFlag::WarpControlCli` enabled at runtime. +The verifier must launch the built Warp app, enable the Scripting settings needed for the command category under test, and capture screenshots or screen recordings that prove the user-visible result of each basic command family. Screenshots should be saved as durable artifacts and named by branch, invocation context, command family, and command name. +The verifier must exercise both invocation contexts: +- **Inside Warp terminal:** run `warpctrl` from a terminal session inside the feature-flag-enabled Warp app. This path must prove the app-issued Warp-terminal execution-context proof is accepted and that inside-Warp settings gate the command categories. +- **Outside Warp terminal:** run the same `warpctrl` binary from an external terminal or automation shell outside the built Warp app. This path must prove outside-Warp control is default-off, then works only after enabling outside-Warp scripting and the required granular permissions in Settings > Scripting. +The verification matrix should cover every implemented command in `PRODUCT.md` at least once in JSON output mode and, where there is a visible UI effect, with a screenshot after the command runs. At minimum: +- read-only metadata commands show successful CLI output and, for active/focus/list commands, a visible target that matches the output; +- underlying data read commands show successful CLI output only when the underlying-data-read permission is enabled and a denial screenshot/output when it is disabled; +- app-state mutation commands show before/after screenshots proving the visible Warp UI changed; +- metadata/configuration mutation commands show before/after screenshots proving the persisted setting or label changed; +- underlying data mutation commands run only in a disposable test workspace/session with test files and test Warp Drive objects, show denial without the underlying-data-mutation permission, then show success with the permission enabled; +- authenticated-user commands show both the logged-out or missing-grant denial path and the enabled authenticated path when a test account/environment is available. +The verifier should produce a verification manifest checked into or attached to the PR artifacts. The manifest should list each command, branch under test, invocation context, required permission category, expected result, actual result, screenshot artifact path, and any skipped case with a reason. Missing screenshots for visible commands block review readiness. ## Parallelization The first slice on `zach/warp-cli` should stay mostly sequential because protocol envelope, discovery, authentication, feature-flag gating, selector resolution, and `tab.create` are tightly coupled and need one coherent architecture. The read-only and read-write follow-up branches are strong fits for Oz cloud-agent fan-out, but the fan-out should happen inside the stacked branch strategy from `## Implementation Plan`, not as unrelated sibling feature branches. From e8d2e84ca7d59725c737fffe5479ff7ce74c62e1 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Sat, 23 May 2026 09:00:09 -0600 Subject: [PATCH 11/48] Specify warpctrl target selectors Co-Authored-By: Oz <oz-agent@warp.dev> --- specs/warp-control-cli/PRODUCT.md | 33 ++++++++++++++++++++++++------- specs/warp-control-cli/TECH.md | 30 ++++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/specs/warp-control-cli/PRODUCT.md b/specs/warp-control-cli/PRODUCT.md index 5ade7bbd5d..68a15d38d8 100644 --- a/specs/warp-control-cli/PRODUCT.md +++ b/specs/warp-control-cli/PRODUCT.md @@ -53,7 +53,14 @@ Non-goals: - Active tab in the selected window. - Active pane in the selected tab. - Active session in the selected pane. -10. Every selector family supports explicit opaque IDs returned by introspection. Tabs may also support index selectors where index-based workflows are already user-visible, but IDs remain the preferred automation surface. +10. Every selector family supports explicit opaque IDs returned by introspection. Selector families may also support scoped indices, titles/names, or paths where those concepts are already user-visible, but IDs remain the preferred automation surface. + - Window selectors support `active`, opaque window IDs, window indices from `window list`, and exact window titles for interactive use. + - Tab selectors support `active`, opaque tab IDs, tab indices scoped to the resolved window, and exact tab titles for interactive use. + - Pane selectors support `active`, opaque pane IDs, and pane indices scoped to the resolved tab or pane group. + - Session selectors support `active`, opaque session IDs, and session indices scoped to the resolved pane when sessions are user-visible as an ordered list. + - Block selectors use opaque block IDs from block/session introspection. + - File selectors use paths, plus optional line/column coordinates where the command supports opening or reading a location. + - Warp Drive selectors use opaque object IDs, with optional type-scoped exact name/path lookups for interactive use. 11. “Active session” means the currently selected terminal session for the resolved pane/window context. If the selected target does not contain a terminal session, session-scoped actions fail rather than silently redirecting elsewhere. 12. When a command omits lower-level selectors, it resolves them from the chosen higher-level context using active defaults. Example: a pane split command with only `--instance` uses that instance’s active window, active tab, and active pane. 13. When an explicitly supplied target disappears between discovery and execution, the request fails with a stale-target error. The CLI must not silently choose a different tab, pane, or session. @@ -136,8 +143,13 @@ Non-goals: - `warpctrl instance list` - `warpctrl app active` - `warpctrl tab create` + - `warpctrl tab rename --window-id <window_id> --tab-id <tab_id> "Build logs"` + - `warpctrl tab rename --window active --tab-index 0 "Build logs"` + - `warpctrl window close --window-title "Scratch"` - `warpctrl pane split --direction right` - `warpctrl pane split --instance <id> --window active --pane active --direction right` + - `warpctrl input replace --session-id <session_id> "cargo check"` + - `warpctrl block output --pane-id <pane_id> --block-id <block_id> --plain` - `warpctrl theme set "Warp Dark"` - `warpctrl setting set appearance.themes.system_theme true` - `warpctrl input insert "cargo check" --replace` @@ -177,15 +189,22 @@ The product surface must distinguish what kind of state a command touches. This - **Underlying data mutations** can change user data or cause external side effects: executing terminal commands, writing/creating/deleting files, running workflows that execute commands, CRUD operations on Warp Drive objects, mutating AI conversation history, and any action that can modify data outside transient app UI state. A command that touches multiple categories must require the strongest applicable permission. For example, `file open` is an app-state mutation, while `file write` is an underlying data mutation; `input insert` is an app-state mutation, while `input run` is an underlying data mutation because it executes a command in the target session. ### Targeting flags -All commands that address a running app target accept the same selector flags where meaningful: +All commands that address a running app target accept the same selector flags where meaningful. Generic `--window`, `--tab`, `--pane`, and `--session` flags accept the selector grammar below; explicit typed aliases are provided so scripts can avoid string parsing ambiguity: - `--instance <instance_id>` selects a running Warp process from `warpctrl instance list`. - `--pid <pid>` is a convenience instance selector and conflicts with `--instance`. -- `--window <active|id|index|title>` selects a window inside the instance. -- `--tab <active|id|index|title>` selects a tab inside the window. -- `--pane <active|id|index>` selects a pane inside the tab or pane-group context. -- `--session <active|id>` selects a terminal or agent session inside the pane when the command is session-scoped. +- `--window <active|id:<id>|index:<n>|title:<title>>` selects a window inside the instance. +- `--window-id <id>`, `--window-index <n>`, and `--window-title <title>` are exact aliases for the corresponding `--window ...` forms. +- `--tab <active|id:<id>|index:<n>|title:<title>>` selects a tab inside the resolved window. +- `--tab-id <id>`, `--tab-index <n>`, and `--tab-title <title>` are exact aliases for the corresponding `--tab ...` forms. +- `--pane <active|id:<id>|index:<n>>` selects a pane inside the resolved tab or pane-group context. +- `--pane-id <id>` and `--pane-index <n>` are exact aliases for the corresponding `--pane ...` forms. +- `--session <active|id:<id>|index:<n>>` selects a terminal or agent session inside the resolved pane when the command is session-scoped. +- `--session-id <id>` and `--session-index <n>` are exact aliases for the corresponding `--session ...` forms. +- `--block-id <id>` selects a terminal block for block-scoped read commands. +- File commands use path arguments or `--path <path>` where the path is the selected file entity; `--line <n>` and `--column <n>` refine the location when supported. +- Drive commands use object ID arguments or `--drive-id <id>` where the ID is the selected Warp Drive entity; name/path lookup must be type-scoped when supported. - `--output-format <pretty|json|ndjson|text>` controls output shape and remains globally available. -Omitted lower-level selectors use active defaults only when that active target is unambiguous. Explicit IDs must resolve exactly or fail with `stale_target`. +Within a selector family, specifying more than one form is invalid. For example, `--tab-id` conflicts with `--tab-index`, `--tab-title`, and `--tab`. Omitted lower-level selectors use active defaults only when that active target is unambiguous. Explicit IDs must resolve exactly or fail with `stale_target`; index/title/name/path selectors that match zero targets fail with `missing_target`, and selectors that match multiple targets fail with `ambiguous_target`. ### Read-only command set The read-only branch `zach/warp-cli-readonly` should implement the following commands before mutating catalog expansion begins. Read-only does not mean one permission: metadata reads and underlying data reads are separate grant categories. Metadata and capability reads: diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index 8c1396d958..9397eb2b0c 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -55,10 +55,23 @@ Create a small shared protocol crate or equivalent shared module used by both th - `TabSelector` - `PaneSelector` - `SessionSelector` + - `BlockSelector` + - `FileSelector` + - `DriveObjectSelector` - Opaque protocol-facing ID newtypes for instance/window/tab/pane/session identifiers. - Allowlisted `ControlAction` variants and typed parameter payloads. - Success/error envelopes with stable machine-readable error codes. The protocol should treat target IDs as opaque. The app may encode existing runtime identifiers internally, but the public wire contract should not require callers to understand `EntityId`, `PaneId`, or other implementation types. +Recommended selector variants: +- `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)`, `Index(u32)`. +- `BlockSelector`: `Id(BlockId)`. +- `FileSelector`: `Path { path, line, column }`. +- `DriveObjectSelector`: `Id(DriveObjectId)` or `Lookup { object_type, name_or_path }`. +Index selectors are resolved only within their parent selector context, so tab index resolution requires a resolved window and pane/session index resolution requires a resolved tab or pane. Title and name/path lookup selectors are ergonomic helpers for interactive use and must fail on ambiguity rather than choosing the first match. Recommended top-level request shape for `tab.create`: ```json { @@ -85,7 +98,7 @@ Recommended response shape: "result": {} } ``` -Error payloads should include stable codes defined in `SECURITY.md`, including `local_control_disabled`, `unauthorized_local_client`, `insufficient_permissions`, `authenticated_user_required`, `authenticated_user_unavailable`, `execution_context_not_allowed`, `ambiguous_instance`, `stale_target`, `invalid_selector`, `unsupported_action`, `not_allowlisted`, `invalid_params`, `target_state_conflict`, `missing_target`, and `no_instance`. +Error payloads should include stable codes defined in `SECURITY.md`, including `local_control_disabled`, `unauthorized_local_client`, `insufficient_permissions`, `authenticated_user_required`, `authenticated_user_unavailable`, `execution_context_not_allowed`, `ambiguous_instance`, `ambiguous_target`, `stale_target`, `invalid_selector`, `unsupported_action`, `not_allowlisted`, `invalid_params`, `target_state_conflict`, `missing_target`, and `no_instance`. ### 2. Per-process discovery instead of fixed-port-only routing Keep the existing fixed-port HTTP behavior intact for installation detection/profiling compatibility. Add a separate local-control listener that follows the same native Axum/Tokio pattern but supports multiple local Warp app processes. Recommended design: @@ -192,16 +205,27 @@ Recommended resolution order: 3. Resolve tab within the window. 4. Resolve pane within the tab/pane-group context. 5. Resolve session only for session-scoped commands. +6. Resolve block/file/Drive selectors only for commands whose action metadata declares that target family. Selector behavior: - `active` resolves from current app focus/selection state. - Explicit opaque IDs must resolve exactly or return `stale_target`. -- Index selectors are allowed only for user-visible indexed concepts such as tabs and should resolve to a concrete opaque ID before execution. +- Index selectors are allowed only for user-visible indexed concepts and should resolve to a concrete opaque ID before execution. +- Title, name, and path selectors are convenience selectors. They must be exact by default, document any future fuzzy behavior explicitly, and return `ambiguous_target` when more than one target matches. - A session-scoped request against a non-terminal pane returns `target_state_conflict`. Target resolution must happen after protected enablement, authentication, and safety-grant checks. This prevents denied requests from learning more target state than necessary and keeps enforcement centralized. Implementation references: - Window-level active selection already exists inside the app through `WindowManager`. - Pane scoping can build on the conceptual model of `PaneViewLocator` in `app/src/workspace/util.rs (12-18)`. - Existing URI intent routing in `app/src/uri/mod.rs (895-1093)` shows how to locate workspaces/windows and avoid silently acting in the wrong place. +#### CLI selector grammar +`crates/warp_cli/src/local_control.rs` should expose a shared selector argument group that is flattened into every command that accepts app targets. The parser must support: +- Instance selectors: `--instance <instance_id>` and `--pid <pid>`, with clap conflicts. +- Window selectors: `--window <active|id:<id>|index:<n>|title:<title>>`, `--window-id <id>`, `--window-index <n>`, and `--window-title <title>`, with one form allowed. +- Tab selectors: `--tab <active|id:<id>|index:<n>|title:<title>>`, `--tab-id <id>`, `--tab-index <n>`, and `--tab-title <title>`, with one form allowed. +- Pane selectors: `--pane <active|id:<id>|index:<n>>`, `--pane-id <id>`, and `--pane-index <n>`, with one form allowed. +- Session selectors: `--session <active|id:<id>|index:<n>>`, `--session-id <id>`, and `--session-index <n>`, with one form allowed. +- Block/file/Drive selectors only on commands that need them: `--block-id <id>`, path arguments or `--path <path>` plus `--line`/`--column`, and Drive object ID arguments or `--drive-id <id>`. +The CLI converts these flags into the protocol `TargetSelector` before sending the request. It must not rely on positional entity IDs for commands like `window close 1`; target entities are selected through the shared selector flags so command arguments remain reserved for action parameters. ### 6. Allowlisted handler families Use one handler module per action family. The protocol layer owns parsing/validation; handler modules own target resolution and delegation to existing app logic. Recommended modules/families: @@ -396,6 +420,8 @@ Map tests directly to `PRODUCT.md` behavior. - Behavior 7-13: - Selector-resolution unit tests for active, explicit ID, index, stale target, ambiguous target, and non-terminal session target. - Tests that no lower-level selector silently retargets after an explicit stale selector fails. + - CLI selector parsing tests for every generic and explicit alias form: `--window`, `--window-id`, `--window-index`, `--window-title`, `--tab`, `--tab-id`, `--tab-index`, `--tab-title`, `--pane`, `--pane-id`, `--pane-index`, `--session`, `--session-id`, and `--session-index`. + - CLI conflict tests proving only one selector form per entity family is accepted and that positional target IDs are rejected where the command expects selector flags. - Behavior 15-28: - Parser/serde tests for every first-slice `ControlAction` variant. - Router tests proving unknown/unallowlisted actions are rejected. From 270bd71c5300d2b1476d32cad776837df6a4ee4e Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Sat, 23 May 2026 09:04:15 -0600 Subject: [PATCH 12/48] Document authoritative warpctrl specs branch Co-Authored-By: Oz <oz-agent@warp.dev> --- specs/warp-control-cli/TECH.md | 1 + 1 file changed, 1 insertion(+) diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index 9397eb2b0c..11892d36e2 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -304,6 +304,7 @@ The intended stack is: 2. `zach/warp-cli` — first implementation branch. This stays as the core scaffolding slice: protocol crate, discovery/auth scaffolding, Scripting settings surface, local-control server/bridge, standalone `warpctrl` binary, packaging hooks, and only the single `warpctrl tab create` mutation needed to prove the end-to-end path. 3. `zach/warp-cli-readonly` — create this branch directly from `zach/warp-cli`. It implements the read-only command set from `PRODUCT.md` without adding additional mutations beyond the existing `tab create` proof command. 4. `zach/warp-cli-read-write` — create this branch directly from `zach/warp-cli-readonly`. It implements the mutating command set from `PRODUCT.md` after the read-only branch has established selectors, metadata result shapes, and inspection APIs. +Spec changes are an important part of the stacking strategy. All spec changes must originate on `zach/warp-cli-specs`, which is the authoritative source for `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs. After a spec change lands there, propagate it upward through every stacked branch with raw git so the spec files stay synchronized across `zach/warp-cli`, `zach/warp-cli-readonly`, and `zach/warp-cli-read-write`. Do not make independent spec edits directly on the implementation branches except as part of resolving a propagation conflict in a way that preserves the authoritative specs-branch content. Recommended raw-git setup: ```bash git fetch origin From 2ff71ac5c68ddd5eba0f80ca164d5c627831ee4f Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Sat, 23 May 2026 12:07:47 -0600 Subject: [PATCH 13/48] Update warpctrl specs for targeting and security scope Co-Authored-By: Oz <oz-agent@warp.dev> --- specs/warp-control-cli/PRODUCT.md | 96 +++++++++++++++++++++--------- specs/warp-control-cli/SECURITY.md | 32 +++++++--- 2 files changed, 90 insertions(+), 38 deletions(-) diff --git a/specs/warp-control-cli/PRODUCT.md b/specs/warp-control-cli/PRODUCT.md index 68a15d38d8..31bbae0f3f 100644 --- a/specs/warp-control-cli/PRODUCT.md +++ b/specs/warp-control-cli/PRODUCT.md @@ -1,13 +1,13 @@ # Summary -Warp should ship an allowlisted standalone local control CLI binary, provisionally named `warpctrl`, that lets developers script the same classes of user-visible actions they can already perform inside the running app: manipulating windows, tabs, panes, sessions, appearance, settings, and selected UI surfaces. The CLI should operate against one or more already-running local Warp app processes through a stable machine protocol, with deterministic target selection and clear errors when a process or target is ambiguous. +Warp should ship an allowlisted standalone local control CLI binary, provisionally named `warpctrl`, that lets developers script the same classes of user-visible actions they can already perform inside the running app: manipulating windows, tabs, panes, sessions, terminal blocks, appearance, settings, Warp Drive views, and selected UI surfaces. The CLI should operate against one or more already-running local Warp app processes through a stable machine protocol, with deterministic target selection and clear errors when a process or target is ambiguous. ## Problem -Warp already has rich interactive actions, but they are primarily reachable through UI, keybindings, menus, or deeplinks. Developers cannot reliably compose those same actions into shell scripts, demos, automation, or agent workflows, and there is no general local protocol for addressing a specific running Warp instance, window, pane, or session. +Warp already has rich interactive actions, but they are primarily reachable through UI, keybindings, menus, or deeplinks. Developers cannot reliably compose those same actions into shell scripts, demos, automation, or agent workflows, and there is no general local protocol for addressing a specific running Warp instance, window, pane, session, terminal block, Warp Drive object, or other uniquely named Warp entity. ## Goals / Non-goals Goals: - Provide a first-class, scriptable standalone `warpctrl` binary for controlling running Warp app processes. - Keep CLI startup lightweight by avoiding GUI-app startup or full terminal initialization for routine control commands. - Keep the surface allowlisted and finite instead of exposing arbitrary internal actions. -- Make targeting explicit and deterministic across multiple Warp processes, windows, tabs, panes, and sessions. +- Make targeting explicit and deterministic across multiple Warp processes, windows, tabs, panes, terminal sessions, terminal blocks, Warp Drive objects, files, projects/workspaces, command surfaces, and other uniquely addressable Warp nouns. - Support both ergonomic active-target defaults and precise selectors for automation. - Define a complete protocol/catalog up front, while shipping the implementation incrementally. Non-goals: @@ -21,7 +21,7 @@ Non-goals: 2. The CLI exposes only explicitly allowlisted actions. Unknown action names, unsupported parameter combinations, or requests for non-allowlisted capabilities fail with structured errors; they are never forwarded to arbitrary internal dispatch. 3. Every successful mutating request identifies: - The Warp process instance that executed it. - - The resolved target, when the action addresses a window, tab, pane, or session. + - The resolved target, when the action addresses a window, tab, pane, terminal session, terminal block, file, project/workspace, Warp Drive object, surface, or other targetable noun. - A success payload suitable for JSON output. 4. Every failure identifies: - A stable machine-readable error code. @@ -39,6 +39,8 @@ Non-goals: - `warpctrl tab list` - `warpctrl pane list` - `warpctrl session list` + - `warpctrl block list` + - `warpctrl drive list` - `warpctrl app active` These commands return opaque protocol-facing IDs and enough metadata for subsequent commands without requiring knowledge of internal Warp identifiers. 8. The target selector model is hierarchical: @@ -47,25 +49,29 @@ Non-goals: - Tab selector resolves within the window. - Pane selector resolves within the tab or active pane group context. - Session selector resolves within the pane when the pane hosts terminal session state. + - Block selector resolves within the terminal session when the command is block-scoped. + Non-hierarchical selectors such as file paths, projects/workspaces, Warp Drive objects, and global app surfaces still resolve inside the selected instance and must not silently borrow lower-level pane/session defaults unless the action definition explicitly requires that scope. 9. Every selector family supports an ergonomic `active` form when that concept exists: - Active instance, if unambiguous. - Active window in the selected instance. - Active tab in the selected window. - Active pane in the selected tab. - Active session in the selected pane. + - Active or selected terminal block in the selected session when a current block is unambiguous. 10. Every selector family supports explicit opaque IDs returned by introspection. Selector families may also support scoped indices, titles/names, or paths where those concepts are already user-visible, but IDs remain the preferred automation surface. - Window selectors support `active`, opaque window IDs, window indices from `window list`, and exact window titles for interactive use. - Tab selectors support `active`, opaque tab IDs, tab indices scoped to the resolved window, and exact tab titles for interactive use. - Pane selectors support `active`, opaque pane IDs, and pane indices scoped to the resolved tab or pane group. - Session selectors support `active`, opaque session IDs, and session indices scoped to the resolved pane when sessions are user-visible as an ordered list. - - Block selectors use opaque block IDs from block/session introspection. + - Block selectors support `active`, opaque block IDs, and block indices scoped to the resolved terminal session when blocks are user-visible as an ordered list. A block command may also support read-only filters such as command text, status, time range, or “last completed” for interactive lookup, but those filters must fail on ambiguity and resolve to concrete block IDs before reading output. - File selectors use paths, plus optional line/column coordinates where the command supports opening or reading a location. - - Warp Drive selectors use opaque object IDs, with optional type-scoped exact name/path lookups for interactive use. + - Project/workspace selectors use paths, opaque project/workspace IDs when exposed by introspection, and exact names only as interactive convenience selectors. + - Warp Drive selectors use opaque object IDs, with optional type-scoped exact name/path lookups for interactive use. Type scopes must include the user-facing object families Warp exposes today: spaces, folders, notebooks, workflows, agent-mode workflows/prompts, environment variable collections, AI facts/rules, MCP servers, MCP server collections, and trash entries when trash operations are supported. 11. “Active session” means the currently selected terminal session for the resolved pane/window context. If the selected target does not contain a terminal session, session-scoped actions fail rather than silently redirecting elsewhere. 12. When a command omits lower-level selectors, it resolves them from the chosen higher-level context using active defaults. Example: a pane split command with only `--instance` uses that instance’s active window, active tab, and active pane. 13. When an explicitly supplied target disappears between discovery and execution, the request fails with a stale-target error. The CLI must not silently choose a different tab, pane, or session. 14. The protocol is command-oriented, not open-ended state mutation. Each action has a named command, validated parameters, and defined target scope. -15. The complete allowlisted action catalog should be organized into these namespaces. +15. The complete allowlisted action catalog should be organized around stable public nouns rather than internal view/action names. The target taxonomy includes instances, windows, tabs, panes, terminal sessions, terminal blocks, input buffers, command history entries, files, projects/workspaces, Warp Drive spaces, folders, notebooks, workflows, agent-mode workflows/prompts, environment variable collections, AI facts/rules, MCP servers, MCP server collections, settings, themes, keybindings, command surfaces such as the command palette and command search, panels/surfaces such as Warp Drive, resource center, AI assistant, code review, left/right panels, and vertical tabs, plus action/capability metadata. The initial implementation may expose only a subset, but new command families should extend this noun taxonomy instead of inventing unrelated selector conventions. 16. Discovery and read-only state actions: - List instances. - Get protocol/app version information for one instance. @@ -98,9 +104,8 @@ Non-goals: - Insert text into the active input without executing it. - Replace the active input buffer. - Clear the active input buffer where that matches existing user behavior. - - Run a command in the target session where the app already supports user-triggered command execution. - Switch input mode between terminal and agent modes only where that mode switch is already user-visible and valid for the selected target. - These commands are part of the protocol catalog, but command execution should be treated as a higher-risk mutating action with explicit confirmation in spec/review before rollout. + The initial public version must not submit terminal input, press Enter, run terminal commands, accept suggested commands, launch workflows into a terminal, or submit agent prompts. At most, it may stage text into an active input buffer for the user to review and confirm manually. Command execution and agent-prompt submission may be reserved as future protocol concepts only after a separate product/security review. 21. Appearance actions: - List available themes. - Set the current fixed theme. @@ -139,6 +144,7 @@ Non-goals: - Arbitrary cloud object mutation or broad Warp Drive CRUD. - Arbitrary internal view dispatch by string. - Arbitrary setting names outside the allowlist. + - Terminal command execution, workflow execution, accepted-command submission, and agent-prompt submission in the initial public version. 27. CLI command names should be noun-oriented and discoverable. During the provisional standalone-binary phase, the control CLI should expose a `warpctrl ...` command surface: - `warpctrl instance list` - `warpctrl app active` @@ -167,7 +173,7 @@ Non-goals: 31. Requests should be scoped to local-user control of the running app, with separate enforcement for actions that require a true logged-in Warp user. A command that fails local authentication, local authorization, execution-context checks, or authenticated-user checks reports that condition explicitly and does not degrade into a less-specific request. 32. If a selected action is valid in general but impossible in the current UI state, the CLI reports a state-specific failure. Examples include: - Splitting a pane that no longer exists. - - Running a session-scoped command against a non-terminal pane. + - Issuing a session-scoped action against a non-terminal pane. - Focusing a window that has closed. - Setting a theme that is not available in that instance. 33. The first `warpctrl` implementation slice should ship the smallest end-to-end vertical slice that proves: @@ -186,10 +192,10 @@ The product surface must distinguish what kind of state a command touches. This - **Underlying data reads** expose user content or data-bearing state without changing it: terminal output, block contents, command history, input buffer contents, file contents, Warp Drive object contents, AI conversation content, and any other content that could contain user data or secrets. - **App-state mutations** change visible local Warp UI state without directly changing user data: opening or focusing windows, creating or closing tabs, splitting panes, focusing panes, opening panels, opening command surfaces, opening files in Warp, and editing the input buffer without submitting it. - **Metadata/configuration mutations** change persistent configuration or metadata, but not primary user content: changing themes, font size, zoom, allowlisted settings, keybindings, tab names, pane names, and tab colors. -- **Underlying data mutations** can change user data or cause external side effects: executing terminal commands, writing/creating/deleting files, running workflows that execute commands, CRUD operations on Warp Drive objects, mutating AI conversation history, and any action that can modify data outside transient app UI state. -A command that touches multiple categories must require the strongest applicable permission. For example, `file open` is an app-state mutation, while `file write` is an underlying data mutation; `input insert` is an app-state mutation, while `input run` is an underlying data mutation because it executes a command in the target session. +- **Underlying data mutations** can change user data or cause external side effects: writing/creating/deleting files, CRUD operations on Warp Drive objects, mutating AI conversation history, and future execution actions such as running terminal commands, running workflows that execute commands, accepting suggested commands, or submitting agent prompts. +A command that touches multiple categories must require the strongest applicable permission. For example, `file open` is an app-state mutation, while `file write` is an underlying data mutation; `input insert` is an app-state mutation, while a future `input run` action would be an underlying data mutation because it executes a command in the target session. ### Targeting flags -All commands that address a running app target accept the same selector flags where meaningful. Generic `--window`, `--tab`, `--pane`, and `--session` flags accept the selector grammar below; explicit typed aliases are provided so scripts can avoid string parsing ambiguity: +All commands that address a running app target accept the same selector flags where meaningful. Generic `--window`, `--tab`, `--pane`, `--session`, and `--block` flags accept the selector grammar below; explicit typed aliases are provided so scripts can avoid string parsing ambiguity: - `--instance <instance_id>` selects a running Warp process from `warpctrl instance list`. - `--pid <pid>` is a convenience instance selector and conflicts with `--instance`. - `--window <active|id:<id>|index:<n>|title:<title>>` selects a window inside the instance. @@ -200,7 +206,8 @@ All commands that address a running app target accept the same selector flags wh - `--pane-id <id>` and `--pane-index <n>` are exact aliases for the corresponding `--pane ...` forms. - `--session <active|id:<id>|index:<n>>` selects a terminal or agent session inside the resolved pane when the command is session-scoped. - `--session-id <id>` and `--session-index <n>` are exact aliases for the corresponding `--session ...` forms. -- `--block-id <id>` selects a terminal block for block-scoped read commands. +- `--block <active|id:<id>|index:<n>>` selects a terminal block inside the resolved terminal session when the command is block-scoped. +- `--block-id <id>` and `--block-index <n>` are exact aliases for the corresponding `--block ...` forms. - File commands use path arguments or `--path <path>` where the path is the selected file entity; `--line <n>` and `--column <n>` refine the location when supported. - Drive commands use object ID arguments or `--drive-id <id>` where the ID is the selected Warp Drive entity; name/path lookup must be type-scoped when supported. - `--output-format <pretty|json|ndjson|text>` controls output shape and remains globally available. @@ -225,9 +232,9 @@ Window, tab, pane, and session reads: - `warpctrl session list [--pane <selector>] [selectors]` - `warpctrl session inspect [--session <selector>] [selectors]` Underlying data reads, gated separately from structural metadata reads: -- `warpctrl block list [--pane <selector>] [--limit <n>] [selectors]` -- `warpctrl block inspect <block_id> [selectors]` -- `warpctrl block output <block_id> [--plain|--ansi|--json] [selectors]` +- `warpctrl block list [--session <selector>|--pane <selector>] [--limit <n>] [selectors]` +- `warpctrl block inspect --block <selector> [selectors]` +- `warpctrl block output --block <selector> [--plain|--ansi|--json] [selectors]` - `warpctrl input get [--session <selector>] [selectors]` - `warpctrl history list [--session <selector>] [--limit <n>] [selectors]` Appearance, settings, and command-surface reads: @@ -245,7 +252,7 @@ Local file and project reads that expose only app/editor state, not arbitrary fi - `warpctrl project active [selectors]` - `warpctrl project list [selectors]` Authenticated read-only Warp Drive metadata and data reads, enabled only when the selected app has a logged-in Warp user and the grant allows authenticated reads. Listing is metadata; inspecting object content is an underlying data read: -- `warpctrl drive list --type <workflow|notebook|env-var-collection|prompt|folder> [selectors]` +- `warpctrl drive list --type <workflow|notebook|env-var-collection|prompt|folder|ai-fact|mcp-server|space|trash> [selectors]` - `warpctrl drive inspect <id> [selectors]` ### Mutating command set The stacked branch `zach/warp-cli-read-write` should build on `zach/warp-cli-readonly` and implement the following mutating commands. Mutating commands are split by what they mutate: app-state, metadata/configuration, or underlying data. Underlying data mutations require a separate and stronger permission than app-state or metadata/configuration mutations. @@ -301,8 +308,7 @@ App-state mutations for sessions and input buffers: - `warpctrl input replace <text> [--session <selector>] [selectors]` - `warpctrl input clear [--session <selector>] [selectors]` - `warpctrl input mode set <terminal|agent> [--session <selector>] [selectors]` -Underlying data mutations for terminal execution: -- `warpctrl input run <command> [--session <selector>] [selectors]` +These input-buffer commands only stage or edit text. The initial public implementation must not include a command that submits the buffer, executes a terminal command, accepts a suggested command, or sends an agent prompt. Metadata/configuration mutations for appearance and settings: - `warpctrl theme set <theme_name> [selectors]` - `warpctrl theme system set <true|false> [selectors]` @@ -327,15 +333,36 @@ Underlying data mutations for files and authenticated Warp Drive objects: - `warpctrl file write <path> --content <text> [selectors]` - `warpctrl file append <path> --content <text> [selectors]` - `warpctrl file delete <path> [selectors]` -- `warpctrl drive workflow run <id> [--arg <name=value>...] [selectors]` - `warpctrl drive object create --type <workflow|notebook|env-var-collection|prompt|folder> [selectors]` - `warpctrl drive object update <id> [selectors]` - `warpctrl drive object trash <id> [selectors]` - `warpctrl drive object restore <id> [selectors]` +Future execution actions explicitly excluded from the initial public implementation: +- `warpctrl input run <command> [--session <selector>] [selectors]` +- `warpctrl agent prompt submit <prompt> [--session <selector>] [selectors]` +- `warpctrl drive workflow run <id> [--arg <name=value>...] [selectors]` +These are underlying-data mutations because they can execute code, trigger external side effects, or send user-authored prompts. They require a separate product/security review before being added to any public allowlist. ### Excluded from the public command surface The command surface must continue to exclude debug-only, crash-only, auth-token, heap-dump, and arbitrary internal dispatch actions even when those actions are available in command palette or keybinding registries. Examples that remain excluded are app crash/panic helpers, access-token copy helpers, heap profile dumps, debug reset actions, raw view-tree debugging, and broad internal action-by-string execution. +## Branch stacking and delivery model +The Warp Control CLI work should ship as a raw-git branch stack so specs, core scaffolding, read-only expansion, and mutating expansion remain reviewable independently: +- `zach/warp-cli-specs` is the spec-only branch. It owns `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs, and should not contain implementation changes. +- `zach/warp-cli` stacks on the specs branch and owns the first implementation slice: shared protocol, discovery/auth scaffolding, Settings > Scripting, local-control bridge/server, standalone `warpctrl` binary, packaging hooks, and the smallest safe end-to-end action. +- `zach/warp-cli-readonly` stacks on `zach/warp-cli` and fills in the read-only command set, including structural metadata reads and separately gated underlying-data reads such as terminal block output. +- `zach/warp-cli-read-write` stacks on `zach/warp-cli-readonly` and fills in approved mutating command families while preserving the initial prohibition on terminal command execution and agent-prompt submission. +Spec changes originate on `zach/warp-cli-specs` and are propagated upward through the stack with raw git so all implementation branches reflect the same product/security contract. Graphite is not part of this stack. If a lower branch merges first, higher branches should rebase onto the merged successor while preserving the approved spec content. ## Built-in Warp Agent skill Warp should include a built-in Agent skill for `warpctrl`, analogous to the bundled `oz-platform` skill. The skill should teach Warp Agent when to use `warpctrl`, how to discover and target running instances, how to prefer read-only commands before mutating commands, how to request explicit user approval for underlying data mutations, and how to interpret structured errors. The skill should document the stable command hierarchy above, include concise recipes for common automation tasks, and avoid instructing agents to bypass the CLI by calling local-control HTTP endpoints directly. +## CLI implementation and documentation conventions +`warpctrl` should feel consistent with the Oz CLI from a developer's perspective and use the same CLI libraries and conventions: +- Argument parsing, subcommand structure, help text, and shell-completion generation should use the same `clap`/`clap_complete` patterns used by the Oz CLI. +- JSON serialization and machine-readable output should use the same `serde`/`serde_json` conventions and the same output-format vocabulary used by the Oz CLI. +- Human-readable help, examples, errors, and generated completions should follow Oz CLI conventions unless Warp Control has a documented product reason to differ. +CLI documentation should be generated from the command catalog instead of maintained by hand in multiple places: +- The typed action catalog is the source of truth for command names, selector flags, parameters, output formats, state/data category, required permission, authenticated-user requirement, support status, and examples. +- `warpctrl help`, shell completions, markdown reference docs, the built-in Warp Agent skill, and the operator README should be generated or checked from that catalog so they cannot drift silently. +- Generated documentation must distinguish implemented commands from planned catalog entries. A command may appear in specs as planned, but public operator docs must not imply it is usable until the selected app build advertises support for it. +- CI or presubmit checks should fail when CLI parser/help output, generated reference docs, completions, or the built-in skill are stale relative to the command catalog. ## Action classification and permission model Agents, scripts, and human developers are expected to be major consumers of `warpctrl`. The action catalog must therefore classify every action by risk posture, state/data category, permission category, and authenticated-user requirement so Warp can enforce local-control permissions in the app bridge. Every action definition must include: @@ -365,8 +392,9 @@ Every action in the catalog belongs to exactly one of the following permission c 4. **Mutating / metadata or configuration.** Actions that change persistent metadata or configuration but do not directly mutate primary user data. - Tab and pane names, tab colors, themes, system-theme settings, font size, zoom, allowlisted app settings, and keybindings. Metadata/configuration writes need a stronger permission than app-state-only changes because they persist beyond the current UI interaction, but they are still distinct from data writes. -5. **Mutating / underlying data.** Actions that can change user data, execute code, or cause external side effects. - - Terminal execution: `input run`, workflow execution in a terminal session, and any command execution path. +5. **Mutating / underlying data.** Actions that can change user data, execute code, submit prompts, or cause external side effects. + - Future terminal execution: `input run`, workflow execution in a terminal session, and any command execution path. These are explicitly excluded from the initial public implementation. + - Future agent execution: submitting an agent prompt, accepting an agent-proposed command, or otherwise causing an agent to act. These are explicitly excluded from the initial public implementation. - File writes: create, write, append, delete, rename, or otherwise modify local files. - Warp Drive CRUD: create, update, trash, restore, permanently delete, or otherwise mutate workflows, notebooks, prompts, env-var collections, folders, or other Drive objects. - AI conversation history mutation and any action that modifies cloud-backed user content. @@ -378,6 +406,15 @@ The allowlist must clearly indicate `requires_authenticated_user` for every acti - `true` for actions that read or mutate Warp Drive, AI conversation traces, synced settings, team/account data, user identity data, or any cloud-backed Warp state. - `true` for actions that execute user-authored Warp Drive content, even if the execution target is a local terminal session. If an authenticated-user action is invoked while the selected app has no logged-in user, the CLI reports a structured authenticated-user error. It must not silently return partial logged-out data as success. +### Warp Control login protocol +`warpctrl` must not maintain an independent cloud login that can drift from the Warp process it controls. For authenticated-user actions, the logged-in user is the user currently authenticated in the selected Warp app instance. +The CLI should expose a small auth/status flow for actions that require a logged-in Warp user: +- `warpctrl auth status [selectors]` reports whether the selected Warp app is logged in and returns a stable, non-secret user subject/identity summary when the caller has the required local-control grant. +- `warpctrl auth login [selectors]` does not collect credentials in the CLI or mint a separate CLI account session. It focuses or opens the selected Warp app's normal sign-in UI and waits, or exits with instructions, until the user completes sign-in in that app. +- After login completes, the app-side credential broker may mint an authenticated-user grant only for the same user subject that is currently logged in to the selected app. +- Authenticated-user credentials are bound to the selected app instance and user subject. If the app logs out, switches users, loses auth state, or the grant's subject no longer matches the selected app's logged-in subject, authenticated-user actions fail with a structured authenticated-user error rather than using stale authority. +- Raw Firebase, server, OAuth, or cloud API tokens are never exported to `warpctrl`, shell scripts, generated docs, logs, or JSON output. +This login protocol applies only to actions whose allowlist entry requires a true logged-in Warp user. Logged-out-safe local actions continue to use local-control credentials without requiring Warp account login. ### Execution context policy `warpctrl` should distinguish verified invocations from inside Warp-managed terminal sessions from external invocations. - **Verified Warp-terminal invocation:** a `warpctrl` process started inside a Warp-managed terminal session and able to present an app-issued execution-context proof. The top-level setting for this context should default to on. When the selected app has a logged-in Warp user, this context can receive authenticated-user grants if the user's Scripting permissions allow that grant. @@ -410,8 +447,9 @@ Scoped credentials should include: - issuance and expiry metadata; - revocation/audit identity. The bridge, not the CLI frontend, enforces these grants. If a request exceeds its credential, the bridge returns `insufficient_permissions`, `authenticated_user_required`, `authenticated_user_unavailable`, or `execution_context_not_allowed` as appropriate. -### Future entity extensibility: files and Warp Drive objects -The selector and action model should be designed to accommodate entity types beyond the current window/tab/pane/session hierarchy. Two important future entity families are **local files** and **Warp Drive objects** (workflows, notebooks, environment variables, prompts). Neither is in scope for the first implementation, but the protocol should not preclude them. +### Future entity extensibility: files, blocks, and Warp Drive objects +The selector and action model should be designed to accommodate entity types beyond the current window/tab/pane/session hierarchy. Important entity families are **terminal blocks**, **local files**, **projects/workspaces**, and **Warp Drive objects**. Neither broad file/Drive mutation nor command/agent execution is in scope for the first implementation, but the protocol should not preclude future reviewed additions. +**Terminal blocks.** Blocks are first-class targetable terminal entities, not just data hanging off a session. Block selectors should support the same addressing primitives as terminal sessions where meaningful: active/current block, opaque block ID, and block index scoped to the resolved session. Block reads can expose command text, output, status, timing, exit code, and metadata, so block content reads are underlying-data reads while block listing that returns only IDs/status/timestamps may be metadata reads. Stale, missing, or ambiguous block selectors must fail rather than selecting a neighboring block. **Files.** Warp already supports file opening via deep links and the built-in editor. A future `file` namespace could support: - `warpctrl file open <path>` — app-state mutation that opens a file in a Warp editor tab, equivalent to clicking a file link. - `warpctrl file open <path> --line <n>` — app-state mutation that opens at a specific line. @@ -419,14 +457,14 @@ The selector and action model should be designed to accommodate entity types bey - `warpctrl file read <path>` — underlying data read that returns file contents. - `warpctrl file create|write|append|delete <path>` — underlying data mutations that modify the filesystem. File selectors would use filesystem paths (absolute or relative to the working directory of the target pane/session). Unlike window/tab/pane selectors, file selectors are not opaque IDs — they are user-visible paths. The protocol should support a `file` field in the target selector that accepts a path string, distinct from the opaque ID selectors used for windows, tabs, and panes. -**Warp Drive objects.** Warp Drive stores typed objects (workflows, notebooks, environment variable sets, prompts) that users can reference, execute, and share. A future `drive` namespace could support: +**Warp Drive objects.** Warp Drive stores typed objects that users can reference, execute, edit, and share. The object taxonomy should include, at minimum, spaces, folders, notebooks, workflows, agent-mode workflows/prompts, environment variable collections, AI facts/rules, MCP servers, MCP server collections, and trash entries where trash operations are exposed. A future `drive` namespace could support: - `warpctrl drive list --type workflow` — authenticated metadata read that lists Warp Drive objects by type. - `warpctrl drive inspect <id>` — authenticated underlying data read when it returns object content. -- `warpctrl drive workflow run <workflow-id>` — authenticated underlying data mutation that executes a workflow in a target session. +- `warpctrl drive workflow run <workflow-id>` — future authenticated underlying data mutation that executes a workflow in a target session, excluded from the initial public implementation. - `warpctrl drive object create|update|trash|restore <id>` — authenticated underlying data mutations that change cloud-backed user content. - `warpctrl drive notebook open <notebook-id>` — app-state mutation that opens a view of an existing notebook without modifying it. -Drive object selectors should support both opaque IDs (for automation stability) and human-friendly name/path lookups (for interactive use). The type field (`workflow`, `notebook`, `env_var`, `prompt`) acts as a namespace filter. Drive actions that execute content in a terminal session (e.g., running a workflow) inherit the underlying-data-mutation permission from the action classification model. -**Design constraints for both:** +Drive object selectors should support both opaque IDs (for automation stability) and human-friendly name/path lookups (for interactive use). The type field (`workflow`, `notebook`, `env_var_collection`, `prompt`, `folder`, `ai_fact`, `mcp_server`, etc.) acts as a namespace filter. Drive actions that execute content in a terminal session (e.g., running a workflow) inherit the underlying-data-mutation permission from the action classification model and remain unavailable until the execution prohibition is lifted by a later spec/review. +**Design constraints for these future entity families:** - File and Drive selectors are orthogonal to the window/tab/pane hierarchy — a file open action targets an instance (which window to open in), not a specific pane. A Drive workflow execution targets a session (which pane to run in). - The `TargetSelector` type in the protocol should be extensible with optional fields for these new selector families without breaking existing requests that omit them. - The action classification categories apply, and Drive actions require authenticated-user grants by default: listing Drive objects is metadata plus authenticated user, reading Drive object content is underlying-data-read plus authenticated user, opening an existing Drive object in the app is app-state mutation plus authenticated user, and executing or changing a Drive object is underlying-data-mutation plus authenticated user. diff --git a/specs/warp-control-cli/SECURITY.md b/specs/warp-control-cli/SECURITY.md index 24297d8eb3..522a2c2cc7 100644 --- a/specs/warp-control-cli/SECURITY.md +++ b/specs/warp-control-cli/SECURITY.md @@ -1,5 +1,5 @@ # warpctrl security architecture -`warpctrl` is a local-control CLI for an already-running Warp app instance. Its security architecture is designed to support the full control catalog: discovery, structural metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, underlying data mutations, input-buffer staging, command execution, file operations, and Warp Drive operations. +`warpctrl` is a local-control CLI for an already-running Warp app instance. Its security architecture is designed to support the control catalog: discovery, structural metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, underlying data mutations, input-buffer staging, file operations, and Warp Drive operations. Terminal command execution, workflow execution, accepted-command submission, and agent-prompt submission are future high-risk capabilities and must not be included in the initial public implementation; the initial version may stage text in an active input buffer only for the user to review and confirm manually. The correct architecture is not a single shared localhost bearer token with client-side conventions. The CLI, app bridge, and protocol must treat security as a local app-enforced capability system: discovery finds compatible instances, secure storage protects raw credential material, broker-issued credentials identify the granted scopes, the running Warp app's local-control bridge enforces action categories before dispatch, and target resolution never silently retargets a request. The action-category model is primarily a safety and intent mechanism, not a hard security boundary against malicious same-user software. It lets a user, script, or agent intentionally request metadata-only, data-read, app-state mutation, metadata/configuration mutation, or underlying-data mutation access so it does not accidentally mutate state, expose sensitive content, or execute commands. It should not be described as strong access control against a process that can already run arbitrary commands as the user. `warpctrl` has two distinct authorization dimensions: local-control authority and Warp user authority. Local-control authority proves the request is allowed to control the local app. Warp user authority proves the selected Warp app has a real logged-in Warp user and the request is allowed to act on user-authenticated data such as Warp Drive objects, AI conversation traces, synced settings, or cloud-backed user state. Logged-out users should retain a smaller local-only control surface, but authenticated-user actions require a true logged-in Warp user in the selected app. @@ -20,6 +20,7 @@ The action-category model is primarily a safety and intent mechanism, not a hard - Classify every action by state/data category and enforce the required permission category in the local app bridge, not in the CLI frontend. - Classify every action by whether it requires an authenticated Warp user. New actions should default to requiring an authenticated user unless they are deliberately reviewed as safe for logged-out or external use. - Prevent `warpctrl` from becoming an ambient full-power confused deputy that any same-user process can invoke for high-risk actions. +- Prohibit terminal command execution, workflow execution, accepted-command submission, and agent-prompt submission in the initial public implementation; input-buffer actions may stage text only and must not submit it. - Preserve deterministic targeting so a request never silently mutates or reads the wrong window, tab, pane, session, file, or Warp Drive object. - Keep the action surface allowlisted and typed rather than exposing arbitrary internal app dispatch. - Make high-risk operations auditable and configurable without logging sensitive terminal contents or credentials. @@ -114,7 +115,7 @@ Once the relevant inside-Warp or outside-Warp enablement setting allows a reques - **Underlying data reads:** permit reads of terminal output, scrollback, input buffers, command history, session traces, file contents, Warp Drive object contents, AI conversation content, and other content-bearing state. - **App-state mutations:** permit local UI/layout/focus changes such as opening windows, creating tabs, closing tabs, focusing panes, splitting panes, opening panels, opening files/projects/views, and staging text in the input buffer without executing it. - **Metadata/configuration mutations:** permit persistent metadata or configuration changes such as tab/pane names, tab colors, themes, font size, zoom, allowlisted settings, and keybindings. -- **Underlying data mutations:** permit terminal command execution, file create/write/append/delete, Warp Drive workflow execution, Warp Drive object CRUD, AI conversation mutations, and any other action that can change user data or cause external side effects. +- **Underlying data mutations:** permit file create/write/append/delete, Warp Drive object CRUD, AI conversation mutations, and any other action that can change user data or cause external side effects. Terminal command execution, Warp Drive workflow execution, accepted-command submission, and agent-prompt submission belong in this category if they are added later, but they are not allowed in the initial public implementation. - **Authenticated-user actions from Warp terminals:** permit `warpctrl` invocations that originate from a verified Warp-managed terminal session to receive grants backed by the currently logged-in Warp user, enabling actions that read or mutate Warp Drive, AI conversation traces, synced settings, or other user-authenticated state. - **Authenticated-user actions from external clients:** default off. If supported, this must be a separate explicit permission from in-Warp authenticated actions because external same-user processes are a weaker context than a Warp-managed terminal session. Granular permissions should be independently configurable for inside-Warp and outside-Warp contexts where the distinction matters. Disabling any category should invalidate active credentials that include that category. The broker and app bridge must enforce these settings locally for every credential request and every presented credential. App-state mutation permission must not imply metadata/configuration mutation or underlying data mutation permission. @@ -130,9 +131,18 @@ These scoped credentials are guardrails for well-behaved clients. They prevent a Acceptable designs include a short-lived per-session capability, an app-owned broker handshake tied to the terminal session, or an equivalent proof that arbitrary external processes cannot mint by setting an environment variable. Plain environment variables may be used as handles or hints, but they must not be the sole authority for in-Warp privileges because external processes can spoof them. Verified in-Warp context can raise the maximum eligible grant set, especially for authenticated-user actions. It does not by itself bypass the user's granular local-control settings, action categories, target scopes, or logged-in-user requirements. ### Warp user authentication boundary -Actions that touch user-authenticated Warp data require a true logged-in Warp user in the selected app. This includes Warp Drive object contents or mutation, AI conversation traces, cloud-backed user settings, team/account data, and any other surface whose normal app access depends on the user's Warp account. +Actions that touch user-authenticated Warp data require a true logged-in Warp user in the selected app. This includes Warp Drive object contents or mutation, AI conversation traces, cloud-backed user settings, team/account data, and any other surface whose normal app access depends on the user's Warp account. Authenticated-user grants must be tied to the same logged-in Warp user that is active in the selected app instance; `warpctrl` must not maintain a separate cloud login that can drift from the controlled process. The app bridge should execute these actions on behalf of the logged-in app user through existing app auth state. `warpctrl` should receive a local-control credential that carries an `authenticated_user` grant, the verified user identity or stable subject reference, and the allowed authenticated action families. It should not need to export raw Firebase, server, or cloud API tokens to shell scripts. If the selected app has no logged-in user, authenticated-user actions must fail with a structured error rather than falling back to logged-out behavior. Logged-out users may still use the smaller local-only action set explicitly marked as not requiring an authenticated user. +### Authenticated-user login protocol +`warpctrl` should provide an auth/status flow for users and automation that need authenticated-user actions, but the CLI must not collect Warp credentials or own an independent Warp account session. +Requirements: +- `warpctrl auth status [selectors]` reports whether the selected app instance is logged in and may return a stable, non-secret user subject/identity summary when the caller has the required grant. +- `warpctrl auth login [selectors]` focuses or opens the selected Warp app's normal sign-in UI and waits, or exits with actionable instructions, until the user signs in through Warp itself. +- The credential broker may mint an authenticated-user grant only after confirming the selected app has a true logged-in Warp user and the requested authenticated-user setting is enabled for the verified invocation context. +- Authenticated-user credentials are bound to the selected instance and logged-in user subject. If the app logs out, switches users, loses authenticated state, or the presented credential subject no longer matches the selected app's current user, authenticated-user actions fail with `authenticated_user_mismatch` or another structured authenticated-user error. +- The app bridge executes authenticated-user actions through the selected app's existing auth state. Raw Firebase, server, OAuth, or cloud API tokens must not be exported to `warpctrl`, shell scripts, JSON output, generated docs, or logs. +This protocol applies only to actions whose allowlist entry requires a logged-in Warp user. Logged-out-safe actions continue to use local-control credentials without requiring Warp account login. ### Application identity boundary On platforms with secure credential storage, especially macOS, the raw local-control credential should be readable only by Warp-owned, correctly signed code. On macOS this means storing raw credential material in Keychain with access constrained by Warp's signing identity, designated requirement, Keychain access group, or equivalent platform mechanism. This narrows token extraction from “any same-user process can read a file” to “only trusted Warp-signed code can unwrap the secret.” This boundary protects the credential from direct theft and prevents arbitrary apps from making authenticated raw HTTP requests to the local-control listener. It also lets the authoritative enablement state be stored somewhere harder to modify than ordinary user preferences. It does not prove that the user personally intended the specific action. Any same-user process may still be able to invoke the trusted `warpctrl` binary or automate the Warp UI. That confused-deputy risk is reduced by explicit in-app enablement, scoped credential issuance, action-category policy, and local app-side bridge enforcement, but it is not eliminated as a hard same-user security boundary. @@ -309,7 +319,7 @@ Default rule for new actions: - New actions require an authenticated Warp user unless the implementer deliberately classifies them as logged-out-safe. - The logged-out-safe set should remain meaningfully smaller and limited to local app structure, local appearance metadata, and other surfaces that do not depend on the user's cloud-backed Warp identity. - Actions that read or mutate Warp Drive, AI conversation traces, synced settings, team/account data, or other user-authenticated state must require an authenticated user. -- Actions that can execute user-authored cloud-backed content, such as running Warp Drive workflows or inserting notebook commands, require both the authenticated-user grant and the appropriate high-risk action category. +- Future actions that can execute user-authored cloud-backed content, such as running Warp Drive workflows or submitting notebook commands, require both the authenticated-user grant and the appropriate high-risk action category. These execution actions are excluded from the initial public implementation. When an authenticated-user action is requested: - the selected app must have an active logged-in Warp user; - the presented local-control credential must include an `authenticated_user` grant for that user or stable subject; @@ -362,14 +372,15 @@ Examples: - theme, font, zoom, keybinding, and allowlisted settings writes. This category should not authorize terminal command execution, file writes, or Warp Drive CRUD. ### Underlying data mutations -Can change user data, execute code, or cause external side effects. +Can change user data, execute code, submit prompts, or cause external side effects. Examples: -- command execution in a session; -- executing Warp Drive workflows or other user-authored runnable content; +- future command execution in a session; +- future agent prompt submission or acceptance of an agent-proposed command; +- future execution of Warp Drive workflows or other user-authored runnable content; - file create/write/append/delete operations; - Warp Drive object create/update/trash/restore/permanent-delete operations; - AI conversation history mutation or other cloud-backed content mutation. -This category should require explicit user or policy approval for unattended automation and integrations. It must remain separate from app-state mutation so a client that can open or focus Warp UI cannot automatically execute commands, write files, or mutate Warp Drive content. +This category should require explicit user or policy approval for unattended automation and integrations. It must remain separate from app-state mutation so a client that can open or focus Warp UI cannot automatically execute commands, submit prompts, write files, or mutate Warp Drive content. Terminal command execution, workflow execution, accepted-command submission, and agent-prompt submission must remain unavailable in the initial public implementation even if the protocol has future reserved action names for them. ## Target scoping and deterministic resolution Targeting is part of security. The protocol must not convert ambiguous or stale selectors into best-effort mutations. Rules: @@ -418,7 +429,8 @@ Recommended audit fields: Avoid logging: - bearer tokens or scoped credentials; - terminal output; -- command text for command execution unless explicitly approved by policy; +- command text for command execution unless explicitly approved by policy in a future version that supports execution; +- agent prompt text; - input buffer contents; - Warp Drive object contents; - environment variable values. @@ -431,6 +443,7 @@ Important errors include: - `insufficient_permissions` for valid credentials that lack the requested permission category or target scope; - `authenticated_user_required` when an action requires a logged-in Warp user but the credential lacks an authenticated-user grant; - `authenticated_user_unavailable` when the selected Warp app has no logged-in Warp user or cannot access the required authenticated user state; +- `authenticated_user_mismatch` when an authenticated-user credential is bound to a different user subject than the user currently logged in to the selected Warp app; - `execution_context_not_allowed` when the action or requested grant is not allowed from the verified invocation context, such as an external client attempting an in-Warp-only authenticated-user action; - `ambiguous_instance` when multiple compatible instances cannot be resolved safely; - `invalid_selector` for malformed or unsupported selector syntax; @@ -458,6 +471,7 @@ Before shipping each action family, verify that these controls are implemented f - Tests cover allowed, insufficient-permission, and denied credential paths. - Logs and errors do not expose credentials, terminal contents, command text, or sensitive settings. - Operator docs distinguish available commands from planned catalog entries. +- Initial public action-family docs and tests prove terminal command execution, workflow execution, accepted-command submission, and agent-prompt submission are not allowlisted; input-buffer staging never submits the buffer. ## Platform requirements ### macOS and Linux Discovery files must be stored in a per-user directory with owner-only permissions. From 6286aa1029c0b8f95071529bb24e3a4b7651055b Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Sat, 23 May 2026 12:17:43 -0600 Subject: [PATCH 14/48] Add WarpCtrlBehavior review gate to warpctrl tech spec Co-Authored-By: Oz <oz-agent@warp.dev> --- specs/warp-control-cli/TECH.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index 11892d36e2..1d99deb401 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -242,6 +242,21 @@ Recommended modules/families: - Panels/surfaces: - settings/page/search, palettes, left/right panels, Drive, resource center, code review, vertical tabs, AI assistant. Do not use a generic “dispatch action by string” endpoint. Every handler should be reachable only through an explicit `ControlAction` variant. +#### WarpCtrlBehavior review gate +The public `ControlAction` catalog remains the source of truth for the wire protocol, CLI parser, permission metadata, generated documentation, and app bridge handlers. Internal app actions such as `WorkspaceAction`, `TerminalAction`, `PaneGroupAction`, settings actions, and future user-visible action enums must not become the public protocol directly because they can contain transient view locators, indices, debug-only variants, implementation-specific payloads, and unstable internal semantics. +To prevent drift between user-visible Warp behavior and the `warpctrl` catalog, every user-visible app action enum should implement a `WarpCtrlBehavior` review mapping. The mapping is a code-level forcing function, not an automatic exposure mechanism. It answers whether each internal app action is: +- `Exposed` through a specific public `ControlAction` kind. +- `CoveredBy` an existing public `ControlAction` kind because several internal actions map to one stable CLI behavior. +- `Excluded` with an explicit reason such as debug-only, unsafe/privileged, internal implementation detail, not user-visible, no deterministic targeting model, no stable public semantics, or prohibited in the initial public version. +- `Deferred` with an explicit reason and tracking issue when the action might belong in `warpctrl` later but needs additional product, security, selector, or protocol design. +`WarpCtrlBehavior` implementations must use exhaustive matches without wildcard arms. Adding a new variant to a reviewed action enum should fail compilation until the developer or agent deliberately classifies its relationship to `warpctrl`. This mirrors the existing exhaustive-action-review style used by app-state saving decisions and makes “should this exist in Warp Control?” part of the ordinary code path for adding new user-visible actions. +Recommended shape: +- Define a shared `WarpCtrlBehavior` trait in the local-control integration layer or another app-visible module that does not force the core `warpui::Action` blanket implementation to change. +- Define review enums such as `WarpCtrlActionReview`, `WarpCtrlExclusionReason`, and `WarpCtrlDeferredReason`. +- Implement `WarpCtrlBehavior` for the major user-visible action enums, starting with `WorkspaceAction` and `TerminalAction`. +- Keep the mapping one-way from internal behavior to public catalog metadata. `WarpCtrlBehavior::Exposed(ControlActionKind::TabCreate)` means the action is represented by the public `tab.create` command; it does not mean raw `WorkspaceAction::AddTerminalTab` is serializable or dispatchable over the protocol. +- Add tests that collect reviewed action kinds and verify every `ControlActionKind` has protocol metadata, permission metadata, CLI parser coverage, generated-doc coverage, and an app-side handler before it can be advertised as supported. +The `warpui::Action` trait should not be extended for this purpose because it currently has a blanket implementation for any `Any + Debug + Send + Sync` type. The enforcement point is the concrete user-visible action enums and binding/action registration surfaces, where exhaustive review can be required without weakening the allowlisted protocol boundary. ### 7. First slice: prove discovery and `tab.create` The first `warpctrl` implementation slice should land the minimum cross-cutting architecture plus a single representative tab mutation: - Shared protocol types and error envelopes. @@ -267,6 +282,7 @@ The PR should also introduce the shell-facing CLI command grammar that the remai ### 8. Follow-up slices: fill out the remaining protocol in parallel After the first slice validates discovery, auth, selector resolution, CLI syntax, and server-to-app execution, follow-up slices can add the remaining allowlisted catalog in parallelized action-family groups. The baseline code should make new action additions mostly additive: - Extend `ControlAction`. +- Update the relevant `WarpCtrlBehavior` mappings for the internal app actions that implement, overlap with, exclude, or defer the behavior. - Add typed params/results. - Add a handler. - Add validation/tests. @@ -437,7 +453,7 @@ Map tests directly to `PRODUCT.md` behavior. - Startup-path tests or focused checks confirming `warpctrl` dispatches commands without entering GUI-app launch code. - Shell completions/help output checks once final command naming is selected. ### Computer-use CLI verification -Before any stacked PR is considered ready for review, run an end-to-end computer-use verification pass against a Claude-built validation artifact from that branch. The validation artifact is a Warp app build and standalone `warpctrl` binary built by the validation agent from the exact commit under review, with `warp_control_cli` compiled in and `FeatureFlag::WarpControlCli` enabled at runtime. +Before any stacked PR is considered ready for review, run an end-to-end computer-use verification pass against a cloud built validation artifact from that branch. The validation artifact is a Warp app build and standalone `warpctrl` binary built by the validation agent from the exact commit under review, with `warp_control_cli` compiled in and `FeatureFlag::WarpControlCli` enabled at runtime. The verifier must launch the built Warp app, enable the Scripting settings needed for the command category under test, and capture screenshots or screen recordings that prove the user-visible result of each basic command family. Screenshots should be saved as durable artifacts and named by branch, invocation context, command family, and command name. The verifier must exercise both invocation contexts: - **Inside Warp terminal:** run `warpctrl` from a terminal session inside the feature-flag-enabled Warp app. This path must prove the app-issued Warp-terminal execution-context proof is accepted and that inside-Warp settings gate the command categories. @@ -496,7 +512,7 @@ flowchart LR - Implementation drift from `SECURITY.md`: - Mitigation: treat `SECURITY.md` as normative for security behavior; update this technical plan before implementation when there is disagreement, and include tests for the security architecture in the first slice. - Action catalog drift from real UI behavior: - - Mitigation: each control action reuses or factors existing UI action paths rather than duplicating behavior. + - Mitigation: each control action reuses or factors existing UI action paths rather than duplicating behavior, and user-visible app action enums implement exhaustive `WarpCtrlBehavior` mappings so new internal actions cannot be added without an explicit expose/cover/exclude/defer decision. - Leaking internal unstable identifiers: - Mitigation: public protocol exposes opaque IDs and selectors; internal runtime IDs stay implementation details. - Over-broad settings mutation: From c4fde66706f817be095ebe131c443b269754b5fe Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Sat, 23 May 2026 12:34:06 -0600 Subject: [PATCH 15/48] Update warpctrl tech plan for reviewable branch stack Co-Authored-By: Oz <oz-agent@warp.dev> --- specs/warp-control-cli/TECH.md | 174 +++++++++++++++++++-------------- 1 file changed, 98 insertions(+), 76 deletions(-) diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index 1d99deb401..b22124f454 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -315,22 +315,58 @@ Naming decision: ## Implementation Plan ### Branch stack Use raw git for the stack; do not use Graphite for these branches. -The intended stack is: +The durable review stack should optimize for reviewability rather than mirroring only broad product phases. The intended stack is: 1. `zach/warp-cli-specs` — spec-only branch. This branch owns `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs. It should not contain implementation changes. -2. `zach/warp-cli` — first implementation branch. This stays as the core scaffolding slice: protocol crate, discovery/auth scaffolding, Scripting settings surface, local-control server/bridge, standalone `warpctrl` binary, packaging hooks, and only the single `warpctrl tab create` mutation needed to prove the end-to-end path. -3. `zach/warp-cli-readonly` — create this branch directly from `zach/warp-cli`. It implements the read-only command set from `PRODUCT.md` without adding additional mutations beyond the existing `tab create` proof command. -4. `zach/warp-cli-read-write` — create this branch directly from `zach/warp-cli-readonly`. It implements the mutating command set from `PRODUCT.md` after the read-only branch has established selectors, metadata result shapes, and inspection APIs. -Spec changes are an important part of the stacking strategy. All spec changes must originate on `zach/warp-cli-specs`, which is the authoritative source for `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs. After a spec change lands there, propagate it upward through every stacked branch with raw git so the spec files stay synchronized across `zach/warp-cli`, `zach/warp-cli-readonly`, and `zach/warp-cli-read-write`. Do not make independent spec edits directly on the implementation branches except as part of resolving a propagation conflict in a way that preserves the authoritative specs-branch content. -Recommended raw-git setup: +2. `zach/warp-cli-core-foundation` — create this branch from `zach/warp-cli-specs`. It owns the shared implementation foundation: protocol crate shape, discovery/auth scaffolding, selector and error types, action metadata/catalog structure, Scripting settings surface, protected local-control settings, local-control server/bridge skeleton, standalone `warpctrl` binary skeleton, packaging hooks, module split, `WarpCtrlBehavior` scaffolding, and the minimum `tab.create` smoke path if needed to prove the end-to-end architecture. +3. `zach/warp-cli-readonly-metadata` — create this branch from `zach/warp-cli-core-foundation`. It implements structural metadata reads: instance/app health and active-chain commands, windows, tabs, panes, sessions, capability/action metadata, opaque IDs, metadata target shapes, and metadata-read permission enforcement. It must not expose terminal output, input buffers, history, file contents, Drive object contents, or other underlying user data. +4. `zach/warp-cli-readonly-data-settings` — create this branch from `zach/warp-cli-readonly-metadata`. It implements underlying-data reads and read-only settings/appearance/docs: block listing/inspection/output, input-buffer reads, history reads, theme/settings/keybinding/action reads, project/file app-state reads, authenticated Drive metadata/content reads where present, read-only docs, and the read-only `warpctrl` Agent skill. +5. `zach/warp-cli-mutating-layout` — create this branch from `zach/warp-cli-readonly-data-settings`. It implements layout and app-state mutations for app/window/tab/pane behavior: window create/focus/close, tab create/activate/move/close, tab metadata that belongs with layout review if appropriate, pane split/focus/navigate/resize/maximize/close, app-state mutation permission checks, and layout mutation tests. +6. `zach/warp-cli-mutating-input-settings-surfaces` — create this branch from `zach/warp-cli-mutating-layout`. It implements the remaining approved mutating command families: session activation/cycling/reopen, input insert/replace/clear, input mode switching, theme/system-theme/font/zoom changes, allowlisted setting set/toggle, settings/palette/search/panel/surface commands, file/project/Drive open commands that are app-state-only, mutating docs, and the mutating-command Agent skill updates. It must preserve the initial public prohibition on terminal command execution, workflow execution, accepted-command submission, and agent-prompt submission. +The goal is to keep durable review branches close to roughly 2,000 lines of incremental changes where practical while avoiding a one-branch-per-command maintenance burden. Product phases still matter, but they are not the primary PR boundary. The durable branches are the review spine; short-lived shard branches can feed into them during implementation. +Spec changes are an important part of the stacking strategy. All spec changes must originate on `zach/warp-cli-specs`, which is the authoritative source for `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs. After a spec change lands there, propagate it upward through every stacked branch with raw git so the spec files stay synchronized across `zach/warp-cli-core-foundation`, `zach/warp-cli-readonly-metadata`, `zach/warp-cli-readonly-data-settings`, `zach/warp-cli-mutating-layout`, and `zach/warp-cli-mutating-input-settings-surfaces`. Do not make independent spec edits directly on the implementation branches except as part of resolving a propagation conflict in a way that preserves the authoritative specs-branch content. +Recommended raw-git setup after `zach/warp-cli-specs` is ready: ```bash git fetch origin -git checkout zach/warp-cli -git checkout -b zach/warp-cli-readonly -git push -u origin zach/warp-cli-readonly -git checkout -b zach/warp-cli-read-write -git push -u origin zach/warp-cli-read-write +git checkout zach/warp-cli-specs +git checkout -b zach/warp-cli-core-foundation +git checkout -b zach/warp-cli-readonly-metadata +git checkout -b zach/warp-cli-readonly-data-settings +git checkout -b zach/warp-cli-mutating-layout +git checkout -b zach/warp-cli-mutating-input-settings-surfaces ``` -If `zach/warp-cli-readonly` changes after `zach/warp-cli-read-write` exists, rebase the read-write branch onto the updated read-only branch with raw git (`git checkout zach/warp-cli-read-write && git rebase zach/warp-cli-readonly`) and resolve conflicts by preserving both read-only API shape and mutating handlers. +If a lower branch changes after higher branches exist, rebase each higher branch onto its immediate lower branch with raw git and resolve conflicts by preserving both the lower branch's stable API/permission model and the higher branch's owned behavior. +### Migrating from the earlier four-branch stack +The earlier four-branch stack (`zach/warp-cli-specs`, `zach/warp-cli`, `zach/warp-cli-readonly`, `zach/warp-cli-read-write`) should be treated as source material for the six-branch review stack, not as the final review structure. +Recommended migration: +1. Create backup refs before rewriting or replacing anything: + - `backup/warp-cli-specs` from `zach/warp-cli-specs`. + - `backup/warp-cli` from `zach/warp-cli`. + - `backup/warp-cli-readonly` from `zach/warp-cli-readonly`. + - `backup/warp-cli-read-write` from `zach/warp-cli-read-write`. +2. Keep `zach/warp-cli-specs` as the bottom spec-only branch after rebasing it onto latest `origin/master`. +3. Create `zach/warp-cli-core-foundation` from `zach/warp-cli-specs` and bring over only the foundation pieces from `zach/warp-cli`. Prefer path-level checkout followed by selective editing or `git add -p`; do not preserve every old commit if that makes review boundaries worse. +4. Create `zach/warp-cli-readonly-metadata` from `zach/warp-cli-core-foundation` and bring over only metadata-read pieces from `zach/warp-cli-readonly`. +5. Create `zach/warp-cli-readonly-data-settings` from `zach/warp-cli-readonly-metadata` and bring over the remaining read-only underlying-data, settings, docs, and skill pieces from `zach/warp-cli-readonly`. +6. Create `zach/warp-cli-mutating-layout` from `zach/warp-cli-readonly-data-settings` and bring over only layout/app-state mutations from `zach/warp-cli-read-write`. +7. Create `zach/warp-cli-mutating-input-settings-surfaces` from `zach/warp-cli-mutating-layout` and bring over the remaining approved mutating input/session/settings/surface pieces from `zach/warp-cli-read-write`. +8. Recompute incremental diff sizes, validate compilation/tests for each branch, push the new stack, and retire or close the old broad branches once the new review stack is accepted. +Before redistributing feature work, prefer landing a mechanical module-split commit in `zach/warp-cli-core-foundation` so later branches do not all expand the same large files. The app-side target should be: +- `app/src/local_control/mod.rs` for registration and top-level exports. +- `app/src/local_control/bridge.rs` for the app request bridge. +- `app/src/local_control/resolver.rs` for target resolution. +- `app/src/local_control/permissions.rs` for app-side permission/auth checks. +- `app/src/local_control/handlers/metadata.rs`. +- `app/src/local_control/handlers/data.rs`. +- `app/src/local_control/handlers/layout.rs`. +- `app/src/local_control/handlers/input.rs`. +- `app/src/local_control/handlers/settings_surfaces.rs`. +Likewise, split CLI and protocol code if they become review bottlenecks: +- `crates/warp_cli/src/local_control/mod.rs`. +- `crates/warp_cli/src/local_control/selectors.rs`. +- `crates/warp_cli/src/local_control/output.rs`. +- `crates/warp_cli/src/local_control/commands/{metadata,data,layout,input,settings_surfaces}.rs`. +- `crates/local_control/src/{protocol,catalog,selectors}.rs`. +- `crates/local_control/src/actions/{metadata,data,layout,input,settings_surfaces}.rs`. ### Feature flag and rollout gate The entire feature should be gated behind a Warp feature flag, proposed as `FeatureFlag::WarpControlCli` with Cargo feature `warp_control_cli`. Implementation should follow the existing runtime feature-flag conventions: @@ -348,44 +384,14 @@ When `FeatureFlag::WarpControlCli` is disabled in the Warp app: - command-palette/keybinding entries related specifically to installing, configuring, or using `warpctrl` should be hidden; - tests should cover both enabled and disabled flag states with the repo's normal feature-flag override helpers. The standalone `warpctrl` binary can still exist in a build where the app feature is disabled, but it should find no compatible enabled app instance and should return a structured no-instance or feature-disabled error rather than relying on hidden server state. -### Read-only branch sharding with Oz cloud agents -Use Oz cloud agents to shard `zach/warp-cli-readonly` by API family. Each agent should work from `zach/warp-cli-readonly` or a short-lived shard branch based on it, push a branch or return a patch, and report changed files plus validation results. A lead integrator merges/cherry-picks accepted shard work into `zach/warp-cli-readonly` using raw git. -Suggested shards: -- `readonly-protocol-cli` owns `crates/local_control` action variants, typed read-only params/results, CLI parser/output for read-only commands, and serde tests. -- `readonly-app-targets` owns app-side handlers and target resolvers for `app`, `window`, `tab`, `pane`, and `session` structural metadata. -- `readonly-underlying-data` owns read-only underlying-data commands such as block listing/output, input-buffer reads, history reads, file content reads if added, and Drive object content reads, with extra tests that content is denied without underlying-data-read permission. -- `readonly-settings-appearance` owns theme, appearance, settings, keybinding, and action/capability inspection commands. -- `readonly-files-drive-skill-docs` owns app-state file/project reads, authenticated Warp Drive read-only commands, operator docs, and the first version of the built-in `warpctrl` Agent skill. -Read-only branch acceptance criteria: -- all read-only commands in `PRODUCT.md` parse and serialize stable request envelopes; -- structural metadata reads return opaque protocol IDs and do not expose terminal, file, Drive object, or AI conversation content; -- underlying-data reads require the separate underlying-data-read grant; -- authenticated Warp Drive metadata reads require a logged-in user and authenticated-user grant, and object-content reads additionally require the underlying-data-read grant; -- disabled feature flag state exposes no settings, discovery records, or endpoints; -- `cargo nextest run --no-fail-fast --workspace <relevant tests>` and targeted `cargo check` pass for the changed crates. -### Read-write branch sharding with Oz cloud agents -Start `zach/warp-cli-read-write` only after the read-only branch has a coherent target-resolution and result-shape baseline. Each mutating shard should add action metadata, typed params/results, CLI parser surface, app bridge handlers, permission checks, and tests for allowed and denied paths. -Suggested shards: -- `mutate-window-tab-pane` owns window creation/focus/close, tab create/activate/move/rename/color/close, and pane split/focus/navigate/resize/maximize/rename/close. -- `mutate-input-session` owns session activation/cycling/reopen, input insert/replace/clear, and terminal-vs-agent input mode changes as app-state mutations. It may define `input run`, but execution must be classified as an underlying data mutation and can be handed to `mutate-underlying-data` if that keeps review cleaner. -- `mutate-metadata-config` owns theme selection, system theme toggles, font/zoom controls, tab/pane metadata updates, and allowlisted setting set/toggle commands as metadata/configuration mutations. -- `mutate-surfaces-files-drive` owns settings/palette/search/panel surface commands and file/project/Drive open commands as app-state mutations. -- `mutate-underlying-data` owns terminal command execution, file create/write/append/delete commands, Warp Drive workflow execution, and Warp Drive object CRUD. This shard must require the underlying-data-mutation permission and authenticated-user grants where applicable. -- `mutate-tests-docs-skill` owns cross-family integration tests, command help/shell completion checks, README updates, and updates to the built-in `warpctrl` Agent skill once the mutating command surface stabilizes. -Read-write branch acceptance criteria: -- every mutating command in `PRODUCT.md` has an explicit state/data category, required permission category, and authenticated-user classification; -- app-state mutations, metadata/configuration mutations, and underlying-data mutations require distinct permissions; -- command execution, file writes, Warp Drive CRUD, and other underlying-data mutations require the underlying-data-mutation permission, not merely read-write/app-state permission; -- selector resolution happens after auth and permission checks and never silently retargets stale explicit selectors; -- all mutating handlers reuse existing user-visible app behavior rather than duplicating business logic; -- disabled feature flag state continues to hide settings and withhold endpoints; -- the branch can be reviewed as a stacked PR whose base is `zach/warp-cli-readonly`. ### Merge and review strategy Keep PR boundaries aligned with the stack: -- PR1: `zach/warp-cli` into `master` for core scaffolding plus `tab create` only. -- PR2: `zach/warp-cli-readonly` into `zach/warp-cli` or the merged successor of PR1 for read-only command expansion. -- PR3: `zach/warp-cli-read-write` into `zach/warp-cli-readonly` or the merged successor of PR2 for mutating command expansion. -If PR1 merges before PR2 is ready, rebase `zach/warp-cli-readonly` onto the new `master`. If PR2 merges before PR3 is ready, rebase `zach/warp-cli-read-write` onto the new `master` or onto the updated read-only branch, depending on the active review base. Use raw git for all rebases, conflict resolution, and pushes. +- PR1: `zach/warp-cli-core-foundation` into `master` or the merged successor of `zach/warp-cli-specs` for shared protocol, CLI, settings, bridge, and module scaffolding. +- PR2: `zach/warp-cli-readonly-metadata` into `zach/warp-cli-core-foundation` or its merged successor for metadata reads. +- PR3: `zach/warp-cli-readonly-data-settings` into `zach/warp-cli-readonly-metadata` or its merged successor for underlying-data reads, settings reads, docs, and skill updates. +- PR4: `zach/warp-cli-mutating-layout` into `zach/warp-cli-readonly-data-settings` or its merged successor for app/window/tab/pane layout mutations. +- PR5: `zach/warp-cli-mutating-input-settings-surfaces` into `zach/warp-cli-mutating-layout` or its merged successor for input/session/settings/surface mutations. +If a lower PR merges before higher branches are ready, rebase the next branch onto the merged successor of its base and continue upward through the stack. Use raw git for all rebases, conflict resolution, cherry-picks, and pushes. ## End-to-end flow ```mermaid sequenceDiagram @@ -467,36 +473,52 @@ The verification matrix should cover every implemented command in `PRODUCT.md` a - authenticated-user commands show both the logged-out or missing-grant denial path and the enabled authenticated path when a test account/environment is available. The verifier should produce a verification manifest checked into or attached to the PR artifacts. The manifest should list each command, branch under test, invocation context, required permission category, expected result, actual result, screenshot artifact path, and any skipped case with a reason. Missing screenshots for visible commands block review readiness. ## Parallelization -The first slice on `zach/warp-cli` should stay mostly sequential because protocol envelope, discovery, authentication, feature-flag gating, selector resolution, and `tab.create` are tightly coupled and need one coherent architecture. -The read-only and read-write follow-up branches are strong fits for Oz cloud-agent fan-out, but the fan-out should happen inside the stacked branch strategy from `## Implementation Plan`, not as unrelated sibling feature branches. -Read-only fan-out: -- Launch agents against `zach/warp-cli-readonly` or short-lived shard branches based on it. -- Use the shard names from `### Read-only branch sharding with Oz cloud agents`: `readonly-protocol-cli`, `readonly-app-targets`, `readonly-underlying-data`, `readonly-settings-appearance`, and `readonly-files-drive-skill-docs`. -- The lead integrator merges accepted work into `zach/warp-cli-readonly` with raw git, then validates the full read-only API before the branch is used as the base for mutations. -Read-write fan-out: -- Launch agents against `zach/warp-cli-read-write` only after read-only target resolution and result shapes are stable. -- Use the shard names from `### Read-write branch sharding with Oz cloud agents`: `mutate-window-tab-pane`, `mutate-input-session`, `mutate-metadata-config`, `mutate-surfaces-files-drive`, `mutate-underlying-data`, and `mutate-tests-docs-skill`. -- The lead integrator merges accepted work into `zach/warp-cli-read-write` with raw git and keeps the branch rebased on the current read-only base. -Merge strategy: -- One stacked PR per durable branch: `zach/warp-cli`, then `zach/warp-cli-readonly`, then `zach/warp-cli-read-write`. -- Shard branches should not become independent long-lived PRs unless the lead intentionally splits review; their default purpose is to feed the durable stacked branch. -- Use raw git for branch creation, merges, cherry-picks, rebases, conflict resolution, and pushes. Do not use Graphite commands. +The durable review stack should remain linear, but implementation can still happen in parallel with Oz cloud agents. The pattern is contract-first fan-out: land the shared contracts and module boundaries in `zach/warp-cli-core-foundation`, then let cloud agents work on short-lived shard branches that feed the durable review branches. +Wave 0: foundation: +- Keep `zach/warp-cli-core-foundation` mostly sequential or use at most one or two tightly scoped agents because protocol envelope, discovery, authentication, feature-flag gating, selector resolution, module boundaries, and `tab.create` smoke behavior need one coherent architecture. +- Acceptable foundation shards are `core-protocol-cli` for shared protocol/CLI skeleton and `core-app-foundation` for settings, bridge, resolver, permissions, and handler skeletons. These shards should merge into the single durable `zach/warp-cli-core-foundation` branch before feature fan-out begins. +Wave 1: read-only fan-out: +- Launch short-lived Oz cloud shard branches from `zach/warp-cli-core-foundation` once the contracts compile. +- Suggested shards: + - `zach/warp-cli-shard/readonly-metadata` owns structural metadata commands and feeds `zach/warp-cli-readonly-metadata`. + - `zach/warp-cli-shard/readonly-data` owns block output, input-buffer reads, history reads, and other underlying-data reads, then feeds `zach/warp-cli-readonly-data-settings`. + - `zach/warp-cli-shard/readonly-settings-docs` owns theme/settings/keybinding/action reads, docs, and read-only skill updates, then feeds `zach/warp-cli-readonly-data-settings`. +Wave 2: mutating fan-out: +- Launch mutating shards only after read-only target resolution and result shapes are stable. +- Suggested shards: + - `zach/warp-cli-shard/mutating-window-tab-pane` owns window/tab/pane layout mutations and feeds `zach/warp-cli-mutating-layout`. + - `zach/warp-cli-shard/mutating-input-session` owns session activation/cycling/reopen, input insert/replace/clear, and input mode switching, then feeds `zach/warp-cli-mutating-input-settings-surfaces`. + - `zach/warp-cli-shard/mutating-settings-surfaces` owns theme/font/zoom/setting mutations and settings/palette/panel/surface commands, then feeds `zach/warp-cli-mutating-input-settings-surfaces`. + - `zach/warp-cli-shard/mutating-files-drive` is optional and should be deferred unless the approved scope includes file/Drive app-state opens or future underlying-data mutations. +Each cloud shard prompt should include: +- The exact base branch and shard branch name. +- Owned command families. +- Owned files/modules. +- Files/modules the shard must not edit without calling out the need for integration. +- Required permission categories and authenticated-user behavior. +- Selector resolution requirements. +- Validation commands and expected tests. +- A handoff requirement: report branch name, changed files, implemented commands, permission decisions, validation results, and any conflicts or follow-ups. +Default file ownership for shards: +- Metadata shards own metadata handler/protocol/CLI modules and metadata tests. +- Data shards own data handler/protocol/CLI modules and underlying-data permission tests. +- Layout shards own layout handler/protocol/CLI modules and app-state mutation tests. +- Input/session shards own input/session handler/protocol/CLI modules and tests proving staging does not submit or execute. +- Settings/surface shards own settings/surface handler/protocol/CLI modules and metadata/configuration mutation tests. +The lead integrator merges or cherry-picks accepted shard work into the durable stack with raw git, in review order. Shard branches should not become independent long-lived PRs unless the lead intentionally splits review further; their default purpose is to feed the durable stack while preserving parallel implementation and focused context windows. ```mermaid flowchart LR - Specs["zach/warp-cli-specs<br/>spec-only"] --> Core["zach/warp-cli<br/>core + tab.create"] - Core --> RO["zach/warp-cli-readonly<br/>read-only API"] - RO --> RW["zach/warp-cli-read-write<br/>mutating API"] - ROShardA["readonly-protocol-cli"] --> RO - ROShardB["readonly-app-targets"] --> RO - ROShardC["readonly-underlying-data"] --> RO - ROShardD["readonly-settings-appearance"] --> RO - ROShardE["readonly-files-drive-skill-docs"] --> RO - RWShardA["mutate-window-tab-pane"] --> RW - RWShardB["mutate-input-session"] --> RW - RWShardC["mutate-metadata-config"] --> RW - RWShardD["mutate-surfaces-files-drive"] --> RW - RWShardE["mutate-underlying-data"] --> RW - RWShardF["mutate-tests-docs-skill"] --> RW + Specs["zach/warp-cli-specs<br/>spec-only"] --> Core["zach/warp-cli-core-foundation<br/>contracts + bridge"] + Core --> ROMeta["zach/warp-cli-readonly-metadata<br/>structural reads"] + ROMeta --> ROData["zach/warp-cli-readonly-data-settings<br/>data/settings reads"] + ROData --> MutLayout["zach/warp-cli-mutating-layout<br/>layout mutations"] + MutLayout --> MutInput["zach/warp-cli-mutating-input-settings-surfaces<br/>input/settings/surfaces"] + ROMetaShard["shard/readonly-metadata"] --> ROMeta + RODataShard["shard/readonly-data"] --> ROData + ROSettingsShard["shard/readonly-settings-docs"] --> ROData + MutLayoutShard["shard/mutating-window-tab-pane"] --> MutLayout + MutInputShard["shard/mutating-input-session"] --> MutInput + MutSettingsShard["shard/mutating-settings-surfaces"] --> MutInput ``` ## Risks and mitigations - Fixed-port server assumptions: From cc48d49d1c1a865e2d741cf7a806d397d07906fe Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Sat, 23 May 2026 13:05:55 -0600 Subject: [PATCH 16/48] Create warpctrl core foundation branch Co-Authored-By: Oz <oz-agent@warp.dev> --- Cargo.lock | 19 + Cargo.toml | 1 + app/Cargo.toml | 6 + app/src/ai/agent_sdk/mod.rs | 1 + app/src/bin/warpctrl.rs | 6 + app/src/features.rs | 2 + app/src/lib.rs | 11 + app/src/local_control/bridge.rs | 115 ++++ app/src/local_control/handlers.rs | 2 + app/src/local_control/handlers/layout.rs | 55 ++ app/src/local_control/handlers/metadata.rs | 36 ++ app/src/local_control/mod.rs | 369 +++++++++++++ app/src/local_control/mod_tests.rs | 198 +++++++ app/src/local_control/permissions.rs | 115 ++++ app/src/local_control/resolver.rs | 79 +++ app/src/settings/init.rs | 7 +- app/src/settings/local_control.rs | 125 +++++ app/src/settings/local_control_tests.rs | 94 ++++ app/src/settings/mod.rs | 2 + app/src/settings_view/mod.rs | 32 ++ app/src/settings_view/scripting_page.rs | 353 ++++++++++++ app/src/settings_view/settings_page.rs | 3 + app/src/test_util/settings.rs | 9 +- crates/local_control/Cargo.toml | 23 + crates/local_control/src/auth.rs | 216 ++++++++ crates/local_control/src/auth_tests.rs | 113 ++++ crates/local_control/src/catalog.rs | 508 ++++++++++++++++++ crates/local_control/src/client.rs | 103 ++++ crates/local_control/src/discovery.rs | 281 ++++++++++ crates/local_control/src/lib.rs | 24 + crates/local_control/src/protocol.rs | 164 ++++++ crates/local_control/src/protocol_tests.rs | 133 +++++ crates/local_control/src/selection.rs | 101 ++++ crates/local_control/src/selectors.rs | 50 ++ crates/warp_cli/Cargo.toml | 3 + crates/warp_cli/src/bin/warpctrl.rs | 6 + crates/warp_cli/src/lib.rs | 1 + crates/warp_cli/src/local_control/commands.rs | 126 +++++ .../warp_cli/src/local_control/completions.rs | 31 ++ crates/warp_cli/src/local_control/mod.rs | 163 ++++++ crates/warp_cli/src/local_control/output.rs | 51 ++ .../warp_cli/src/local_control/selectors.rs | 13 + crates/warp_cli/src/local_control_tests.rs | 110 ++++ crates/warp_features/src/lib.rs | 5 + script/linux/bundle | 15 +- script/macos/bundle | 23 +- 46 files changed, 3882 insertions(+), 21 deletions(-) create mode 100644 app/src/bin/warpctrl.rs create mode 100644 app/src/local_control/bridge.rs create mode 100644 app/src/local_control/handlers.rs create mode 100644 app/src/local_control/handlers/layout.rs create mode 100644 app/src/local_control/handlers/metadata.rs create mode 100644 app/src/local_control/mod.rs create mode 100644 app/src/local_control/mod_tests.rs create mode 100644 app/src/local_control/permissions.rs create mode 100644 app/src/local_control/resolver.rs create mode 100644 app/src/settings/local_control.rs create mode 100644 app/src/settings/local_control_tests.rs create mode 100644 app/src/settings_view/scripting_page.rs create mode 100644 crates/local_control/Cargo.toml create mode 100644 crates/local_control/src/auth.rs create mode 100644 crates/local_control/src/auth_tests.rs create mode 100644 crates/local_control/src/catalog.rs create mode 100644 crates/local_control/src/client.rs create mode 100644 crates/local_control/src/discovery.rs create mode 100644 crates/local_control/src/lib.rs create mode 100644 crates/local_control/src/protocol.rs create mode 100644 crates/local_control/src/protocol_tests.rs create mode 100644 crates/local_control/src/selection.rs create mode 100644 crates/local_control/src/selectors.rs create mode 100644 crates/warp_cli/src/bin/warpctrl.rs create mode 100644 crates/warp_cli/src/local_control/commands.rs create mode 100644 crates/warp_cli/src/local_control/completions.rs create mode 100644 crates/warp_cli/src/local_control/mod.rs create mode 100644 crates/warp_cli/src/local_control/output.rs create mode 100644 crates/warp_cli/src/local_control/selectors.rs create mode 100644 crates/warp_cli/src/local_control_tests.rs diff --git a/Cargo.lock b/Cargo.lock index f5cefafaeb..b6d519d0b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7453,6 +7453,22 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" +[[package]] +name = "local_control" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "chrono", + "libc", + "rand 0.8.6", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.17", + "uuid", +] + [[package]] name = "locale_config" version = "0.3.0" @@ -14414,6 +14430,7 @@ dependencies = [ "libc", "libsqlite3-sys", "line-ending", + "local_control", "log", "log-panics", "lsp", @@ -14644,7 +14661,9 @@ dependencies = [ "color-print", "humantime", "jaq-all", + "local_control", "serde", + "serde_json", "serial_test", "url", "uuid", diff --git a/Cargo.toml b/Cargo.toml index a5e992e8c3..5cd0ae37c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ integration = { path = "crates/integration" } ipc = { path = "crates/ipc" } jsonrpc = { path = "crates/jsonrpc" } languages = { path = "crates/languages" } +local_control = { path = "crates/local_control" } lsp = { path = "crates/lsp" } markdown_parser = { path = "crates/markdown_parser" } mcp = { path = "crates/mcp" } diff --git a/app/Cargo.toml b/app/Cargo.toml index a1ac9bce51..2bd0091aa2 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -26,6 +26,10 @@ test = false name = "warp" path = "src/bin/local.rs" test = false +[[bin]] +name = "warpctrl" +path = "src/bin/warpctrl.rs" +test = false [[bin]] name = "integration" @@ -259,6 +263,7 @@ rmcp = { workspace = true, features = ["client"] } warp_isolation_platform.workspace = true warp_ripgrep.workspace = true warp_managed_secrets.workspace = true +local_control.workspace = true [target.'cfg(target_os = "macos")'.dependencies] block.workspace = true @@ -950,6 +955,7 @@ vertical_tabs = [] vertical_tabs_summary_mode = [] tab_configs = [] grouped_tabs = [] +warp_control_cli = [] agent_harness = [] oz_handoff = [] handoff_local_cloud = [] diff --git a/app/src/ai/agent_sdk/mod.rs b/app/src/ai/agent_sdk/mod.rs index 4df462a952..e4b301437b 100644 --- a/app/src/ai/agent_sdk/mod.rs +++ b/app/src/ai/agent_sdk/mod.rs @@ -88,6 +88,7 @@ mod harness_support; mod integration; #[cfg(not(target_family = "wasm"))] mod integration_output; + mod mcp; mod mcp_config; mod model; diff --git a/app/src/bin/warpctrl.rs b/app/src/bin/warpctrl.rs new file mode 100644 index 0000000000..bda8e50f74 --- /dev/null +++ b/app/src/bin/warpctrl.rs @@ -0,0 +1,6 @@ +use std::process::ExitCode; + +fn main() -> ExitCode { + let args = warp_cli::local_control::ControlArgs::from_env(); + warp_cli::local_control::run(args) +} diff --git a/app/src/features.rs b/app/src/features.rs index c27cff3c67..5e632fd4e1 100644 --- a/app/src/features.rs +++ b/app/src/features.rs @@ -447,6 +447,8 @@ fn enabled_features() -> HashSet<FeatureFlag> { FeatureFlag::TabConfigs, #[cfg(feature = "grouped_tabs")] FeatureFlag::GroupedTabs, + #[cfg(feature = "warp_control_cli")] + FeatureFlag::WarpControlCli, #[cfg(feature = "agent_harness")] FeatureFlag::AgentHarness, #[cfg(feature = "oz_handoff")] diff --git a/app/src/lib.rs b/app/src/lib.rs index 4287c7d648..2d63341e21 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -44,6 +44,8 @@ mod gpu_state; mod input_classifier; mod interval_timer; mod linear; +#[cfg(not(target_family = "wasm"))] +mod local_control; #[cfg(any(target_os = "macos", target_os = "windows"))] mod login_item; mod menu; @@ -2005,6 +2007,15 @@ pub(crate) fn initialize_app( ]; http_server::HttpServer::new(routers, ctx) }); + #[cfg(not(target_family = "wasm"))] + if matches!( + launch_mode, + LaunchMode::App { .. } | LaunchMode::Test { .. } + ) && FeatureFlag::WarpControlCli.is_enabled() + { + ctx.add_singleton_model(local_control::LocalControlBridge::new); + ctx.add_singleton_model(local_control::LocalControlServer::new); + } app_state } diff --git a/app/src/local_control/bridge.rs b/app/src/local_control/bridge.rs new file mode 100644 index 0000000000..fe614d0d3b --- /dev/null +++ b/app/src/local_control/bridge.rs @@ -0,0 +1,115 @@ +use ::local_control::auth::CredentialGrant; +use ::local_control::{ + ActionKind, ControlError, ErrorCode, InstanceId, RequestEnvelope, ResponseEnvelope, + PROTOCOL_VERSION, +}; +use warpui::{Entity, ModelContext, SingletonEntity}; + +use crate::local_control::handlers::{layout, metadata}; +use crate::local_control::permissions::{ensure_action_allowed, ensure_feature_enabled}; +use crate::local_control::resolver::validate_action_params; + +pub struct LocalControlBridge { + instance_id: Option<InstanceId>, +} + +impl Entity for LocalControlBridge { + type Event = (); +} + +impl SingletonEntity for LocalControlBridge {} + +impl LocalControlBridge { + pub fn new(_ctx: &mut ModelContext<Self>) -> Self { + Self { instance_id: None } + } + + pub(super) fn set_instance_id(&mut self, instance_id: InstanceId) { + self.instance_id = Some(instance_id); + } + + pub(super) fn handle_request( + &mut self, + request: RequestEnvelope, + grant: CredentialGrant, + ctx: &mut ModelContext<Self>, + ) -> ResponseEnvelope { + if let Err(error) = ensure_feature_enabled() { + return ResponseEnvelope::error(request.request_id, error); + } + if request.protocol_version != PROTOCOL_VERSION { + return ResponseEnvelope::error( + request.request_id, + ControlError::new( + ErrorCode::ProtocolVersionUnsupported, + format!("unsupported protocol version {}", request.protocol_version), + ), + ); + } + if let Err(error) = validate_action_params(&request.action) { + return ResponseEnvelope::error(request.request_id, error); + } + if let Err(error) = grant.verify_for_action(request.action.kind) { + return ResponseEnvelope::error(request.request_id, error); + } + if !request.action.kind.is_implemented() { + return ResponseEnvelope::error( + request.request_id, + ControlError::new( + ErrorCode::UnsupportedAction, + format!( + "{} is not implemented by this local-control bridge", + request.action.kind.as_str() + ), + ), + ); + } + match request.action.kind { + ActionKind::InstanceList => { + if let Err(error) = + ensure_action_allowed(grant.invocation_context, request.action.kind, ctx) + { + return ResponseEnvelope::error(request.request_id, error); + } + ResponseEnvelope::ok(request.request_id, metadata::instance(&self.instance_id)) + } + ActionKind::AppPing => { + if let Err(error) = + ensure_action_allowed(grant.invocation_context, request.action.kind, ctx) + { + return ResponseEnvelope::error(request.request_id, error); + } + ResponseEnvelope::ok(request.request_id, metadata::ping(&self.instance_id)) + } + ActionKind::AppVersion => { + if let Err(error) = + ensure_action_allowed(grant.invocation_context, request.action.kind, ctx) + { + return ResponseEnvelope::error(request.request_id, error); + } + ResponseEnvelope::ok(request.request_id, metadata::version(&self.instance_id)) + } + ActionKind::TabCreate => { + if let Err(error) = + ensure_action_allowed(grant.invocation_context, request.action.kind, ctx) + { + return ResponseEnvelope::error(request.request_id, error); + } + match layout::create_terminal_tab(&self.instance_id, &request.target, ctx) { + Ok(data) => ResponseEnvelope::ok(request.request_id, data), + Err(error) => ResponseEnvelope::error(request.request_id, error), + } + } + action => ResponseEnvelope::error( + request.request_id, + ControlError::new( + ErrorCode::UnsupportedAction, + format!( + "{} is not implemented by this local-control bridge", + action.as_str() + ), + ), + ), + } + } +} diff --git a/app/src/local_control/handlers.rs b/app/src/local_control/handlers.rs new file mode 100644 index 0000000000..be809e75fd --- /dev/null +++ b/app/src/local_control/handlers.rs @@ -0,0 +1,2 @@ +pub(super) mod layout; +pub(super) mod metadata; diff --git a/app/src/local_control/handlers/layout.rs b/app/src/local_control/handlers/layout.rs new file mode 100644 index 0000000000..9749d158b1 --- /dev/null +++ b/app/src/local_control/handlers/layout.rs @@ -0,0 +1,55 @@ +use ::local_control::protocol::TargetSelector; +use ::local_control::{ActionKind, ControlError, ErrorCode, InstanceId}; +use serde_json::json; +use warpui::{ModelContext, TypedActionView}; + +use crate::local_control::resolver::{target_window_id, validate_tab_create_target}; +use crate::local_control::LocalControlBridge; +use crate::workspace::{Workspace, WorkspaceAction}; + +pub(crate) fn create_terminal_tab( + instance_id: &Option<InstanceId>, + target: &TargetSelector, + ctx: &mut ModelContext<LocalControlBridge>, +) -> Result<serde_json::Value, ControlError> { + validate_tab_create_target(target)?; + let window_id = target_window_id(ctx)?; + let workspace = ctx + .views_of_type::<Workspace>(window_id) + .and_then(|workspaces| workspaces.into_iter().next()) + .ok_or_else(|| { + ControlError::new( + ErrorCode::MissingTarget, + "tab.create requires a workspace in the target window", + ) + })?; + let (previous_tab_count, tab_count, active_tab_index) = + workspace.update(ctx, |workspace, ctx| { + let previous_tab_count = workspace.tab_count(); + workspace.handle_action( + &WorkspaceAction::AddTerminalTab { + hide_homepage: false, + }, + ctx, + ); + ( + previous_tab_count, + workspace.tab_count(), + workspace.active_tab_index(), + ) + }); + Ok(json!({ + "action": ActionKind::TabCreate.as_str(), + "created": true, + "instance_id": instance_id.as_ref().map(|id| id.0.as_str()), + "window": { + "selector": "active", + "id": window_id.to_string(), + }, + "tab": { + "previous_count": previous_tab_count, + "count": tab_count, + "active_index": active_tab_index, + }, + })) +} diff --git a/app/src/local_control/handlers/metadata.rs b/app/src/local_control/handlers/metadata.rs new file mode 100644 index 0000000000..d1607483ce --- /dev/null +++ b/app/src/local_control/handlers/metadata.rs @@ -0,0 +1,36 @@ +use ::local_control::{ActionKind, InstanceId, PROTOCOL_VERSION}; +use serde_json::json; +use warp_core::channel::ChannelState; + +pub(crate) fn instance(instance_id: &Option<InstanceId>) -> serde_json::Value { + json!({ + "action": ActionKind::InstanceList.as_str(), + "instance_id": instance_id.as_ref().map(|id| id.0.as_str()), + "pid": std::process::id(), + "channel": ChannelState::channel().to_string(), + "app_id": ChannelState::app_id().to_string(), + "app_version": ChannelState::app_version(), + "protocol_version": PROTOCOL_VERSION, + "actions": ActionKind::implemented_metadata(), + }) +} + +pub(crate) fn ping(instance_id: &Option<InstanceId>) -> serde_json::Value { + json!({ + "action": ActionKind::AppPing.as_str(), + "ok": true, + "instance_id": instance_id.as_ref().map(|id| id.0.as_str()), + "protocol_version": PROTOCOL_VERSION, + }) +} + +pub(crate) fn version(instance_id: &Option<InstanceId>) -> serde_json::Value { + json!({ + "action": ActionKind::AppVersion.as_str(), + "instance_id": instance_id.as_ref().map(|id| id.0.as_str()), + "protocol_version": PROTOCOL_VERSION, + "channel": ChannelState::channel().to_string(), + "app_id": ChannelState::app_id().to_string(), + "app_version": ChannelState::app_version(), + }) +} diff --git a/app/src/local_control/mod.rs b/app/src/local_control/mod.rs new file mode 100644 index 0000000000..73de2054e0 --- /dev/null +++ b/app/src/local_control/mod.rs @@ -0,0 +1,369 @@ +mod bridge; +mod handlers; +mod permissions; +mod resolver; + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; + +use crate::settings::LocalControlInvocationContext; +use ::local_control::auth::{CredentialGrant, CredentialRequest, ScopedCredential}; +use ::local_control::{ + ActionKind, AuthToken, ControlEndpoint, ControlError, ControlResponse, ErrorCode, + ErrorResponseEnvelope, InstanceId, InstanceRecord, RegisteredInstance, RequestEnvelope, + ResponseEnvelope, PROTOCOL_VERSION, +}; +use axum::extract::rejection::JsonRejection; +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use axum::{Json, Router}; +use chrono::Duration; +use warp_core::channel::ChannelState; +use warpui::{Entity, ModelContext, ModelSpawner, SingletonEntity}; + +pub use bridge::LocalControlBridge; +use permissions::{ + ensure_action_allowed, ensure_feature_enabled, outside_warp_any_implemented_action_enabled, +}; + +#[derive(Clone)] +struct ControlServerState { + bridge_spawner: ModelSpawner<LocalControlBridge>, + instance_id: InstanceId, + credentials: Arc<Mutex<HashMap<String, CredentialGrant>>>, +} + +pub struct LocalControlServer { + _runtime: Option<tokio::runtime::Runtime>, + _registered_instance: Option<RegisteredInstance>, +} + +impl Entity for LocalControlServer { + type Event = (); +} + +impl SingletonEntity for LocalControlServer {} + +impl LocalControlServer { + pub fn new(ctx: &mut ModelContext<Self>) -> Self { + if !permissions::warp_control_cli_enabled() { + return Self { + _runtime: None, + _registered_instance: None, + }; + } + match Self::start(ctx) { + Ok(server) => server, + Err(error) => { + log::warn!("Failed to start local-control server: {error:#}"); + Self { + _runtime: None, + _registered_instance: None, + } + } + } + } + + fn start(ctx: &mut ModelContext<Self>) -> Result<Self, ControlError> { + ensure_feature_enabled()?; + if !outside_warp_any_implemented_action_enabled(ctx) { + return Err(ControlError::new( + ErrorCode::LocalControlDisabled, + "outside-Warp local control is disabled", + )); + } + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_io() + .build() + .map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to create local-control runtime", + err.to_string(), + ) + })?; + let listener = runtime + .block_on(tokio::net::TcpListener::bind(SocketAddr::from(( + [127, 0, 0, 1], + 0, + )))) + .map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to bind local-control listener", + err.to_string(), + ) + })?; + let port = listener.local_addr().map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to read local-control listener address", + err.to_string(), + ) + })?; + let outside_warp_control_enabled = crate::settings::LocalControlSettings::as_ref(ctx) + .is_context_enabled(LocalControlInvocationContext::OutsideWarp); + let endpoint = + outside_warp_control_enabled.then_some(ControlEndpoint::localhost(port.port())); + let record = InstanceRecord::for_current_process( + endpoint, + ChannelState::channel().to_string(), + ChannelState::app_id().to_string(), + ChannelState::app_version().map(str::to_owned), + ActionKind::implemented_metadata(), + ); + let instance_id = record.instance_id.clone(); + let bridge_spawner = LocalControlBridge::handle(ctx).update(ctx, |bridge, ctx| { + bridge.set_instance_id(instance_id.clone()); + ctx.spawner() + }); + let registered_instance = RegisteredInstance::register(record)?; + let state = ControlServerState { + bridge_spawner, + instance_id, + credentials: Arc::default(), + }; + let router = Router::new() + .route("/v1/control", post(handle_control_request)) + .route("/v1/control/credentials", post(handle_credential_request)) + .with_state(state); + runtime.spawn(async move { + if let Err(err) = axum::serve(listener, router).await { + log::warn!("local-control listener stopped: {err:#}"); + } + }); + Ok(Self { + _runtime: Some(runtime), + _registered_instance: Some(registered_instance), + }) + } +} + +async fn handle_credential_request( + State(state): State<ControlServerState>, + payload: Result<Json<CredentialRequest>, JsonRejection>, +) -> Response { + if let Err(error) = ensure_feature_enabled() { + return ( + StatusCode::FORBIDDEN, + Json(ErrorResponseEnvelope::new(error)), + ) + .into_response(); + } + let request = match payload { + Ok(Json(request)) => request, + Err(err) => { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponseEnvelope::new(ControlError::with_details( + ErrorCode::InvalidRequest, + "failed to decode local-control credential request", + err.to_string(), + ))), + ) + .into_response(); + } + }; + if request.protocol_version != PROTOCOL_VERSION { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponseEnvelope::new(ControlError::new( + ErrorCode::ProtocolVersionUnsupported, + format!("unsupported protocol version {}", request.protocol_version), + ))), + ) + .into_response(); + } + let metadata = request.action.metadata(); + if !request.action.is_implemented() { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponseEnvelope::new(ControlError::new( + ErrorCode::UnsupportedAction, + format!( + "{} is not implemented by this local-control bridge", + request.action.as_str() + ), + ))), + ) + .into_response(); + } + if !metadata + .allowed_invocation_contexts + .contains(&request.invocation_context) + { + return ( + StatusCode::FORBIDDEN, + Json(ErrorResponseEnvelope::new(ControlError::new( + ErrorCode::ExecutionContextNotAllowed, + format!( + "{} cannot run from the requested invocation context", + request.action.as_str() + ), + ))), + ) + .into_response(); + } + if let Err(error) = request.verify_execution_context_proof() { + return ( + StatusCode::FORBIDDEN, + Json(ErrorResponseEnvelope::new(error)), + ) + .into_response(); + } + let settings_check = state + .bridge_spawner + .spawn({ + let action = request.action; + let invocation_context = request.invocation_context; + move |_, ctx| ensure_action_allowed(invocation_context, action, ctx) + }) + .await; + match settings_check { + Ok(Ok(())) => {} + Ok(Err(error)) => { + return ( + StatusCode::FORBIDDEN, + Json(ErrorResponseEnvelope::new(error)), + ) + .into_response(); + } + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponseEnvelope::new(ControlError::new( + ErrorCode::BridgeUnavailable, + "local-control app bridge is unavailable", + ))), + ) + .into_response(); + } + } + let auth_token = AuthToken::generate(); + let grant = CredentialGrant::new( + state.instance_id.clone(), + request.action, + request.invocation_context, + Duration::minutes(5), + ); + let mut credentials = match state.credentials.lock() { + Ok(credentials) => credentials, + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponseEnvelope::new(ControlError::new( + ErrorCode::Internal, + "local-control credential broker is unavailable", + ))), + ) + .into_response(); + } + }; + credentials.insert(auth_token.secret().to_owned(), grant.clone()); + Json(ScopedCredential { + bearer_token: auth_token.secret().to_owned(), + grant, + }) + .into_response() +} + +async fn handle_control_request( + State(state): State<ControlServerState>, + headers: HeaderMap, + payload: Result<Json<RequestEnvelope>, JsonRejection>, +) -> Response { + if let Err(error) = ensure_feature_enabled() { + return ( + StatusCode::FORBIDDEN, + Json(ErrorResponseEnvelope::new(error)), + ) + .into_response(); + } + let auth_header = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()); + let auth_token = match AuthToken::from_authorization_header(auth_header) { + Ok(token) => token, + Err(error) => { + return ( + StatusCode::UNAUTHORIZED, + Json(ErrorResponseEnvelope::new(error)), + ) + .into_response(); + } + }; + let request = match payload { + Ok(Json(request)) => request, + Err(err) => { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponseEnvelope::new(ControlError::with_details( + ErrorCode::InvalidRequest, + "failed to decode local-control request", + err.to_string(), + ))), + ) + .into_response(); + } + }; + let grant = match state.credentials.lock() { + Ok(credentials) => credentials.get(auth_token.secret()).cloned(), + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponseEnvelope::new(ControlError::new( + ErrorCode::Internal, + "local-control credential broker is unavailable", + ))), + ) + .into_response(); + } + }; + let Some(grant) = grant else { + return ( + StatusCode::UNAUTHORIZED, + Json(ErrorResponseEnvelope::new(ControlError::new( + ErrorCode::UnauthorizedLocalClient, + "local-control credential is invalid", + ))), + ) + .into_response(); + }; + let request_id = request.request_id; + let response = match state + .bridge_spawner + .spawn(move |bridge, ctx| bridge.handle_request(request, grant, ctx)) + .await + { + Ok(response) => response, + Err(_) => ResponseEnvelope::error( + request_id, + ControlError::new( + ErrorCode::BridgeUnavailable, + "local-control app bridge is unavailable", + ), + ), + }; + let status = match &response.response { + ControlResponse::Ok { .. } => StatusCode::OK, + ControlResponse::Error { .. } => StatusCode::BAD_REQUEST, + }; + (status, Json(response)).into_response() +} + +#[cfg(test)] +pub(crate) use permissions::{ + capabilities, ensure_settings_allow_action, outside_warp_action_enabled_for_settings, +}; +#[cfg(test)] +pub(crate) use resolver::{ + require_active_window_id, validate_action_params, validate_tab_create_target, +}; + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; diff --git a/app/src/local_control/mod_tests.rs b/app/src/local_control/mod_tests.rs new file mode 100644 index 0000000000..283c9c99a2 --- /dev/null +++ b/app/src/local_control/mod_tests.rs @@ -0,0 +1,198 @@ +use ::local_control::protocol::ActionKind; +use ::local_control::protocol::{ + Action, PaneSelector, PaneTarget, TabSelector, TabTarget, TargetSelector, WindowSelector, + WindowTarget, +}; +use ::local_control::{ErrorCode, InvocationContext}; +use settings::Setting as _; +use warp_core::features::FeatureFlag; + +use super::{ + capabilities, ensure_feature_enabled, ensure_settings_allow_action, + outside_warp_action_enabled_for_settings, require_active_window_id, validate_action_params, + validate_tab_create_target, +}; +use crate::settings::{ + AllowInsideWarpControl, AllowInsideWarpReadOnly, AllowInsideWarpReadWrite, + AllowOutsideWarpControl, AllowOutsideWarpReadOnly, AllowOutsideWarpReadWrite, + LocalControlSettings, +}; + +fn settings_with_values( + inside_enabled: bool, + outside_enabled: bool, + inside_read_only: bool, + outside_read_only: bool, + inside_read_write: bool, + outside_read_write: bool, +) -> LocalControlSettings { + LocalControlSettings { + allow_inside_warp_control: AllowInsideWarpControl::new(Some(inside_enabled)), + allow_outside_warp_control: AllowOutsideWarpControl::new(Some(outside_enabled)), + allow_inside_warp_read_only: AllowInsideWarpReadOnly::new(Some(inside_read_only)), + allow_outside_warp_read_only: AllowOutsideWarpReadOnly::new(Some(outside_read_only)), + allow_inside_warp_read_write: AllowInsideWarpReadWrite::new(Some(inside_read_write)), + allow_outside_warp_read_write: AllowOutsideWarpReadWrite::new(Some(outside_read_write)), + } +} + +fn settings_with_outside_warp( + outside_control: bool, + outside_read_write: bool, +) -> LocalControlSettings { + settings_with_values(true, outside_control, true, false, true, outside_read_write) +} + +#[test] +fn tab_create_accepts_default_and_active_targets() { + validate_tab_create_target(&TargetSelector::default()).expect("default target is accepted"); + + validate_tab_create_target(&TargetSelector { + window: Some(WindowTarget::Active), + tab: Some(TabTarget::Active), + pane: Some(PaneTarget::Active), + }) + .expect("active target is accepted"); +} + +#[test] +fn tab_create_rejects_concrete_targets() { + let err = validate_tab_create_target(&TargetSelector { + window: Some(WindowTarget::Id { + id: WindowSelector("window".to_owned()), + }), + tab: None, + pane: None, + }) + .expect_err("concrete window target is rejected"); + assert_eq!(err.code, ErrorCode::StaleTarget); + + let err = validate_tab_create_target(&TargetSelector { + window: None, + tab: Some(TabTarget::Id { + id: TabSelector("tab".to_owned()), + }), + pane: None, + }) + .expect_err("concrete tab target is rejected"); + assert_eq!(err.code, ErrorCode::StaleTarget); + + let err = validate_tab_create_target(&TargetSelector { + window: None, + tab: None, + pane: Some(PaneTarget::Id { + id: PaneSelector("pane".to_owned()), + }), + }) + .expect_err("concrete pane target is rejected"); + assert_eq!(err.code, ErrorCode::StaleTarget); +} + +#[test] +fn tab_create_rejects_unsupported_selector_forms() { + let err = validate_tab_create_target(&TargetSelector { + window: Some(WindowTarget::Index { index: 0 }), + tab: None, + pane: None, + }) + .expect_err("indexed window target is rejected"); + assert_eq!(err.code, ErrorCode::InvalidSelector); + + let err = validate_tab_create_target(&TargetSelector { + window: None, + tab: Some(TabTarget::Index { index: 0 }), + pane: None, + }) + .expect_err("indexed tab target is rejected"); + assert_eq!(err.code, ErrorCode::InvalidSelector); +} + +#[test] +fn capabilities_advertises_only_first_slice_core_actions() { + assert_eq!( + capabilities(), + vec![ + ActionKind::InstanceList, + ActionKind::AppPing, + ActionKind::AppVersion, + ActionKind::TabCreate, + ] + ); +} + +#[test] +fn outside_warp_discovery_requires_context_and_action_permission() { + assert!(!outside_warp_action_enabled_for_settings( + &settings_with_outside_warp(false, true), + ActionKind::TabCreate + )); + assert!(!outside_warp_action_enabled_for_settings( + &settings_with_outside_warp(true, false), + ActionKind::TabCreate + )); + assert!(outside_warp_action_enabled_for_settings( + &settings_with_outside_warp(true, true), + ActionKind::TabCreate + )); +} + +#[test] +fn tab_create_requires_active_window() { + let active = warpui::WindowId::from_usize(1); + + assert_eq!( + require_active_window_id(Some(active)).expect("active"), + active + ); + let err = require_active_window_id(None).expect_err("missing active window"); + assert_eq!(err.code, ErrorCode::MissingTarget); +} + +#[test] +fn feature_flag_disabled_denies_local_control() { + let _flag = FeatureFlag::WarpControlCli.override_enabled(false); + let err = ensure_feature_enabled().expect_err("feature flag disabled"); + assert_eq!(err.code, ErrorCode::LocalControlDisabled); +} + +#[test] +fn disabled_context_denies_before_granular_permission() { + let settings = settings_with_values(false, true, true, true, true, true); + + let err = ensure_settings_allow_action( + &settings, + InvocationContext::InsideWarp, + ActionKind::TabCreate, + ) + .expect_err("inside-Warp parent context is disabled"); + assert_eq!(err.code, ErrorCode::LocalControlDisabled); +} + +#[test] +fn disabled_granular_permission_denies_with_insufficient_permissions() { + let settings = settings_with_values(true, true, true, true, false, true); + + let err = ensure_settings_allow_action( + &settings, + InvocationContext::InsideWarp, + ActionKind::TabCreate, + ) + .expect_err("read-write permission is disabled"); + assert_eq!(err.code, ErrorCode::InsufficientPermissions); +} + +#[test] +fn tab_create_rejects_malformed_params() { + let err = validate_action_params(&Action { + kind: ActionKind::TabCreate, + params: serde_json::json!({ "unexpected": true }), + }) + .expect_err("tab.create params must be empty"); + assert_eq!(err.code, ErrorCode::InvalidParams); + + validate_action_params(&Action { + kind: ActionKind::TabCreate, + params: serde_json::json!({}), + }) + .expect("empty tab.create params are accepted"); +} diff --git a/app/src/local_control/permissions.rs b/app/src/local_control/permissions.rs new file mode 100644 index 0000000000..22ed248520 --- /dev/null +++ b/app/src/local_control/permissions.rs @@ -0,0 +1,115 @@ +use crate::features::FeatureFlag; +use crate::settings::{ + LocalControlInvocationContext, LocalControlPermissionCategory, LocalControlSettings, +}; +use ::local_control::{ActionKind, ControlError, ErrorCode, InvocationContext, PermissionCategory}; +use warpui::{ModelContext, SingletonEntity}; + +use crate::local_control::{LocalControlBridge, LocalControlServer}; + +pub(super) fn warp_control_cli_enabled() -> bool { + FeatureFlag::WarpControlCli.is_enabled() +} + +pub(super) fn ensure_feature_enabled() -> Result<(), ControlError> { + if warp_control_cli_enabled() { + return Ok(()); + } + Err(ControlError::new( + ErrorCode::LocalControlDisabled, + "Warp control CLI is disabled by feature flag", + )) +} + +pub(super) fn outside_warp_any_implemented_action_enabled( + ctx: &ModelContext<LocalControlServer>, +) -> bool { + let settings = LocalControlSettings::as_ref(ctx); + ActionKind::implemented_metadata() + .into_iter() + .any(|metadata| { + outside_warp_permission_enabled_for_settings(settings, metadata.permission_category) + }) +} + +#[cfg(test)] +pub(crate) fn outside_warp_action_enabled_for_settings( + settings: &LocalControlSettings, + action: ActionKind, +) -> bool { + outside_warp_permission_enabled_for_settings(settings, action.metadata().permission_category) +} + +fn outside_warp_permission_enabled_for_settings( + settings: &LocalControlSettings, + permission: PermissionCategory, +) -> bool { + let context = LocalControlInvocationContext::OutsideWarp; + settings.is_context_enabled(context) + && settings.is_permission_enabled(context, local_permission(permission)) +} + +#[cfg(test)] +pub(crate) fn capabilities() -> Vec<ActionKind> { + ActionKind::implemented_metadata() + .into_iter() + .map(|metadata| metadata.kind) + .collect() +} + +fn local_invocation_context(context: InvocationContext) -> LocalControlInvocationContext { + match context { + InvocationContext::InsideWarp => LocalControlInvocationContext::InsideWarp, + InvocationContext::OutsideWarp => LocalControlInvocationContext::OutsideWarp, + } +} + +fn local_permission(permission: PermissionCategory) -> LocalControlPermissionCategory { + match permission { + PermissionCategory::ReadMetadata => LocalControlPermissionCategory::MetadataReads, + PermissionCategory::ReadUnderlyingData => { + LocalControlPermissionCategory::UnderlyingDataReads + } + PermissionCategory::MutateAppState => LocalControlPermissionCategory::AppStateMutations, + PermissionCategory::MutateMetadataConfiguration => { + LocalControlPermissionCategory::MetadataConfigurationMutations + } + PermissionCategory::MutateUnderlyingData => { + LocalControlPermissionCategory::UnderlyingDataMutations + } + } +} + +pub(super) fn ensure_action_allowed( + context: InvocationContext, + action: ActionKind, + ctx: &mut ModelContext<LocalControlBridge>, +) -> Result<(), ControlError> { + let settings = LocalControlSettings::as_ref(ctx); + ensure_settings_allow_action(settings, context, action) +} + +pub(crate) fn ensure_settings_allow_action( + settings: &LocalControlSettings, + context: InvocationContext, + action: ActionKind, +) -> Result<(), ControlError> { + let context = local_invocation_context(context); + if !settings.is_context_enabled(context) { + return Err(ControlError::new( + ErrorCode::LocalControlDisabled, + "local control is disabled for this invocation context", + )); + } + let permission = local_permission(action.metadata().permission_category); + if !settings.is_permission_enabled(context, permission) { + return Err(ControlError::new( + ErrorCode::InsufficientPermissions, + format!( + "{} requires a local-control permission that is disabled", + action.as_str() + ), + )); + } + Ok(()) +} diff --git a/app/src/local_control/resolver.rs b/app/src/local_control/resolver.rs new file mode 100644 index 0000000000..d9db9bda88 --- /dev/null +++ b/app/src/local_control/resolver.rs @@ -0,0 +1,79 @@ +use ::local_control::protocol::{PaneTarget, TabTarget, TargetSelector, WindowTarget}; +use ::local_control::{ActionKind, ControlError, ErrorCode}; +use warpui::ModelContext; + +use crate::local_control::LocalControlBridge; + +pub(crate) fn validate_tab_create_target(target: &TargetSelector) -> Result<(), ControlError> { + if matches!(target.window.as_ref(), Some(WindowTarget::Id { .. })) { + return Err(ControlError::new( + ErrorCode::StaleTarget, + "tab.create cannot resolve the requested window id", + )); + } + if !matches!(target.window.as_ref(), None | Some(WindowTarget::Active)) { + return Err(ControlError::new( + ErrorCode::InvalidSelector, + "tab.create only supports the active window selector", + )); + } + if matches!(target.tab.as_ref(), Some(TabTarget::Id { .. })) { + return Err(ControlError::new( + ErrorCode::StaleTarget, + "tab.create cannot resolve the requested tab id", + )); + } + if !matches!(target.tab.as_ref(), None | Some(TabTarget::Active)) { + return Err(ControlError::new( + ErrorCode::InvalidSelector, + "tab.create does not accept a concrete tab selector", + )); + } + if matches!(target.pane.as_ref(), Some(PaneTarget::Id { .. })) { + return Err(ControlError::new( + ErrorCode::StaleTarget, + "tab.create cannot resolve the requested pane id", + )); + } + if !matches!(target.pane.as_ref(), None | Some(PaneTarget::Active)) { + return Err(ControlError::new( + ErrorCode::InvalidSelector, + "tab.create does not accept a concrete pane selector", + )); + } + Ok(()) +} + +pub(crate) fn validate_action_params(action: &::local_control::Action) -> Result<(), ControlError> { + if action.kind != ActionKind::TabCreate { + return Ok(()); + } + if action + .params + .as_object() + .is_some_and(serde_json::Map::is_empty) + { + return Ok(()); + } + Err(ControlError::new( + ErrorCode::InvalidParams, + "tab.create does not accept parameters in the first implementation slice", + )) +} + +pub(super) fn target_window_id( + ctx: &mut ModelContext<LocalControlBridge>, +) -> Result<warpui::WindowId, ControlError> { + require_active_window_id(ctx.windows().active_window()) +} + +pub(crate) fn require_active_window_id( + active_window: Option<warpui::WindowId>, +) -> Result<warpui::WindowId, ControlError> { + active_window.ok_or_else(|| { + ControlError::new( + ErrorCode::MissingTarget, + "tab.create requires an active Warp window", + ) + }) +} diff --git a/app/src/settings/init.rs b/app/src/settings/init.rs index 05a9af6a45..d5675291dd 100644 --- a/app/src/settings/init.rs +++ b/app/src/settings/init.rs @@ -14,8 +14,8 @@ use super::{ AISettings, AccessibilitySettings, AliasExpansionSettings, AppEditorSettings, BlockVisibilitySettings, ChangelogSettings, CodeSettings, DebugSettings, EmacsBindingsSettings, FontSettings, FontSettingsChangedEvent, GPUSettings, InputBoxType, InputModeSettings, - InputSettings, PaneSettings, SameLinePromptBlockSettings, ScrollSettings, SelectionSettings, - SshSettings, ThemeSettings, VimBannerSettings, WarpDrivePrivacySettings, + InputSettings, LocalControlSettings, PaneSettings, SameLinePromptBlockSettings, ScrollSettings, + SelectionSettings, SshSettings, ThemeSettings, VimBannerSettings, WarpDrivePrivacySettings, }; use crate::ai::cloud_agent_settings::CloudAgentSettings; use crate::banner::BannerState; @@ -94,6 +94,9 @@ pub fn register_all_settings(ctx: &mut AppContext) { EmacsBindingsSettings::register(ctx); SameLinePromptBlockSettings::register(ctx); SemanticSelection::register(ctx); + if FeatureFlag::WarpControlCli.is_enabled() { + LocalControlSettings::register(ctx); + } #[cfg(any(target_os = "linux", target_os = "freebsd"))] super::LinuxAppConfiguration::register(ctx); diff --git a/app/src/settings/local_control.rs b/app/src/settings/local_control.rs new file mode 100644 index 0000000000..981371f684 --- /dev/null +++ b/app/src/settings/local_control.rs @@ -0,0 +1,125 @@ +use settings::{macros::define_settings_group, SupportedPlatforms, SyncToCloud}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LocalControlInvocationContext { + InsideWarp, + OutsideWarp, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LocalControlPermissionCategory { + MetadataReads, + UnderlyingDataReads, + AppStateMutations, + MetadataConfigurationMutations, + UnderlyingDataMutations, +} + +define_settings_group!(LocalControlSettings, settings: [ + allow_inside_warp_control: AllowInsideWarpControl { + type: bool, + default: true, + supported_platforms: SupportedPlatforms::DESKTOP, + sync_to_cloud: SyncToCloud::Never, + private: true, + storage_key: "LocalControlAllowInsideWarp", + description: "Whether Warp control is allowed from verified Warp-managed terminal sessions.", + }, + allow_outside_warp_control: AllowOutsideWarpControl { + type: bool, + default: false, + supported_platforms: SupportedPlatforms::DESKTOP, + sync_to_cloud: SyncToCloud::Never, + private: true, + storage_key: "LocalControlAllowOutsideWarp", + description: "Whether Warp control is allowed from external local clients.", + }, + allow_inside_warp_read_only: AllowInsideWarpReadOnly { + type: bool, + default: true, + supported_platforms: SupportedPlatforms::DESKTOP, + sync_to_cloud: SyncToCloud::Never, + private: true, + storage_key: "LocalControlInsideWarpReadOnly", + description: "Whether verified Warp-managed terminal sessions may receive read-only local control grants.", + }, + allow_outside_warp_read_only: AllowOutsideWarpReadOnly { + type: bool, + default: false, + supported_platforms: SupportedPlatforms::DESKTOP, + sync_to_cloud: SyncToCloud::Never, + private: true, + storage_key: "LocalControlOutsideWarpReadOnly", + description: "Whether external local clients may receive read-only local control grants.", + }, + allow_inside_warp_read_write: AllowInsideWarpReadWrite { + type: bool, + default: true, + supported_platforms: SupportedPlatforms::DESKTOP, + sync_to_cloud: SyncToCloud::Never, + private: true, + storage_key: "LocalControlInsideWarpReadWrite", + description: "Whether verified Warp-managed terminal sessions may receive read-write local control grants.", + }, + allow_outside_warp_read_write: AllowOutsideWarpReadWrite { + type: bool, + default: false, + supported_platforms: SupportedPlatforms::DESKTOP, + sync_to_cloud: SyncToCloud::Never, + private: true, + storage_key: "LocalControlOutsideWarpReadWrite", + description: "Whether external local clients may receive read-write local control grants.", + }, +]); + +impl LocalControlSettings { + pub fn is_context_enabled(&self, context: LocalControlInvocationContext) -> bool { + match context { + LocalControlInvocationContext::InsideWarp => *self.allow_inside_warp_control, + LocalControlInvocationContext::OutsideWarp => *self.allow_outside_warp_control, + } + } + + pub fn is_permission_enabled( + &self, + context: LocalControlInvocationContext, + permission: LocalControlPermissionCategory, + ) -> bool { + match (context, permission) { + ( + LocalControlInvocationContext::InsideWarp, + LocalControlPermissionCategory::MetadataReads + | LocalControlPermissionCategory::UnderlyingDataReads, + ) => *self.allow_inside_warp_read_only, + ( + LocalControlInvocationContext::OutsideWarp, + LocalControlPermissionCategory::MetadataReads + | LocalControlPermissionCategory::UnderlyingDataReads, + ) => *self.allow_outside_warp_read_only, + ( + LocalControlInvocationContext::InsideWarp, + LocalControlPermissionCategory::AppStateMutations + | LocalControlPermissionCategory::MetadataConfigurationMutations + | LocalControlPermissionCategory::UnderlyingDataMutations, + ) => *self.allow_inside_warp_read_write, + ( + LocalControlInvocationContext::OutsideWarp, + LocalControlPermissionCategory::AppStateMutations + | LocalControlPermissionCategory::MetadataConfigurationMutations + | LocalControlPermissionCategory::UnderlyingDataMutations, + ) => *self.allow_outside_warp_read_write, + } + } + + pub fn allows( + &self, + context: LocalControlInvocationContext, + permission: LocalControlPermissionCategory, + ) -> bool { + self.is_context_enabled(context) && self.is_permission_enabled(context, permission) + } +} + +#[cfg(test)] +#[path = "local_control_tests.rs"] +mod tests; diff --git a/app/src/settings/local_control_tests.rs b/app/src/settings/local_control_tests.rs new file mode 100644 index 0000000000..5bb14a7bc6 --- /dev/null +++ b/app/src/settings/local_control_tests.rs @@ -0,0 +1,94 @@ +use settings::{Setting, SyncToCloud}; + +use super::{ + AllowInsideWarpControl, AllowInsideWarpReadOnly, AllowInsideWarpReadWrite, + AllowOutsideWarpControl, AllowOutsideWarpReadOnly, AllowOutsideWarpReadWrite, + LocalControlInvocationContext, LocalControlPermissionCategory, LocalControlSettings, +}; + +fn settings_with_values( + inside_enabled: bool, + outside_enabled: bool, + inside_read_only: bool, + outside_read_only: bool, + inside_read_write: bool, + outside_read_write: bool, +) -> LocalControlSettings { + LocalControlSettings { + allow_inside_warp_control: AllowInsideWarpControl::new(Some(inside_enabled)), + allow_outside_warp_control: AllowOutsideWarpControl::new(Some(outside_enabled)), + allow_inside_warp_read_only: AllowInsideWarpReadOnly::new(Some(inside_read_only)), + allow_outside_warp_read_only: AllowOutsideWarpReadOnly::new(Some(outside_read_only)), + allow_inside_warp_read_write: AllowInsideWarpReadWrite::new(Some(inside_read_write)), + allow_outside_warp_read_write: AllowOutsideWarpReadWrite::new(Some(outside_read_write)), + } +} + +#[test] +fn defaults_allow_inside_warp_permissions_only() { + let settings = settings_with_values(true, false, true, false, true, false); + + assert!(settings.allows( + LocalControlInvocationContext::InsideWarp, + LocalControlPermissionCategory::MetadataReads + )); + assert!(settings.allows( + LocalControlInvocationContext::InsideWarp, + LocalControlPermissionCategory::UnderlyingDataReads + )); + assert!(settings.allows( + LocalControlInvocationContext::InsideWarp, + LocalControlPermissionCategory::AppStateMutations + )); + assert!(settings.allows( + LocalControlInvocationContext::InsideWarp, + LocalControlPermissionCategory::MetadataConfigurationMutations + )); + assert!(settings.allows( + LocalControlInvocationContext::InsideWarp, + LocalControlPermissionCategory::UnderlyingDataMutations + )); + assert!(!settings.allows( + LocalControlInvocationContext::OutsideWarp, + LocalControlPermissionCategory::MetadataReads + )); + assert!(!settings.allows( + LocalControlInvocationContext::OutsideWarp, + LocalControlPermissionCategory::AppStateMutations + )); +} + +#[test] +fn generated_settings_are_private_local_only_with_expected_defaults() { + assert!(*AllowInsideWarpControl::new(None)); + assert!(!*AllowOutsideWarpControl::new(None)); + assert!(*AllowInsideWarpReadOnly::new(None)); + assert!(!*AllowOutsideWarpReadOnly::new(None)); + assert!(*AllowInsideWarpReadWrite::new(None)); + assert!(!*AllowOutsideWarpReadWrite::new(None)); + assert_eq!(AllowInsideWarpControl::sync_to_cloud(), SyncToCloud::Never); + assert_eq!(AllowOutsideWarpControl::sync_to_cloud(), SyncToCloud::Never); + assert_eq!(AllowInsideWarpReadOnly::sync_to_cloud(), SyncToCloud::Never); + assert_eq!( + AllowOutsideWarpReadWrite::sync_to_cloud(), + SyncToCloud::Never + ); + assert!(AllowInsideWarpControl::is_private()); + assert!(AllowOutsideWarpControl::is_private()); + assert!(AllowInsideWarpReadOnly::is_private()); + assert!(AllowOutsideWarpReadWrite::is_private()); +} + +#[test] +fn disabled_context_blocks_enabled_granular_permissions() { + let settings = settings_with_values(false, false, true, true, true, true); + + assert!(!settings.allows( + LocalControlInvocationContext::InsideWarp, + LocalControlPermissionCategory::AppStateMutations + )); + assert!(!settings.allows( + LocalControlInvocationContext::OutsideWarp, + LocalControlPermissionCategory::MetadataReads + )); +} diff --git a/app/src/settings/mod.rs b/app/src/settings/mod.rs index 52fb778fa0..5638cdba7d 100644 --- a/app/src/settings/mod.rs +++ b/app/src/settings/mod.rs @@ -20,6 +20,7 @@ mod input; mod input_mode; #[cfg(any(target_os = "linux", target_os = "freebsd"))] mod linux; +mod local_control; pub mod macros; pub mod manager; pub mod native_preference; @@ -54,6 +55,7 @@ pub use input::*; pub use input_mode::*; #[cfg(any(target_os = "linux", target_os = "freebsd"))] pub use linux::*; +pub use local_control::*; pub use native_preference::*; pub use onboarding::*; pub use pane::*; diff --git a/app/src/settings_view/mod.rs b/app/src/settings_view/mod.rs index 153b52a952..6f1fc4d230 100644 --- a/app/src/settings_view/mod.rs +++ b/app/src/settings_view/mod.rs @@ -18,6 +18,7 @@ use nav::{SettingsNavItem, SettingsUmbrella}; use pathfinder_geometry::vector::Vector2F; use privacy_page::{PrivacyPageView, PrivacyPageViewEvent}; use referrals_page::{ReferralsPageEvent, ReferralsPageView}; +use scripting_page::ScriptingSettingsPageView; use settings_file_footer::{render_footer, SettingsFooterKind, SettingsFooterMouseStates}; use settings_page::{ MatchData, SettingsPage, SettingsPageEvent, SettingsPageMeta, SettingsPageViewHandle, @@ -100,6 +101,7 @@ mod privacy; mod privacy_page; mod referrals_page; mod remove_custom_endpoint_confirmation_dialog; +mod scripting_page; mod settings_file_footer; pub(crate) mod settings_page; mod show_blocks_view; @@ -246,6 +248,7 @@ pub enum SettingsSection { Keybindings, Privacy, Referrals, + Scripting, SharedBlocks, Teams, WarpDrive, @@ -285,6 +288,7 @@ impl Display for SettingsSection { SettingsSection::Keybindings => write!(f, "Keyboard shortcuts"), SettingsSection::SharedBlocks => write!(f, "Shared blocks"), SettingsSection::MCPServers => write!(f, "MCP Servers"), + SettingsSection::Scripting => write!(f, "Scripting"), SettingsSection::WarpDrive => write!(f, "Warp Drive"), SettingsSection::WarpAgent => write!(f, "Warp Agent"), SettingsSection::AgentProfiles => write!(f, "Profiles"), @@ -382,6 +386,7 @@ impl FromStr for SettingsSection { "Keyboard shortcuts" => Ok(Self::Keybindings), "Privacy" => Ok(Self::Privacy), "Referrals" => Ok(Self::Referrals), + "Scripting" => Ok(Self::Scripting), "Shared blocks" => Ok(Self::SharedBlocks), "Teams" => Ok(Self::Teams), "Warpify" => Ok(Self::Warpify), @@ -1018,6 +1023,7 @@ macro_rules! update_page { SettingsPageViewHandle::OzCloudAPIKeys(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::Privacy(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::Referrals(handle) => $ctx.update_view(handle, $update), + SettingsPageViewHandle::Scripting(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::AI(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::CloudEnvironments(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::About(handle) => $ctx.update_view(handle, $update), @@ -1166,6 +1172,11 @@ impl SettingsView { ctx.subscribe_to_view(&referrals_page_handle, |me, _, event, ctx| { me.handle_referrals_page_event(event, ctx); }); + let scripting_page_handle = if FeatureFlag::WarpControlCli.is_enabled() { + Some(ctx.add_typed_action_view(ScriptingSettingsPageView::new)) + } else { + None + }; // Warp Drive page let warp_drive_page_handle = @@ -1229,6 +1240,10 @@ impl SettingsView { SettingsPage::new(warp_drive_page_handle), ]; + if let Some(scripting_page_handle) = scripting_page_handle { + settings_pages.push(SettingsPage::new(scripting_page_handle)); + } + settings_pages.extend(vec![ SettingsPage::new(mcp_servers_page_handle), SettingsPage::new(environments_page_handle.clone()), @@ -1271,10 +1286,26 @@ impl SettingsView { SettingsNavItem::Page(SettingsSection::About), ]; + if FeatureFlag::WarpControlCli.is_enabled() { + let shared_blocks_index = nav_items + .iter() + .position(|item| { + matches!(item, SettingsNavItem::Page(SettingsSection::SharedBlocks)) + }) + .unwrap_or(nav_items.len()); + nav_items.insert( + shared_blocks_index, + SettingsNavItem::Page(SettingsSection::Scripting), + ); + } + // Resolve the initial page: map internal backing-page sections to their default subpage. let initial_page = match page { Some(SettingsSection::AI) => SettingsSection::WarpAgent, Some(SettingsSection::Code) => SettingsSection::CodeIndexing, + Some(SettingsSection::Scripting) if !FeatureFlag::WarpControlCli.is_enabled() => { + SettingsSection::Account + } Some(section) if section.is_subpage() => section, other => other.unwrap_or_default(), }; @@ -2017,6 +2048,7 @@ impl SettingsView { SettingsPageViewHandle::Privacy(v) => v.as_ref(app).should_render(app), SettingsPageViewHandle::Warpify(v) => v.as_ref(app).should_render(app), SettingsPageViewHandle::Referrals(v) => v.as_ref(app).should_render(app), + SettingsPageViewHandle::Scripting(v) => v.as_ref(app).should_render(app), SettingsPageViewHandle::AI(v) => v.as_ref(app).should_render(app), SettingsPageViewHandle::CloudEnvironments(v) => v.as_ref(app).should_render(app), SettingsPageViewHandle::MCPServers(v) => v.as_ref(app).should_render(app), diff --git a/app/src/settings_view/scripting_page.rs b/app/src/settings_view/scripting_page.rs new file mode 100644 index 0000000000..a8c56ec30a --- /dev/null +++ b/app/src/settings_view/scripting_page.rs @@ -0,0 +1,353 @@ +use super::{ + settings_page::{ + render_body_item, render_settings_info_banner, LocalOnlyIconState, MatchData, PageType, + SettingsPageMeta, SettingsPageViewHandle, SettingsWidget, + }, + SettingsSection, ToggleState, +}; +use crate::appearance::Appearance; +use crate::features::FeatureFlag; +use crate::report_if_error; +use crate::settings::{ + AllowInsideWarpControl, AllowInsideWarpReadOnly, AllowInsideWarpReadWrite, + AllowOutsideWarpControl, AllowOutsideWarpReadOnly, AllowOutsideWarpReadWrite, + LocalControlInvocationContext, LocalControlSettings, +}; +use settings::{Setting as _, ToggleableSetting as _}; +use std::cell::RefCell; +use std::collections::HashMap; +use warp_core::settings::SyncToCloud; +use warpui::elements::{Container, Element, MouseStateHandle}; +use warpui::ui_components::components::UiComponent; +use warpui::ui_components::switch::SwitchStateHandle; +use warpui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle}; + +#[derive(Clone, Copy, Debug)] +pub enum ScriptingToggle { + InsideWarpControl, + InsideWarpReadOnly, + InsideWarpReadWrite, + OutsideWarpControl, + OutsideWarpReadOnly, + OutsideWarpReadWrite, +} + +impl ScriptingToggle { + fn label(self) -> &'static str { + match self { + Self::InsideWarpControl => "Warp control within Warp", + Self::OutsideWarpControl => "Warp control outside Warp", + Self::InsideWarpReadOnly | Self::OutsideWarpReadOnly => "Allow read-only control", + Self::InsideWarpReadWrite | Self::OutsideWarpReadWrite => "Allow read-write control", + } + } + + fn description(self) -> &'static str { + match self { + Self::InsideWarpControl => { + "Allows control commands launched from verified Warp-managed terminal sessions." + } + Self::OutsideWarpControl => { + "Allows other local apps, terminals, IDEs, launch agents, and scripts to request Warp control." + } + Self::InsideWarpReadOnly => { + "Allows commands inside Warp to query app information such as instances, windows, tabs, and protocol version." + } + Self::OutsideWarpReadOnly => { + "Allows external local clients to query app information after outside-Warp control is enabled." + } + Self::InsideWarpReadWrite => { + "Allows commands inside Warp to change Warp app state, such as creating a tab." + } + Self::OutsideWarpReadWrite => { + "Allows external local clients to change Warp app state after outside-Warp control is enabled." + } + } + } + + fn search_terms(self) -> &'static str { + match self { + Self::InsideWarpControl => "inside warp control terminal scripting automation", + Self::OutsideWarpControl => { + "outside warp control external scripts automation local cli" + } + Self::InsideWarpReadOnly => "inside warp read only query windows tabs panes instances", + Self::OutsideWarpReadOnly => { + "outside warp read only query windows tabs panes instances" + } + Self::InsideWarpReadWrite => "inside warp read write mutate change tab create", + Self::OutsideWarpReadWrite => "outside warp read write mutate change tab create", + } + } + + fn value(self, settings: &LocalControlSettings) -> bool { + match self { + Self::InsideWarpControl => *settings.allow_inside_warp_control, + Self::OutsideWarpControl => *settings.allow_outside_warp_control, + Self::InsideWarpReadOnly => *settings.allow_inside_warp_read_only, + Self::OutsideWarpReadOnly => *settings.allow_outside_warp_read_only, + Self::InsideWarpReadWrite => *settings.allow_inside_warp_read_write, + Self::OutsideWarpReadWrite => *settings.allow_outside_warp_read_write, + } + } + + fn storage_key(self) -> &'static str { + match self { + Self::InsideWarpControl => AllowInsideWarpControl::storage_key(), + Self::OutsideWarpControl => AllowOutsideWarpControl::storage_key(), + Self::InsideWarpReadOnly => AllowInsideWarpReadOnly::storage_key(), + Self::OutsideWarpReadOnly => AllowOutsideWarpReadOnly::storage_key(), + Self::InsideWarpReadWrite => AllowInsideWarpReadWrite::storage_key(), + Self::OutsideWarpReadWrite => AllowOutsideWarpReadWrite::storage_key(), + } + } + + fn sync_to_cloud(self) -> SyncToCloud { + match self { + Self::InsideWarpControl => AllowInsideWarpControl::sync_to_cloud(), + Self::OutsideWarpControl => AllowOutsideWarpControl::sync_to_cloud(), + Self::InsideWarpReadOnly => AllowInsideWarpReadOnly::sync_to_cloud(), + Self::OutsideWarpReadOnly => AllowOutsideWarpReadOnly::sync_to_cloud(), + Self::InsideWarpReadWrite => AllowInsideWarpReadWrite::sync_to_cloud(), + Self::OutsideWarpReadWrite => AllowOutsideWarpReadWrite::sync_to_cloud(), + } + } + + fn parent_context(self) -> Option<LocalControlInvocationContext> { + match self { + Self::InsideWarpReadOnly | Self::InsideWarpReadWrite => { + Some(LocalControlInvocationContext::InsideWarp) + } + Self::OutsideWarpReadOnly | Self::OutsideWarpReadWrite => { + Some(LocalControlInvocationContext::OutsideWarp) + } + Self::InsideWarpControl | Self::OutsideWarpControl => None, + } + } +} + +#[derive(Clone, Debug)] +pub enum ScriptingSettingsPageAction { + Toggle(ScriptingToggle), +} + +pub struct ScriptingSettingsPageView { + page: PageType<Self>, + local_only_icon_tooltip_states: RefCell<HashMap<String, MouseStateHandle>>, +} + +impl ScriptingSettingsPageView { + pub fn new(ctx: &mut ViewContext<Self>) -> Self { + if FeatureFlag::WarpControlCli.is_enabled() { + ctx.subscribe_to_model(&LocalControlSettings::handle(ctx), |_, _, _, ctx| { + ctx.notify(); + }); + } + + Self { + page: PageType::new_uncategorized( + vec![ + Box::new(ScriptingIntroWidget), + Box::new(ScriptingToggleWidget::new( + ScriptingToggle::InsideWarpControl, + )), + Box::new(ScriptingToggleWidget::new( + ScriptingToggle::InsideWarpReadOnly, + )), + Box::new(ScriptingToggleWidget::new( + ScriptingToggle::InsideWarpReadWrite, + )), + Box::new(ScriptingToggleWidget::new( + ScriptingToggle::OutsideWarpControl, + )), + Box::new(ScriptingToggleWidget::new( + ScriptingToggle::OutsideWarpReadOnly, + )), + Box::new(ScriptingToggleWidget::new( + ScriptingToggle::OutsideWarpReadWrite, + )), + ], + Some("Scripting"), + ), + local_only_icon_tooltip_states: RefCell::new(HashMap::new()), + } + } +} + +impl Entity for ScriptingSettingsPageView { + type Event = (); +} + +impl TypedActionView for ScriptingSettingsPageView { + type Action = ScriptingSettingsPageAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) { + match action { + ScriptingSettingsPageAction::Toggle(toggle) => { + LocalControlSettings::handle(ctx).update(ctx, |settings, ctx| match toggle { + ScriptingToggle::InsideWarpControl => { + report_if_error!(settings + .allow_inside_warp_control + .toggle_and_save_value(ctx)); + } + ScriptingToggle::OutsideWarpControl => { + report_if_error!(settings + .allow_outside_warp_control + .toggle_and_save_value(ctx)); + } + ScriptingToggle::InsideWarpReadOnly => { + report_if_error!(settings + .allow_inside_warp_read_only + .toggle_and_save_value(ctx)); + } + ScriptingToggle::OutsideWarpReadOnly => { + report_if_error!(settings + .allow_outside_warp_read_only + .toggle_and_save_value(ctx)); + } + ScriptingToggle::InsideWarpReadWrite => { + report_if_error!(settings + .allow_inside_warp_read_write + .toggle_and_save_value(ctx)); + } + ScriptingToggle::OutsideWarpReadWrite => { + report_if_error!(settings + .allow_outside_warp_read_write + .toggle_and_save_value(ctx)); + } + }); + ctx.notify(); + } + } + } +} + +impl View for ScriptingSettingsPageView { + fn ui_name() -> &'static str { + "ScriptingSettingsPage" + } + + fn render(&self, app: &AppContext) -> Box<dyn Element> { + self.page.render(self, app) + } +} + +impl SettingsPageMeta for ScriptingSettingsPageView { + fn section() -> SettingsSection { + SettingsSection::Scripting + } + + fn should_render(&self, _ctx: &AppContext) -> bool { + cfg!(not(target_family = "wasm")) && FeatureFlag::WarpControlCli.is_enabled() + } + + fn update_filter(&mut self, query: &str, ctx: &mut ViewContext<Self>) -> MatchData { + self.page.update_filter(query, ctx) + } + + fn scroll_to_widget(&mut self, widget_id: &'static str) { + self.page.scroll_to_widget(widget_id) + } + + fn clear_highlighted_widget(&mut self) { + self.page.clear_highlighted_widget(); + } +} + +impl From<ViewHandle<ScriptingSettingsPageView>> for SettingsPageViewHandle { + fn from(view_handle: ViewHandle<ScriptingSettingsPageView>) -> Self { + SettingsPageViewHandle::Scripting(view_handle) + } +} + +struct ScriptingIntroWidget; + +impl SettingsWidget for ScriptingIntroWidget { + type View = ScriptingSettingsPageView; + + fn search_terms(&self) -> &str { + "scripting warp control automation warpctrl local cli inside outside read only read write" + } + + fn render( + &self, + _view: &Self::View, + appearance: &Appearance, + _app: &AppContext, + ) -> Box<dyn Element> { + render_settings_info_banner( + "Warp control lets local scripts automate allowlisted actions in a running Warp app.", + Some("Enable Warp control within Warp for commands launched from Warp-managed terminals, or outside Warp for other local apps and scripts. Each scope can allow read-only queries and read-write app changes separately."), + appearance, + ) + } +} + +struct ScriptingToggleWidget { + toggle: ScriptingToggle, + switch_state: SwitchStateHandle, +} + +impl ScriptingToggleWidget { + fn new(toggle: ScriptingToggle) -> Self { + Self { + toggle, + switch_state: SwitchStateHandle::default(), + } + } +} + +impl SettingsWidget for ScriptingToggleWidget { + type View = ScriptingSettingsPageView; + + fn search_terms(&self) -> &str { + self.toggle.search_terms() + } + + fn should_render(&self, app: &AppContext) -> bool { + let settings = LocalControlSettings::as_ref(app); + match self.toggle.parent_context() { + Some(context) => settings.is_context_enabled(context), + None => true, + } + } + + fn render( + &self, + view: &Self::View, + appearance: &Appearance, + app: &AppContext, + ) -> Box<dyn Element> { + let settings = LocalControlSettings::as_ref(app); + let checked = self.toggle.value(settings); + let toggle = self.toggle; + + let item = render_body_item::<ScriptingSettingsPageAction>( + self.toggle.label().to_owned(), + None, + LocalOnlyIconState::for_setting( + self.toggle.storage_key(), + self.toggle.sync_to_cloud(), + &mut view.local_only_icon_tooltip_states.borrow_mut(), + app, + ), + ToggleState::Enabled, + appearance, + appearance + .ui_builder() + .switch(self.switch_state.clone()) + .check(checked) + .build() + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action(ScriptingSettingsPageAction::Toggle(toggle)); + }) + .finish(), + Some(self.toggle.description().to_owned()), + ); + if self.toggle.parent_context().is_some() { + Container::new(item).with_margin_left(16.).finish() + } else { + item + } + } +} diff --git a/app/src/settings_view/settings_page.rs b/app/src/settings_view/settings_page.rs index 518630a0bc..472542af4b 100644 --- a/app/src/settings_view/settings_page.rs +++ b/app/src/settings_view/settings_page.rs @@ -38,6 +38,7 @@ use super::main_page::MainSettingsPageView; use super::mcp_servers_page::MCPServersSettingsPageView; use super::privacy_page::PrivacyPageView; use super::referrals_page::ReferralsPageView; +use super::scripting_page::ScriptingSettingsPageView; use super::show_blocks_view::ShowBlocksView; use super::teams_page::TeamsPageView; use super::warp_drive_page::WarpDriveSettingsPageView; @@ -111,6 +112,7 @@ pub enum SettingsPageViewHandle { Privacy(ViewHandle<PrivacyPageView>), Warpify(ViewHandle<WarpifyPageView>), Referrals(ViewHandle<ReferralsPageView>), + Scripting(ViewHandle<ScriptingSettingsPageView>), AI(ViewHandle<AISettingsPageView>), CloudEnvironments(ViewHandle<EnvironmentsPageView>), BillingAndUsage(ViewHandle<BillingAndUsageDispatchView>), @@ -134,6 +136,7 @@ impl SettingsPageViewHandle { Privacy(view_handle) => ChildView::new(view_handle).finish(), Warpify(view_handle) => ChildView::new(view_handle).finish(), Referrals(view_handle) => ChildView::new(view_handle).finish(), + Scripting(view_handle) => ChildView::new(view_handle).finish(), AI(view_handle) => ChildView::new(view_handle).finish(), CloudEnvironments(view_handle) => ChildView::new(view_handle).finish(), BillingAndUsage(view_handle) => ChildView::new(view_handle).finish(), diff --git a/app/src/test_util/settings.rs b/app/src/test_util/settings.rs index 9e9abc3229..e9bc4176bd 100644 --- a/app/src/test_util/settings.rs +++ b/app/src/test_util/settings.rs @@ -35,9 +35,9 @@ pub fn initialize_settings_for_tests_with_mode( init_and_register_user_preferences, AISettings, AccessibilitySettings, AliasExpansionSettings, AppEditorSettings, BlockVisibilitySettings, ChangelogSettings, CloudPreferencesSettings, CodeSettings, DebugSettings, EmacsBindingsSettings, FontSettings, - GPUSettings, InputModeSettings, InputSettings, NativePreferenceSettings, PaneSettings, - SameLinePromptBlockSettings, ScrollSettings, SelectionSettings, SshSettings, ThemeSettings, - VimBannerSettings, + GPUSettings, InputModeSettings, InputSettings, LocalControlSettings, + NativePreferenceSettings, PaneSettings, SameLinePromptBlockSettings, ScrollSettings, + SelectionSettings, SshSettings, ThemeSettings, VimBannerSettings, }; use crate::terminal::general_settings::GeneralSettings; use crate::terminal::keys_settings::KeysSettings; @@ -84,6 +84,9 @@ pub fn initialize_settings_for_tests_with_mode( InputSettings::register(app); KeysSettings::register(app); LigatureSettings::register(app); + if warp_core::features::FeatureFlag::WarpControlCli.is_enabled() { + LocalControlSettings::register(app); + } #[cfg(any(target_os = "linux", target_os = "freebsd"))] { diff --git a/crates/local_control/Cargo.toml b/crates/local_control/Cargo.toml new file mode 100644 index 0000000000..2d4bd75a36 --- /dev/null +++ b/crates/local_control/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "local_control" +edition = "2024" +description = "Shared protocol and discovery primitives for Warp local control" +authors.workspace = true +publish.workspace = true +license.workspace = true + +[dependencies] +base64.workspace = true +chrono.workspace = true +rand.workspace = true +reqwest.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +thiserror.workspace = true +uuid.workspace = true + +[target.'cfg(unix)'.dependencies] +libc.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/local_control/src/auth.rs b/crates/local_control/src/auth.rs new file mode 100644 index 0000000000..ec7e9c4d21 --- /dev/null +++ b/crates/local_control/src/auth.rs @@ -0,0 +1,216 @@ +use base64::Engine as _; +use chrono::{DateTime, Duration, Utc}; +use rand::RngCore as _; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::discovery::InstanceId; +use crate::protocol::{ + ActionKind, ControlError, ErrorCode, ExecutionContextProof, InvocationContext, + PermissionCategory, RiskTier, StateDataCategory, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthToken(String); + +impl AuthToken { + pub fn generate() -> Self { + let mut bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + Self(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)) + } + + pub fn from_secret(secret: impl Into<String>) -> Self { + Self(secret.into()) + } + + pub fn secret(&self) -> &str { + &self.0 + } + + pub fn authorization_value(&self) -> String { + format!("Bearer {}", self.0) + } + + pub fn from_authorization_header(value: Option<&str>) -> Result<Self, ControlError> { + let Some(value) = value else { + return Err(ControlError::new( + ErrorCode::UnauthorizedLocalClient, + "Authorization header is required", + )); + }; + let Some(token) = value.strip_prefix("Bearer ") else { + return Err(ControlError::new( + ErrorCode::UnauthorizedLocalClient, + "Authorization header must use the Bearer scheme", + )); + }; + Ok(Self::from_secret(token)) + } + + pub fn verify_authorization_header(&self, value: Option<&str>) -> Result<(), ControlError> { + let token = Self::from_authorization_header(value)?; + if token != *self { + return Err(ControlError::new( + ErrorCode::UnauthorizedLocalClient, + "Authorization token is invalid", + )); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CredentialRequest { + pub protocol_version: u32, + pub request_id: Uuid, + pub action: ActionKind, + pub invocation_context: InvocationContext, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_context_proof: Option<ExecutionContextProof>, +} + +impl CredentialRequest { + pub fn new(action: ActionKind, invocation_context: InvocationContext) -> Self { + Self { + protocol_version: crate::protocol::PROTOCOL_VERSION, + request_id: Uuid::new_v4(), + action, + invocation_context, + execution_context_proof: None, + } + } + + pub fn verify_execution_context_proof(&self) -> Result<(), ControlError> { + match (&self.invocation_context, &self.execution_context_proof) { + (InvocationContext::InsideWarp, _) => Err(ControlError::new( + ErrorCode::ExecutionContextNotAllowed, + "inside-Warp credentials require an app-issued verified Warp terminal proof", + )), + ( + InvocationContext::OutsideWarp, + None | Some(ExecutionContextProof::ExternalClient), + ) => Ok(()), + ( + InvocationContext::OutsideWarp, + Some(ExecutionContextProof::VerifiedWarpTerminal { .. }), + ) => Err(ControlError::new( + ErrorCode::ExecutionContextNotAllowed, + "external clients cannot use a Warp terminal execution proof", + )), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ScopedCredential { + pub bearer_token: String, + pub grant: CredentialGrant, +} + +impl ScopedCredential { + pub fn authorization_value(&self) -> String { + format!("Bearer {}", self.bearer_token) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CredentialGrant { + pub credential_id: String, + pub instance_id: InstanceId, + pub action: ActionKind, + pub risk_tier: RiskTier, + pub state_data_category: StateDataCategory, + pub permission_category: PermissionCategory, + pub invocation_context: InvocationContext, + pub authenticated_user: AuthenticatedUserGrant, + pub issued_at: DateTime<Utc>, + pub expires_at: DateTime<Utc>, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthenticatedUserGrant { + pub required: bool, + pub subject: Option<String>, +} + +impl CredentialGrant { + pub fn new( + instance_id: InstanceId, + action: ActionKind, + invocation_context: InvocationContext, + ttl: Duration, + ) -> Self { + let issued_at = Utc::now(); + let metadata = action.metadata(); + Self { + credential_id: format!("cred_{}", Uuid::new_v4().simple()), + instance_id, + action, + risk_tier: metadata.risk_tier, + state_data_category: metadata.state_data_category, + permission_category: metadata.permission_category, + invocation_context, + authenticated_user: AuthenticatedUserGrant { + required: metadata.authenticated_user.required, + subject: None, + }, + issued_at, + expires_at: issued_at + ttl, + } + } + + pub fn verify_for_action(&self, action: ActionKind) -> Result<(), ControlError> { + if Utc::now() >= self.expires_at { + return Err(ControlError::new( + ErrorCode::UnauthorizedLocalClient, + "local-control credential has expired", + )); + } + if self.action != action { + return Err(ControlError::new( + ErrorCode::InsufficientPermissions, + format!( + "credential for {} cannot invoke {}", + self.action.as_str(), + action.as_str() + ), + )); + } + let metadata = action.metadata(); + if self.risk_tier != metadata.risk_tier + || self.state_data_category != metadata.state_data_category + || self.permission_category != metadata.permission_category + { + return Err(ControlError::new( + ErrorCode::InsufficientPermissions, + format!( + "credential grant metadata does not satisfy {}", + action.as_str() + ), + )); + } + if metadata.requires_authenticated_user && self.authenticated_user.subject.is_none() { + return Err(ControlError::new( + ErrorCode::AuthenticatedUserRequired, + format!("{} requires an authenticated Warp user", action.as_str()), + )); + } + if !metadata + .allowed_invocation_contexts + .contains(&self.invocation_context) + { + return Err(ControlError::new( + ErrorCode::ExecutionContextNotAllowed, + format!( + "{} cannot run from the credential invocation context", + action.as_str() + ), + )); + } + Ok(()) + } +} + +#[cfg(test)] +#[path = "auth_tests.rs"] +mod tests; diff --git a/crates/local_control/src/auth_tests.rs b/crates/local_control/src/auth_tests.rs new file mode 100644 index 0000000000..c29f44e64e --- /dev/null +++ b/crates/local_control/src/auth_tests.rs @@ -0,0 +1,113 @@ +use chrono::Duration; + +use super::*; +use crate::discovery::InstanceId; +use crate::protocol::{PermissionCategory, StateDataCategory}; + +#[test] +fn rejects_missing_authorization_header() { + let token = AuthToken::from_secret("secret"); + let err = token + .verify_authorization_header(None) + .expect_err("rejected"); + assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); +} +#[test] +fn rejects_malformed_authorization_header() { + let token = AuthToken::from_secret("secret"); + let err = token + .verify_authorization_header(Some("Basic secret")) + .expect_err("rejected"); + assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); +} + +#[test] +fn rejects_wrong_bearer_token() { + let token = AuthToken::from_secret("secret"); + let err = token + .verify_authorization_header(Some("Bearer wrong")) + .expect_err("rejected"); + assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); +} + +#[test] +fn accepts_matching_bearer_token() { + let token = AuthToken::from_secret("secret"); + token + .verify_authorization_header(Some("Bearer secret")) + .expect("accepted"); +} + +#[test] +fn scoped_credential_allows_only_granted_action() { + let grant = CredentialGrant::new( + InstanceId("inst_test".to_owned()), + ActionKind::TabCreate, + InvocationContext::OutsideWarp, + Duration::minutes(5), + ); + grant + .verify_for_action(ActionKind::TabCreate) + .expect("tab.create grant is accepted"); + let err = grant + .verify_for_action(ActionKind::WindowCreate) + .expect_err("other actions are rejected"); + assert_eq!(err.code, ErrorCode::InsufficientPermissions); +} + +#[test] +fn scoped_credential_carries_permission_and_authenticated_user_metadata() { + let grant = CredentialGrant::new( + InstanceId("inst_test".to_owned()), + ActionKind::TabCreate, + InvocationContext::InsideWarp, + Duration::minutes(5), + ); + assert_eq!(grant.risk_tier, RiskTier::MutatingNonDestructive); + assert_eq!( + grant.state_data_category, + StateDataCategory::AppStateMutation + ); + assert_eq!( + grant.permission_category, + PermissionCategory::MutateAppState + ); + assert!(!grant.authenticated_user.required); + assert!(grant.authenticated_user.subject.is_none()); +} + +#[test] +fn mismatched_permission_metadata_is_rejected() { + let mut grant = CredentialGrant::new( + InstanceId("inst_test".to_owned()), + ActionKind::TabCreate, + InvocationContext::InsideWarp, + Duration::minutes(5), + ); + grant.permission_category = PermissionCategory::ReadMetadata; + let err = grant + .verify_for_action(ActionKind::TabCreate) + .expect_err("metadata mismatch is rejected"); + assert_eq!(err.code, ErrorCode::InsufficientPermissions); +} + +#[test] +fn credential_request_rejects_unverified_inside_warp_context() { + let request = CredentialRequest::new(ActionKind::TabCreate, InvocationContext::InsideWarp); + let err = request + .verify_execution_context_proof() + .expect_err("missing proof is rejected"); + assert_eq!(err.code, ErrorCode::ExecutionContextNotAllowed); +} + +#[test] +fn credential_request_rejects_terminal_proof_for_external_client() { + let mut request = CredentialRequest::new(ActionKind::TabCreate, InvocationContext::OutsideWarp); + request.execution_context_proof = Some(ExecutionContextProof::VerifiedWarpTerminal { + proof_id: "proof".to_owned(), + }); + let err = request + .verify_execution_context_proof() + .expect_err("terminal proof is rejected for external context"); + assert_eq!(err.code, ErrorCode::ExecutionContextNotAllowed); +} diff --git a/crates/local_control/src/catalog.rs b/crates/local_control/src/catalog.rs new file mode 100644 index 0000000000..7815350358 --- /dev/null +++ b/crates/local_control/src/catalog.rs @@ -0,0 +1,508 @@ +use serde::{Deserialize, Serialize}; + +pub const PROTOCOL_VERSION: u32 = 1; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InvocationContext { + InsideWarp, + OutsideWarp, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ExecutionContextProof { + VerifiedWarpTerminal { proof_id: String }, + ExternalClient, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RiskTier { + ReadOnlyMetadata, + ReadOnlyTerminalData, + MutatingNonDestructive, + MutatingDestructiveOrExecution, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StateDataCategory { + MetadataRead, + UnderlyingDataRead, + AppStateMutation, + MetadataConfigurationMutation, + UnderlyingDataMutation, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PermissionCategory { + ReadMetadata, + ReadUnderlyingData, + MutateAppState, + MutateMetadataConfiguration, + MutateUnderlyingData, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthenticatedUserRequirement { + pub required: bool, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TargetScope { + Instance, + Window, + Tab, + Pane, + Session, + Settings, + Appearance, + Surface, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ActionImplementationStatus { + Implemented, + Stub, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ActionMetadata { + pub kind: ActionKind, + pub name: String, + pub implementation_status: ActionImplementationStatus, + pub risk_tier: RiskTier, + pub state_data_category: StateDataCategory, + pub requires_authenticated_user: bool, + pub authenticated_user: AuthenticatedUserRequirement, + pub allowed_invocation_contexts: Vec<InvocationContext>, + pub permission_category: PermissionCategory, + pub target_scope: TargetScope, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ActionKind { + #[serde(rename = "instance.list")] + InstanceList, + #[serde(rename = "app.ping")] + AppPing, + #[serde(rename = "app.inspect")] + AppInspect, + #[serde(rename = "app.version")] + AppVersion, + #[serde(rename = "app.active")] + AppActive, + #[serde(rename = "app.focus")] + AppFocus, + #[serde(rename = "app.settings.open")] + AppSettingsOpen, + #[serde(rename = "app.command_palette.open")] + AppCommandPaletteOpen, + #[serde(rename = "app.command_search.open")] + AppCommandSearchOpen, + #[serde(rename = "app.warp_drive.open")] + AppWarpDriveOpen, + #[serde(rename = "app.warp_drive.toggle")] + AppWarpDriveToggle, + #[serde(rename = "app.resource_center.toggle")] + AppResourceCenterToggle, + #[serde(rename = "app.ai_assistant.toggle")] + AppAiAssistantToggle, + #[serde(rename = "app.code_review.toggle")] + AppCodeReviewToggle, + #[serde(rename = "app.vertical_tabs.toggle")] + AppVerticalTabsToggle, + #[serde(rename = "window.list")] + WindowList, + #[serde(rename = "window.create")] + WindowCreate, + #[serde(rename = "window.focus")] + WindowFocus, + #[serde(rename = "window.close")] + WindowClose, + #[serde(rename = "tab.list")] + TabList, + #[serde(rename = "tab.create")] + TabCreate, + #[serde(rename = "tab.activate")] + TabActivate, + #[serde(rename = "tab.move")] + TabMove, + #[serde(rename = "tab.rename")] + TabRename, + #[serde(rename = "tab.close")] + TabClose, + #[serde(rename = "pane.list")] + PaneList, + #[serde(rename = "pane.split")] + PaneSplit, + #[serde(rename = "pane.focus")] + PaneFocus, + #[serde(rename = "pane.navigate")] + PaneNavigate, + #[serde(rename = "pane.close")] + PaneClose, + #[serde(rename = "pane.maximize")] + PaneMaximize, + #[serde(rename = "pane.resize")] + PaneResize, + #[serde(rename = "pane.session.previous")] + PaneSessionPrevious, + #[serde(rename = "pane.session.next")] + PaneSessionNext, + #[serde(rename = "session.list")] + SessionList, + #[serde(rename = "input.insert")] + InputInsert, + #[serde(rename = "input.replace")] + InputReplace, + #[serde(rename = "input.clear")] + InputClear, + #[serde(rename = "input.mode.set")] + InputModeSet, + #[serde(rename = "theme.list")] + ThemeList, + #[serde(rename = "theme.set")] + ThemeSet, + #[serde(rename = "appearance.get")] + AppearanceGet, + #[serde(rename = "appearance.set")] + AppearanceSet, + #[serde(rename = "appearance.font_size")] + AppearanceFontSize, + #[serde(rename = "appearance.zoom")] + AppearanceZoom, + #[serde(rename = "setting.get")] + SettingGet, + #[serde(rename = "setting.list")] + SettingList, + #[serde(rename = "setting.set")] + SettingSet, + #[serde(rename = "setting.toggle")] + SettingToggle, +} + +impl ActionKind { + pub const ALL: &[Self] = &[ + Self::InstanceList, + Self::AppPing, + Self::AppInspect, + Self::AppVersion, + Self::AppActive, + Self::AppFocus, + Self::AppSettingsOpen, + Self::AppCommandPaletteOpen, + Self::AppCommandSearchOpen, + Self::AppWarpDriveOpen, + Self::AppWarpDriveToggle, + Self::AppResourceCenterToggle, + Self::AppAiAssistantToggle, + Self::AppCodeReviewToggle, + Self::AppVerticalTabsToggle, + Self::WindowList, + Self::WindowCreate, + Self::WindowFocus, + Self::WindowClose, + Self::TabList, + Self::TabCreate, + Self::TabActivate, + Self::TabMove, + Self::TabRename, + Self::TabClose, + Self::PaneList, + Self::PaneSplit, + Self::PaneFocus, + Self::PaneNavigate, + Self::PaneClose, + Self::PaneMaximize, + Self::PaneResize, + Self::PaneSessionPrevious, + Self::PaneSessionNext, + Self::SessionList, + Self::InputInsert, + Self::InputReplace, + Self::InputClear, + Self::InputModeSet, + Self::ThemeList, + Self::ThemeSet, + Self::AppearanceGet, + Self::AppearanceSet, + Self::AppearanceFontSize, + Self::AppearanceZoom, + Self::SettingGet, + Self::SettingList, + Self::SettingSet, + Self::SettingToggle, + ]; + pub fn as_str(self) -> &'static str { + match self { + Self::InstanceList => "instance.list", + Self::AppPing => "app.ping", + Self::AppInspect => "app.inspect", + Self::AppVersion => "app.version", + Self::AppActive => "app.active", + Self::AppFocus => "app.focus", + Self::AppSettingsOpen => "app.settings.open", + Self::AppCommandPaletteOpen => "app.command_palette.open", + Self::AppCommandSearchOpen => "app.command_search.open", + Self::AppWarpDriveOpen => "app.warp_drive.open", + Self::AppWarpDriveToggle => "app.warp_drive.toggle", + Self::AppResourceCenterToggle => "app.resource_center.toggle", + Self::AppAiAssistantToggle => "app.ai_assistant.toggle", + Self::AppCodeReviewToggle => "app.code_review.toggle", + Self::AppVerticalTabsToggle => "app.vertical_tabs.toggle", + Self::WindowList => "window.list", + Self::WindowCreate => "window.create", + Self::WindowFocus => "window.focus", + Self::WindowClose => "window.close", + Self::TabList => "tab.list", + Self::TabCreate => "tab.create", + Self::TabActivate => "tab.activate", + Self::TabMove => "tab.move", + Self::TabRename => "tab.rename", + Self::TabClose => "tab.close", + Self::PaneList => "pane.list", + Self::PaneSplit => "pane.split", + Self::PaneFocus => "pane.focus", + Self::PaneNavigate => "pane.navigate", + Self::PaneClose => "pane.close", + Self::PaneMaximize => "pane.maximize", + Self::PaneResize => "pane.resize", + Self::PaneSessionPrevious => "pane.session.previous", + Self::PaneSessionNext => "pane.session.next", + Self::SessionList => "session.list", + Self::InputInsert => "input.insert", + Self::InputReplace => "input.replace", + Self::InputClear => "input.clear", + Self::InputModeSet => "input.mode.set", + Self::ThemeList => "theme.list", + Self::ThemeSet => "theme.set", + Self::AppearanceGet => "appearance.get", + Self::AppearanceSet => "appearance.set", + Self::AppearanceFontSize => "appearance.font_size", + Self::AppearanceZoom => "appearance.zoom", + Self::SettingGet => "setting.get", + Self::SettingList => "setting.list", + Self::SettingSet => "setting.set", + Self::SettingToggle => "setting.toggle", + } + } + + pub fn metadata(self) -> ActionMetadata { + let (implementation_status, requires_authenticated_user, allowed_invocation_contexts) = + match self { + Self::InstanceList | Self::AppPing | Self::AppVersion | Self::TabCreate => ( + ActionImplementationStatus::Implemented, + false, + vec![ + InvocationContext::InsideWarp, + InvocationContext::OutsideWarp, + ], + ), + _ => (ActionImplementationStatus::Stub, true, Vec::new()), + }; + ActionMetadata { + kind: self, + name: self.as_str().to_owned(), + implementation_status, + risk_tier: self.default_risk_tier(), + state_data_category: self.default_state_data_category(), + requires_authenticated_user, + authenticated_user: AuthenticatedUserRequirement { + required: requires_authenticated_user, + }, + allowed_invocation_contexts, + permission_category: self.default_permission_category(), + target_scope: self.default_target_scope(), + } + } + + pub fn implemented_metadata() -> Vec<ActionMetadata> { + Self::ALL + .iter() + .copied() + .map(Self::metadata) + .filter(|metadata| { + metadata.implementation_status == ActionImplementationStatus::Implemented + }) + .collect() + } + + pub fn is_implemented(self) -> bool { + self.metadata().implementation_status == ActionImplementationStatus::Implemented + } + + fn default_risk_tier(self) -> RiskTier { + match self { + Self::InstanceList + | Self::AppPing + | Self::AppInspect + | Self::AppVersion + | Self::AppActive + | Self::WindowList + | Self::TabList + | Self::PaneList + | Self::SessionList + | Self::ThemeList + | Self::AppearanceGet + | Self::SettingGet + | Self::SettingList => RiskTier::ReadOnlyMetadata, + Self::InputInsert + | Self::InputReplace + | Self::InputClear + | Self::InputModeSet + | Self::WindowClose + | Self::TabClose + | Self::PaneClose => RiskTier::MutatingDestructiveOrExecution, + Self::AppFocus + | Self::AppSettingsOpen + | Self::AppCommandPaletteOpen + | Self::AppCommandSearchOpen + | Self::AppWarpDriveOpen + | Self::AppWarpDriveToggle + | Self::AppResourceCenterToggle + | Self::AppAiAssistantToggle + | Self::AppCodeReviewToggle + | Self::AppVerticalTabsToggle + | Self::WindowCreate + | Self::WindowFocus + | Self::TabCreate + | Self::TabActivate + | Self::TabMove + | Self::TabRename + | Self::PaneSplit + | Self::PaneFocus + | Self::PaneNavigate + | Self::PaneMaximize + | Self::PaneResize + | Self::PaneSessionPrevious + | Self::PaneSessionNext + | Self::ThemeSet + | Self::AppearanceSet + | Self::AppearanceFontSize + | Self::AppearanceZoom + | Self::SettingSet + | Self::SettingToggle => RiskTier::MutatingNonDestructive, + } + } + + fn default_state_data_category(self) -> StateDataCategory { + match self { + Self::InstanceList + | Self::AppPing + | Self::AppInspect + | Self::AppVersion + | Self::AppActive + | Self::WindowList + | Self::TabList + | Self::PaneList + | Self::SessionList + | Self::ThemeList + | Self::AppearanceGet + | Self::SettingGet + | Self::SettingList => StateDataCategory::MetadataRead, + Self::SettingSet + | Self::SettingToggle + | Self::ThemeSet + | Self::AppearanceSet + | Self::AppearanceFontSize + | Self::AppearanceZoom => StateDataCategory::MetadataConfigurationMutation, + Self::InputInsert | Self::InputReplace | Self::InputClear | Self::InputModeSet => { + StateDataCategory::UnderlyingDataMutation + } + Self::AppFocus + | Self::AppSettingsOpen + | Self::AppCommandPaletteOpen + | Self::AppCommandSearchOpen + | Self::AppWarpDriveOpen + | Self::AppWarpDriveToggle + | Self::AppResourceCenterToggle + | Self::AppAiAssistantToggle + | Self::AppCodeReviewToggle + | Self::AppVerticalTabsToggle + | Self::WindowCreate + | Self::WindowFocus + | Self::WindowClose + | Self::TabCreate + | Self::TabActivate + | Self::TabMove + | Self::TabRename + | Self::TabClose + | Self::PaneSplit + | Self::PaneFocus + | Self::PaneNavigate + | Self::PaneClose + | Self::PaneMaximize + | Self::PaneResize + | Self::PaneSessionPrevious + | Self::PaneSessionNext => StateDataCategory::AppStateMutation, + } + } + + fn default_permission_category(self) -> PermissionCategory { + match self.default_state_data_category() { + StateDataCategory::MetadataRead => PermissionCategory::ReadMetadata, + StateDataCategory::UnderlyingDataRead => PermissionCategory::ReadUnderlyingData, + StateDataCategory::AppStateMutation => PermissionCategory::MutateAppState, + StateDataCategory::MetadataConfigurationMutation => { + PermissionCategory::MutateMetadataConfiguration + } + StateDataCategory::UnderlyingDataMutation => PermissionCategory::MutateUnderlyingData, + } + } + fn default_target_scope(self) -> TargetScope { + match self { + Self::WindowList | Self::WindowCreate | Self::WindowFocus | Self::WindowClose => { + TargetScope::Window + } + Self::TabList + | Self::TabCreate + | Self::TabActivate + | Self::TabMove + | Self::TabRename + | Self::TabClose => TargetScope::Tab, + Self::PaneList + | Self::PaneSplit + | Self::PaneFocus + | Self::PaneNavigate + | Self::PaneClose + | Self::PaneMaximize + | Self::PaneResize + | Self::PaneSessionPrevious + | Self::PaneSessionNext => TargetScope::Pane, + Self::SessionList + | Self::InputInsert + | Self::InputReplace + | Self::InputClear + | Self::InputModeSet => TargetScope::Session, + Self::ThemeList + | Self::ThemeSet + | Self::AppearanceGet + | Self::AppearanceSet + | Self::AppearanceFontSize + | Self::AppearanceZoom => TargetScope::Appearance, + Self::SettingGet | Self::SettingList | Self::SettingSet | Self::SettingToggle => { + TargetScope::Settings + } + Self::AppSettingsOpen + | Self::AppCommandPaletteOpen + | Self::AppCommandSearchOpen + | Self::AppWarpDriveOpen + | Self::AppWarpDriveToggle + | Self::AppResourceCenterToggle + | Self::AppAiAssistantToggle + | Self::AppCodeReviewToggle + | Self::AppVerticalTabsToggle => TargetScope::Surface, + Self::InstanceList + | Self::AppPing + | Self::AppInspect + | Self::AppVersion + | Self::AppActive + | Self::AppFocus => TargetScope::Instance, + } + } +} diff --git a/crates/local_control/src/client.rs b/crates/local_control/src/client.rs new file mode 100644 index 0000000000..90dd10f016 --- /dev/null +++ b/crates/local_control/src/client.rs @@ -0,0 +1,103 @@ +use crate::auth::{CredentialRequest, ScopedCredential}; +use crate::discovery::InstanceRecord; +use crate::protocol::{ + ControlError, ControlResponse, ErrorCode, ErrorResponseEnvelope, InvocationContext, + RequestEnvelope, ResponseEnvelope, +}; + +pub fn send_request( + instance: &InstanceRecord, + request: &RequestEnvelope, +) -> Result<ResponseEnvelope, ControlError> { + let credential = request_credential( + instance, + request.action.kind, + InvocationContext::OutsideWarp, + )?; + let endpoint = instance.endpoint.as_ref().ok_or_else(|| { + ControlError::new( + ErrorCode::LocalControlDisabled, + "outside-Warp local control endpoint is disabled for this instance", + ) + })?; + let client = reqwest::blocking::Client::new(); + let response = client + .post(endpoint.url()) + .header("Authorization", credential.authorization_value()) + .json(request) + .send() + .map_err(|err| { + ControlError::with_details( + ErrorCode::TransportUnavailable, + "failed to send local-control request", + err.to_string(), + ) + })?; + let status = response.status(); + let text = response.text().map_err(|err| { + ControlError::with_details( + ErrorCode::TransportUnavailable, + "failed to read local-control response", + err.to_string(), + ) + })?; + if let Ok(envelope) = serde_json::from_str::<ResponseEnvelope>(&text) { + if let ControlResponse::Error { error } = &envelope.response { + return Err(error.clone()); + } + return Ok(envelope); + } + if let Ok(envelope) = serde_json::from_str::<ErrorResponseEnvelope>(&text) { + return Err(envelope.error); + } + Err(ControlError::with_details( + ErrorCode::TransportUnavailable, + format!("local-control request failed with HTTP {status}"), + text, + )) +} + +pub fn request_credential( + instance: &InstanceRecord, + action: crate::protocol::ActionKind, + invocation_context: InvocationContext, +) -> Result<ScopedCredential, ControlError> { + let credential_broker = instance.credential_broker.as_ref().ok_or_else(|| { + ControlError::new( + ErrorCode::LocalControlDisabled, + "outside-Warp local control credential broker is disabled for this instance", + ) + })?; + let client = reqwest::blocking::Client::new(); + let request = CredentialRequest::new(action, invocation_context); + let response = client + .post(credential_broker.endpoint.credential_url()) + .json(&request) + .send() + .map_err(|err| { + ControlError::with_details( + ErrorCode::TransportUnavailable, + "failed to request local-control credential", + err.to_string(), + ) + })?; + let status = response.status(); + let text = response.text().map_err(|err| { + ControlError::with_details( + ErrorCode::TransportUnavailable, + "failed to read local-control credential response", + err.to_string(), + ) + })?; + if let Ok(credential) = serde_json::from_str::<ScopedCredential>(&text) { + return Ok(credential); + } + if let Ok(envelope) = serde_json::from_str::<ErrorResponseEnvelope>(&text) { + return Err(envelope.error); + } + Err(ControlError::with_details( + ErrorCode::TransportUnavailable, + format!("local-control credential request failed with HTTP {status}"), + text, + )) +} diff --git a/crates/local_control/src/discovery.rs b/crates/local_control/src/discovery.rs new file mode 100644 index 0000000000..e4f1ff2df3 --- /dev/null +++ b/crates/local_control/src/discovery.rs @@ -0,0 +1,281 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::protocol::{ActionMetadata, ControlError, ErrorCode, PROTOCOL_VERSION}; + +const DISCOVERY_DIR_ENV: &str = "WARP_LOCAL_CONTROL_DISCOVERY_DIR"; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct InstanceId(pub String); + +impl InstanceId { + pub fn new() -> Self { + Self(format!("inst_{}", uuid::Uuid::new_v4().simple())) + } +} + +impl Default for InstanceId { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ControlEndpoint { + pub host: String, + pub port: u16, +} + +impl ControlEndpoint { + pub fn localhost(port: u16) -> Self { + Self { + host: "127.0.0.1".to_owned(), + port, + } + } + + pub fn url(&self) -> String { + format!("http://{}:{}/v1/control", self.host, self.port) + } + + pub fn credential_url(&self) -> String { + format!("http://{}:{}/v1/control/credentials", self.host, self.port) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CredentialBrokerReference { + pub endpoint: ControlEndpoint, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InstanceRecord { + pub protocol_version: u32, + pub instance_id: InstanceId, + pub pid: u32, + pub channel: String, + pub app_id: String, + pub app_version: Option<String>, + pub started_at: DateTime<Utc>, + pub executable_path: Option<PathBuf>, + pub endpoint: Option<ControlEndpoint>, + pub credential_broker: Option<CredentialBrokerReference>, + pub outside_warp_control_enabled: bool, + pub actions: Vec<ActionMetadata>, +} + +impl InstanceRecord { + pub fn for_current_process( + endpoint: Option<ControlEndpoint>, + channel: impl Into<String>, + app_id: impl Into<String>, + app_version: Option<String>, + actions: Vec<ActionMetadata>, + ) -> Self { + let credential_broker = endpoint + .clone() + .map(|endpoint| CredentialBrokerReference { endpoint }); + Self { + protocol_version: PROTOCOL_VERSION, + instance_id: InstanceId::new(), + pid: std::process::id(), + channel: channel.into(), + app_id: app_id.into(), + app_version, + started_at: Utc::now(), + executable_path: std::env::current_exe().ok(), + outside_warp_control_enabled: endpoint.is_some(), + credential_broker, + endpoint, + actions, + } + } +} + +pub struct RegisteredInstance { + record: InstanceRecord, + path: PathBuf, +} + +impl RegisteredInstance { + pub fn register(record: InstanceRecord) -> Result<Self, ControlError> { + let dir = discovery_dir(); + fs::create_dir_all(&dir).map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to create local-control discovery directory", + err.to_string(), + ) + })?; + let path = record_path(&dir, &record.instance_id); + let bytes = serde_json::to_vec_pretty(&record).map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to serialize local-control discovery record", + err.to_string(), + ) + })?; + fs::write(&path, bytes).map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to write local-control discovery record", + err.to_string(), + ) + })?; + set_private_permissions(&path); + Ok(Self { record, path }) + } + + pub fn record(&self) -> &InstanceRecord { + &self.record + } +} + +impl Drop for RegisteredInstance { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +pub fn discovery_dir() -> PathBuf { + if let Some(path) = std::env::var_os(DISCOVERY_DIR_ENV) { + return PathBuf::from(path); + } + if let Some(path) = std::env::var_os("XDG_RUNTIME_DIR") { + return PathBuf::from(path).join("warp").join("local-control"); + } + let home = std::env::var_os("HOME").unwrap_or_else(|| ".".into()); + PathBuf::from(home).join(".warp").join("local-control") +} + +pub fn list_instances() -> Vec<InstanceRecord> { + list_instances_from_dir(&discovery_dir()) +} + +pub fn list_instances_from_dir(dir: &Path) -> Vec<InstanceRecord> { + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + let mut records = Vec::new(); + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + let contents = match fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => continue, + }; + let record = match serde_json::from_str::<InstanceRecord>(&contents) { + Ok(r) => r, + Err(_) => continue, + }; + if record.protocol_version != PROTOCOL_VERSION { + continue; + } + if !is_pid_alive(record.pid) { + let _ = fs::remove_file(&path); + continue; + } + records.push(record); + } + records.sort_by_key(|record| record.started_at); + records +} + +#[cfg(unix)] +fn is_pid_alive(pid: u32) -> bool { + unsafe { libc::kill(pid as libc::pid_t, 0) == 0 } +} + +#[cfg(not(unix))] +fn is_pid_alive(pid: u32) -> bool { + std::process::Command::new("tasklist") + .args(["/FI", &format!("PID eq {pid}"), "/NH"]) + .output() + .map(|o| !String::from_utf8_lossy(&o.stdout).contains("No tasks")) + .unwrap_or(true) +} + +fn record_path(dir: &Path, instance_id: &InstanceId) -> PathBuf { + dir.join(format!("{}.json", instance_id.0)) +} + +#[cfg(unix)] +fn set_private_permissions(path: &Path) { + use std::os::unix::fs::PermissionsExt as _; + + if let Ok(metadata) = fs::metadata(path) { + let mut permissions = metadata.permissions(); + permissions.set_mode(0o600); + let _ = fs::set_permissions(path, permissions); + } +} + +#[cfg(not(unix))] +fn set_private_permissions(_path: &Path) {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registered_instance_round_trips_discovery_record() { + let dir = tempfile::tempdir().expect("temp dir"); + let record = InstanceRecord::for_current_process( + Some(ControlEndpoint::localhost(4000)), + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + let _registered = RegisteredInstance::register_in_dir_for_test(record.clone(), dir.path()) + .expect("registered"); + let records = list_instances_from_dir(dir.path()); + assert_eq!(records, vec![record]); + } + + #[test] + fn serialized_discovery_record_does_not_contain_raw_credential_material() { + let raw_secret = "raw-secret-token-material"; + let record = InstanceRecord::for_current_process( + Some(ControlEndpoint::localhost(4000)), + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + let serialized = serde_json::to_string_pretty(&record).expect("serialize"); + assert!(!serialized.contains(raw_secret)); + assert!(!serialized.contains("auth_token")); + assert!(!serialized.contains("bearer_token")); + } + + #[test] + fn disabled_outside_warp_record_does_not_expose_actionable_authority() { + let record = InstanceRecord::for_current_process( + None, + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + assert!(!record.outside_warp_control_enabled); + assert!(record.endpoint.is_none()); + assert!(record.credential_broker.is_none()); + } + + impl RegisteredInstance { + fn register_in_dir_for_test( + record: InstanceRecord, + dir: &Path, + ) -> Result<Self, ControlError> { + fs::create_dir_all(dir).expect("create dir"); + let path = record_path(dir, &record.instance_id); + let bytes = serde_json::to_vec_pretty(&record).expect("serialize"); + fs::write(&path, bytes).expect("write"); + Ok(Self { record, path }) + } + } +} diff --git a/crates/local_control/src/lib.rs b/crates/local_control/src/lib.rs new file mode 100644 index 0000000000..abb368b88b --- /dev/null +++ b/crates/local_control/src/lib.rs @@ -0,0 +1,24 @@ +pub mod auth; +pub mod catalog; +pub mod client; +pub mod discovery; +pub mod protocol; +pub mod selection; +pub mod selectors; + +pub use auth::{ + AuthToken, AuthenticatedUserGrant, CredentialGrant, CredentialRequest, ScopedCredential, +}; +pub use catalog::{ + ActionImplementationStatus, ActionKind, ActionMetadata, AuthenticatedUserRequirement, + InvocationContext, PermissionCategory, RiskTier, StateDataCategory, TargetScope, +}; +pub use discovery::{ + ControlEndpoint, CredentialBrokerReference, InstanceId, InstanceRecord, RegisteredInstance, + discovery_dir, +}; +pub use protocol::{ + Action, ControlError, ControlResponse, ErrorCode, ErrorResponseEnvelope, ExecutionContextProof, + PROTOCOL_VERSION, RequestEnvelope, ResponseEnvelope, +}; +pub use selectors::{PaneSelector, TabSelector, TargetSelector, WindowSelector}; diff --git a/crates/local_control/src/protocol.rs b/crates/local_control/src/protocol.rs new file mode 100644 index 0000000000..defb1ca072 --- /dev/null +++ b/crates/local_control/src/protocol.rs @@ -0,0 +1,164 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +pub use crate::catalog::{ + ActionImplementationStatus, ActionKind, ActionMetadata, AuthenticatedUserRequirement, + ExecutionContextProof, InvocationContext, PROTOCOL_VERSION, PermissionCategory, RiskTier, + StateDataCategory, TargetScope, +}; +pub use crate::selectors::{ + PaneSelector, PaneTarget, TabSelector, TabTarget, TargetSelector, WindowSelector, WindowTarget, +}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RequestEnvelope { + pub protocol_version: u32, + pub request_id: Uuid, + #[serde(default)] + pub target: TargetSelector, + pub action: Action, +} + +impl RequestEnvelope { + pub fn new(action: Action) -> Self { + Self { + protocol_version: PROTOCOL_VERSION, + request_id: Uuid::new_v4(), + target: TargetSelector::default(), + action, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Action { + pub kind: ActionKind, + #[serde(default)] + pub params: serde_json::Value, +} + +impl Action { + pub fn new(kind: ActionKind) -> Self { + Self { + kind, + params: serde_json::Value::Object(Default::default()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ResponseEnvelope { + pub protocol_version: u32, + pub request_id: Uuid, + pub response: ControlResponse, +} + +impl ResponseEnvelope { + pub fn ok(request_id: Uuid, data: serde_json::Value) -> Self { + Self { + protocol_version: PROTOCOL_VERSION, + request_id, + response: ControlResponse::Ok { data }, + } + } + + pub fn error(request_id: Uuid, error: ControlError) -> Self { + Self { + protocol_version: PROTOCOL_VERSION, + request_id, + response: ControlResponse::Error { error }, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum ControlResponse { + Ok { data: serde_json::Value }, + Error { error: ControlError }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ErrorResponseEnvelope { + pub protocol_version: u32, + pub error: ControlError, +} + +impl ErrorResponseEnvelope { + pub fn new(error: ControlError) -> Self { + Self { + protocol_version: PROTOCOL_VERSION, + error, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)] +#[error("{code}: {message}")] +pub struct ControlError { + pub code: ErrorCode, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option<String>, +} + +impl ControlError { + pub fn new(code: ErrorCode, message: impl Into<String>) -> Self { + Self { + code, + message: message.into(), + details: None, + } + } + + pub fn with_details( + code: ErrorCode, + message: impl Into<String>, + details: impl Into<String>, + ) -> Self { + Self { + code, + message: message.into(), + details: Some(details.into()), + } + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorCode { + LocalControlDisabled, + UnauthorizedLocalClient, + InsufficientPermissions, + AuthenticatedUserRequired, + AuthenticatedUserUnavailable, + ExecutionContextNotAllowed, + ProtocolVersionUnsupported, + InvalidRequest, + InvalidSelector, + InvalidParams, + NoInstance, + AmbiguousInstance, + StaleTarget, + TargetStateConflict, + MissingTarget, + TransportUnavailable, + BridgeUnavailable, + UnsupportedAction, + NotAllowlisted, + Internal, +} + +impl std::fmt::Display for ErrorCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let value = serde_json::to_value(self).map_err(|_| std::fmt::Error)?; + let Some(value) = value.as_str() else { + return Err(std::fmt::Error); + }; + f.write_str(value) + } +} + +#[cfg(test)] +#[path = "protocol_tests.rs"] +mod tests; diff --git a/crates/local_control/src/protocol_tests.rs b/crates/local_control/src/protocol_tests.rs new file mode 100644 index 0000000000..9c943d0c71 --- /dev/null +++ b/crates/local_control/src/protocol_tests.rs @@ -0,0 +1,133 @@ +use super::*; + +#[test] +fn request_envelope_serializes_stable_action_names() { + let request = RequestEnvelope::new(Action::new(ActionKind::WindowFocus)); + let value = serde_json::to_value(&request).expect("request serializes"); + assert_eq!(value["protocol_version"], PROTOCOL_VERSION); + assert_eq!(value["action"]["kind"], "window.focus"); +} + +#[test] +fn response_error_serializes_machine_code() { + let response = ResponseEnvelope::error( + Uuid::nil(), + ControlError::new(ErrorCode::UnauthorizedLocalClient, "bad token"), + ); + let value = serde_json::to_value(&response).expect("response serializes"); + assert_eq!(value["response"]["status"], "error"); + assert_eq!( + value["response"]["error"]["code"], + "unauthorized_local_client" + ); +} + +#[test] +fn input_run_is_not_in_the_allowlisted_catalog() { + let action = serde_json::from_value::<ActionKind>(serde_json::json!("input.run")); + assert!(action.is_err()); +} +#[test] +fn malformed_action_name_is_not_deserialized() { + let action = serde_json::from_value::<ActionKind>(serde_json::json!("tab.create.extra")); + assert!(action.is_err()); +} + +#[test] +fn tab_create_metadata_is_first_slice_logged_out_safe_mutation() { + let metadata = ActionKind::TabCreate.metadata(); + assert_eq!( + metadata.implementation_status, + ActionImplementationStatus::Implemented + ); + assert_eq!(metadata.risk_tier, RiskTier::MutatingNonDestructive); + assert_eq!( + metadata.state_data_category, + StateDataCategory::AppStateMutation + ); + assert!(!metadata.requires_authenticated_user); + assert!(!metadata.authenticated_user.required); + assert_eq!( + metadata.permission_category, + PermissionCategory::MutateAppState + ); + assert_eq!( + metadata.allowed_invocation_contexts, + vec![ + InvocationContext::InsideWarp, + InvocationContext::OutsideWarp + ] + ); +} + +#[test] +fn core_smoke_metadata_has_explicit_read_metadata_category() { + for action in [ + ActionKind::InstanceList, + ActionKind::AppPing, + ActionKind::AppVersion, + ] { + let metadata = action.metadata(); + assert_eq!( + metadata.implementation_status, + ActionImplementationStatus::Implemented + ); + assert_eq!(metadata.risk_tier, RiskTier::ReadOnlyMetadata); + assert_eq!( + metadata.state_data_category, + StateDataCategory::MetadataRead + ); + assert_eq!( + metadata.permission_category, + PermissionCategory::ReadMetadata + ); + assert!(!metadata.authenticated_user.required); + assert_eq!(metadata.target_scope, TargetScope::Instance); + } +} + +#[test] +fn action_metadata_serializes_security_categories() { + let metadata = ActionKind::TabCreate.metadata(); + let value = serde_json::to_value(metadata).expect("metadata serializes"); + assert_eq!(value["name"], "tab.create"); + assert_eq!(value["state_data_category"], "app_state_mutation"); + assert_eq!(value["permission_category"], "mutate_app_state"); + assert_eq!( + value["authenticated_user"]["required"], + serde_json::json!(false) + ); +} + +#[test] +fn default_permissions_preserve_security_categories() { + assert_eq!( + ActionKind::TabCreate.metadata().permission_category, + PermissionCategory::MutateAppState + ); + assert_eq!( + ActionKind::InputInsert.metadata().permission_category, + PermissionCategory::MutateUnderlyingData + ); + assert_eq!( + ActionKind::SettingSet.metadata().permission_category, + PermissionCategory::MutateMetadataConfiguration + ); + assert_eq!( + ActionKind::TabList.metadata().permission_category, + PermissionCategory::ReadMetadata + ); +} +#[test] +fn non_first_slice_actions_are_catalog_stubs() { + let metadata = ActionKind::WindowCreate.metadata(); + assert_eq!( + metadata.implementation_status, + ActionImplementationStatus::Stub + ); + assert!( + !metadata + .allowed_invocation_contexts + .contains(&InvocationContext::OutsideWarp) + ); +} diff --git a/crates/local_control/src/selection.rs b/crates/local_control/src/selection.rs new file mode 100644 index 0000000000..bf9d45a89b --- /dev/null +++ b/crates/local_control/src/selection.rs @@ -0,0 +1,101 @@ +use crate::discovery::{InstanceId, InstanceRecord}; +use crate::protocol::{ControlError, ErrorCode}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InstanceSelector { + Active, + Id(InstanceId), + Pid(u32), +} + +pub fn select_instance( + records: &[InstanceRecord], + selector: &InstanceSelector, +) -> Result<InstanceRecord, ControlError> { + match selector { + InstanceSelector::Active => select_active(records), + InstanceSelector::Id(instance_id) => records + .iter() + .find(|record| &record.instance_id == instance_id) + .cloned() + .ok_or_else(|| { + ControlError::new( + ErrorCode::NoInstance, + format!("no Warp instance with id {}", instance_id.0), + ) + }), + InstanceSelector::Pid(pid) => records + .iter() + .find(|record| record.pid == *pid) + .cloned() + .ok_or_else(|| { + ControlError::new( + ErrorCode::NoInstance, + format!("no Warp instance with pid {pid}"), + ) + }), + } +} + +fn select_active(records: &[InstanceRecord]) -> Result<InstanceRecord, ControlError> { + match records { + [] => Err(ControlError::new( + ErrorCode::NoInstance, + "no local Warp control instances were discovered", + )), + [record] => Ok(record.clone()), + _ => Err(ControlError::new( + ErrorCode::AmbiguousInstance, + "multiple local Warp control instances were discovered; pass --instance", + )), + } +} + +#[cfg(test)] +mod tests { + use chrono::Utc; + + use super::*; + use crate::discovery::ControlEndpoint; + use crate::protocol::{ActionKind, PROTOCOL_VERSION}; + + fn record(id: &str, pid: u32) -> InstanceRecord { + InstanceRecord { + protocol_version: PROTOCOL_VERSION, + instance_id: InstanceId(id.to_owned()), + pid, + channel: "local".to_owned(), + app_id: "dev.warp.WarpLocal".to_owned(), + app_version: None, + started_at: Utc::now(), + executable_path: None, + endpoint: Some(ControlEndpoint::localhost(4000)), + credential_broker: Some(crate::discovery::CredentialBrokerReference { + endpoint: ControlEndpoint::localhost(4000), + }), + outside_warp_control_enabled: true, + actions: vec![ActionKind::TabCreate.metadata()], + } + } + + #[test] + fn selects_instance_by_id() { + let records = vec![record("one", 1), record("two", 2)]; + let selected = select_instance(&records, &InstanceSelector::Id(InstanceId("two".into()))) + .expect("selected"); + assert_eq!(selected.pid, 2); + } + + #[test] + fn active_selector_rejects_ambiguity() { + let records = vec![record("one", 1), record("two", 2)]; + let err = select_instance(&records, &InstanceSelector::Active).expect_err("ambiguous"); + assert_eq!(err.code, ErrorCode::AmbiguousInstance); + } + + #[test] + fn active_selector_rejects_no_instances() { + let err = select_instance(&[], &InstanceSelector::Active).expect_err("no instance"); + assert_eq!(err.code, ErrorCode::NoInstance); + } +} diff --git a/crates/local_control/src/selectors.rs b/crates/local_control/src/selectors.rs new file mode 100644 index 0000000000..8dcfaba456 --- /dev/null +++ b/crates/local_control/src/selectors.rs @@ -0,0 +1,50 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct WindowSelector(pub String); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct TabSelector(pub String); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PaneSelector(pub String); + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct TargetSelector { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub window: Option<WindowTarget>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab: Option<TabTarget>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane: Option<PaneTarget>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum WindowTarget { + Active, + Id { id: WindowSelector }, + Index { index: u32 }, + Title { title: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum TabTarget { + Active, + Id { id: TabSelector }, + Index { index: u32 }, + Title { title: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PaneTarget { + Active, + Id { id: PaneSelector }, + Index { index: u32 }, +} diff --git a/crates/warp_cli/Cargo.toml b/crates/warp_cli/Cargo.toml index ca404baadd..9a642be108 100644 --- a/crates/warp_cli/Cargo.toml +++ b/crates/warp_cli/Cargo.toml @@ -13,6 +13,7 @@ cfg-if = { workspace = true } humantime.workspace = true jaq-all.workspace = true serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true url = { workspace = true, features = ["serde"] } uuid = { workspace = true } warp_core = { path = "../warp_core" } @@ -20,6 +21,7 @@ color-print = "0.3" warp_util = { path = "../warp_util" } clap_complete = "4.5.58" anyhow.workspace = true +local_control.workspace = true [target.'cfg(windows)'.dependencies] windows = { workspace = true, features = [ @@ -34,5 +36,6 @@ plugin_host = [] integration_tests = [] api_key_authentication = [] + [dev-dependencies] serial_test = "0.8.0" diff --git a/crates/warp_cli/src/bin/warpctrl.rs b/crates/warp_cli/src/bin/warpctrl.rs new file mode 100644 index 0000000000..bda8e50f74 --- /dev/null +++ b/crates/warp_cli/src/bin/warpctrl.rs @@ -0,0 +1,6 @@ +use std::process::ExitCode; + +fn main() -> ExitCode { + let args = warp_cli::local_control::ControlArgs::from_env(); + warp_cli::local_control::run(args) +} diff --git a/crates/warp_cli/src/lib.rs b/crates/warp_cli/src/lib.rs index c2149a6a91..b2a070bdeb 100644 --- a/crates/warp_cli/src/lib.rs +++ b/crates/warp_cli/src/lib.rs @@ -29,6 +29,7 @@ pub mod federate; pub mod harness_support; pub mod integration; pub mod json_filter; +pub mod local_control; pub mod mcp; pub mod model; pub mod provider; diff --git a/crates/warp_cli/src/local_control/commands.rs b/crates/warp_cli/src/local_control/commands.rs new file mode 100644 index 0000000000..32defccc4a --- /dev/null +++ b/crates/warp_cli/src/local_control/commands.rs @@ -0,0 +1,126 @@ +use local_control::protocol::{ + Action, ActionKind, ActionMetadata, ControlError, ErrorCode, RequestEnvelope, +}; +use local_control::selection::select_instance; +use serde::Serialize; +use serde_json::json; + +use crate::agent::OutputFormat; +use crate::local_control::output::{write_json, write_json_line}; +use crate::local_control::selectors::instance_selector; +use crate::local_control::{AppCommand, InstanceCommand, TabCommand, TargetArgs}; + +#[derive(Serialize)] +struct InstanceSummary { + instance_id: String, + pid: u32, + channel: String, + app_id: String, + app_version: Option<String>, + started_at: String, + endpoint: Option<local_control::discovery::ControlEndpoint>, + outside_warp_control_enabled: bool, + actions: Vec<ActionMetadata>, +} + +impl From<local_control::discovery::InstanceRecord> for InstanceSummary { + fn from(record: local_control::discovery::InstanceRecord) -> Self { + Self { + instance_id: record.instance_id.0, + pid: record.pid, + channel: record.channel, + app_id: record.app_id, + app_version: record.app_version, + started_at: record.started_at.to_rfc3339(), + endpoint: record.endpoint, + outside_warp_control_enabled: record.outside_warp_control_enabled, + actions: record.actions, + } + } +} + +pub(super) fn run_instance_command( + command: InstanceCommand, + output_format: OutputFormat, +) -> Result<(), ControlError> { + match command { + InstanceCommand::List => { + let summaries = local_control::discovery::list_instances() + .into_iter() + .map(InstanceSummary::from) + .collect::<Vec<_>>(); + match output_format { + OutputFormat::Json => write_json(&summaries), + OutputFormat::Ndjson => { + for summary in summaries { + write_json_line(&summary)?; + } + Ok(()) + } + OutputFormat::Pretty | OutputFormat::Text => { + for summary in summaries { + let endpoint = summary + .endpoint + .as_ref() + .map(|endpoint| format!("{}:{}", endpoint.host, endpoint.port)) + .unwrap_or_else(|| "outside_warp_disabled".to_owned()); + println!( + "{}\tpid={}\t{}\t{}", + summary.instance_id, summary.pid, summary.channel, endpoint + ); + } + Ok(()) + } + } + } + } +} + +pub(super) fn run_app_command( + command: AppCommand, + output_format: OutputFormat, +) -> Result<(), ControlError> { + match command { + AppCommand::Ping(args) => run_action(args, ActionKind::AppPing, json!({}), output_format), + AppCommand::Version(args) => { + run_action(args, ActionKind::AppVersion, json!({}), output_format) + } + } +} +pub(super) fn run_tab_command( + command: TabCommand, + output_format: OutputFormat, +) -> Result<(), ControlError> { + match command { + TabCommand::Create(args) => { + run_action(args, ActionKind::TabCreate, json!({}), output_format) + } + } +} + +fn run_action( + args: TargetArgs, + action: ActionKind, + params: serde_json::Value, + output_format: OutputFormat, +) -> Result<(), ControlError> { + let records = local_control::discovery::list_instances(); + let selector = instance_selector(args); + let instance = select_instance(&records, &selector)?; + let request = RequestEnvelope::new(Action { + kind: action, + params, + }); + let response = local_control::client::send_request(&instance, &request)?; + let local_control::protocol::ControlResponse::Ok { data } = response.response else { + return Err(ControlError::new( + ErrorCode::Internal, + "local-control request failed without an error payload", + )); + }; + match output_format { + OutputFormat::Json => write_json(&data), + OutputFormat::Ndjson => write_json_line(&data), + OutputFormat::Pretty | OutputFormat::Text => write_json(&data), + } +} diff --git a/crates/warp_cli/src/local_control/completions.rs b/crates/warp_cli/src/local_control/completions.rs new file mode 100644 index 0000000000..119318d91a --- /dev/null +++ b/crates/warp_cli/src/local_control/completions.rs @@ -0,0 +1,31 @@ +use clap_complete::aot::{Shell, generate}; +use local_control::protocol::{ControlError, ErrorCode}; + +use crate::local_control::ControlArgs; + +pub(super) fn generate_completions_to_stdout(shell: Option<Shell>) -> Result<(), ControlError> { + let shell = shell.or_else(Shell::from_env).ok_or_else(|| { + ControlError::new( + ErrorCode::InvalidParams, + "could not determine shell from environment; provide a shell argument", + ) + })?; + let mut cmd = ControlArgs::clap_command(); + let bin_name = crate::binary_name().unwrap_or_else(|| "warpctrl".to_owned()); + generate(shell, &mut cmd, bin_name, &mut std::io::stdout()); + Ok(()) +} + +#[cfg(test)] +pub(crate) fn generate_completion_string(shell: Shell) -> Result<String, ControlError> { + let mut cmd = ControlArgs::clap_command(); + let mut output = Vec::new(); + generate(shell, &mut cmd, "warpctrl", &mut output); + String::from_utf8(output).map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to render local-control completions", + err.to_string(), + ) + }) +} diff --git a/crates/warp_cli/src/local_control/mod.rs b/crates/warp_cli/src/local_control/mod.rs new file mode 100644 index 0000000000..6524c75ed5 --- /dev/null +++ b/crates/warp_cli/src/local_control/mod.rs @@ -0,0 +1,163 @@ +mod commands; +mod completions; +mod output; +mod selectors; + +use std::process::ExitCode; + +use crate::agent::OutputFormat; +use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand}; +use clap_complete::aot::Shell; + +use commands::{run_app_command, run_instance_command, run_tab_command}; +use completions::generate_completions_to_stdout; +use output::write_control_error; + +#[derive(Debug, Parser)] +#[command( + name = "warpctrl", + display_name = "warpctrl", + about = "Control a running local Warp app instance" +)] +pub struct ControlArgs { + /// Set the output format. + #[arg( + long = "output-format", + global = true, + value_enum, + default_value_t = OutputFormat::Pretty, + env = "WARP_OUTPUT_FORMAT" + )] + pub output_format: OutputFormat, + + #[command(subcommand)] + pub command: ControlCommand, +} + +impl ControlArgs { + pub fn from_env() -> Self { + let matches = Self::clap_command().get_matches(); + Self::from_arg_matches(&matches).unwrap_or_else(|err| err.exit()) + } + + pub fn clap_command() -> clap::Command { + let bin_name = crate::binary_name().unwrap_or_else(|| "warpctrl".to_owned()); + <Self as CommandFactory>::command() + .version(crate::version_string()) + .bin_name(bin_name.clone()) + .after_help(color_print::cformat!( + r#"<bold><underline>Examples:</underline></bold> + + <dim>$</dim> <bold>{bin_name} instance list</bold> + + <dim>$</dim> <bold>{bin_name} tab create</bold> + +<bold><underline>Learn more:</underline></bold> +* Use <bold>{bin_name} help</bold> to learn more about each command +"# + )) + } +} + +#[derive(Debug, Clone, Subcommand)] +pub enum ControlCommand { + /// Inspect local Warp app instances. + #[command(subcommand)] + Instance(InstanceCommand), + /// Inspect a selected local Warp app. + #[command(subcommand)] + App(AppCommand), + + /// Control local Warp tabs. + #[command(subcommand)] + Tab(TabCommand), + + /// Generate shell completions for your shell to stdout. + /// + /// For bash, add the following to ~/.bashrc: + /// source <(path/to/warpctrl completions bash) + /// + /// For zsh, add the following to ~/.zshrc: + /// source <(path/to/warpctrl completions zsh) + /// + /// For fish, add the following to ~/.config/fish/config.fish: + /// path/to/warpctrl completions fish | source + /// + /// For Powershell, add the following to $PROFILE: + /// path\to\warpctrl completions powershell | Out-String | Invoke-Expression + /// + /// If no shell is provided, this defaults to the shell that Warp was run from. + #[command(verbatim_doc_comment)] + Completions { + /// Shell to generate completions for. + #[arg(value_enum)] + shell: Option<Shell>, + }, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum InstanceCommand { + /// List locally discoverable Warp instances. + List, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum AppCommand { + /// Check that the selected local Warp app responds. + Ping(TargetArgs), + + /// Print protocol and app version metadata for the selected local Warp app. + Version(TargetArgs), +} + +#[derive(Debug, Clone, Subcommand)] +pub enum TabCommand { + /// Create a new terminal tab in the active window. + Create(TargetArgs), +} + +#[derive(Debug, Clone, Args, Default)] +pub struct TargetArgs { + /// Target a specific local Warp instance id from `warp instance list`. + #[arg(long = "instance")] + pub instance: Option<String>, + + /// Target a specific local Warp process id. + #[arg(long = "pid", conflicts_with = "instance")] + pub pid: Option<u32>, +} + +pub fn run(args: ControlArgs) -> ExitCode { + let output_format = args.output_format; + match run_inner(args) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + if let Err(write_error) = write_control_error(&error, output_format) { + eprintln!( + "error: failed to render local-control error: {}", + write_error.message + ); + } + ExitCode::FAILURE + } + } +} + +fn run_inner(args: ControlArgs) -> Result<(), local_control::protocol::ControlError> { + let output_format = args.output_format; + match args.command { + ControlCommand::Instance(command) => run_instance_command(command, output_format), + ControlCommand::App(command) => run_app_command(command, output_format), + ControlCommand::Tab(command) => run_tab_command(command, output_format), + ControlCommand::Completions { shell } => generate_completions_to_stdout(shell), + } +} + +#[cfg(test)] +pub(crate) use completions::generate_completion_string; +#[cfg(test)] +pub(crate) use output::ErrorSummary; + +#[cfg(test)] +#[path = "../local_control_tests.rs"] +mod tests; diff --git a/crates/warp_cli/src/local_control/output.rs b/crates/warp_cli/src/local_control/output.rs new file mode 100644 index 0000000000..99c3af7b3e --- /dev/null +++ b/crates/warp_cli/src/local_control/output.rs @@ -0,0 +1,51 @@ +use std::io::Write as _; + +use local_control::protocol::{ControlError, ErrorCode}; +use serde::Serialize; + +use crate::agent::OutputFormat; + +#[derive(Serialize)] +pub(crate) struct ErrorSummary<'a> { + pub ok: bool, + pub error: &'a ControlError, +} + +pub(super) fn write_control_error( + error: &ControlError, + output_format: OutputFormat, +) -> Result<(), ControlError> { + match output_format { + OutputFormat::Json => write_json(&ErrorSummary { ok: false, error }), + OutputFormat::Ndjson => write_json_line(&ErrorSummary { ok: false, error }), + OutputFormat::Pretty | OutputFormat::Text => { + eprintln!("error: {}: {}", error.code, error.message); + if let Some(details) = &error.details { + eprintln!("details: {details}"); + } + Ok(()) + } + } +} + +pub(super) fn write_json(value: &impl Serialize) -> Result<(), ControlError> { + let stdout = std::io::stdout(); + let mut lock = stdout.lock(); + serde_json::to_writer_pretty(&mut lock, value).map_err(write_error)?; + writeln!(&mut lock).map_err(write_error)?; + Ok(()) +} +pub(super) fn write_json_line(value: &impl Serialize) -> Result<(), ControlError> { + let stdout = std::io::stdout(); + let mut lock = stdout.lock(); + serde_json::to_writer(&mut lock, value).map_err(write_error)?; + writeln!(&mut lock).map_err(write_error)?; + Ok(()) +} +fn write_error(error: impl std::error::Error) -> ControlError { + ControlError::with_details( + ErrorCode::Internal, + "failed to write local-control output", + error.to_string(), + ) +} diff --git a/crates/warp_cli/src/local_control/selectors.rs b/crates/warp_cli/src/local_control/selectors.rs new file mode 100644 index 0000000000..97da547b7a --- /dev/null +++ b/crates/warp_cli/src/local_control/selectors.rs @@ -0,0 +1,13 @@ +use local_control::selection::InstanceSelector; + +use crate::local_control::TargetArgs; + +pub(super) fn instance_selector(args: TargetArgs) -> InstanceSelector { + if let Some(instance_id) = args.instance { + return InstanceSelector::Id(local_control::discovery::InstanceId(instance_id)); + } + if let Some(pid) = args.pid { + return InstanceSelector::Pid(pid); + } + InstanceSelector::Active +} diff --git a/crates/warp_cli/src/local_control_tests.rs b/crates/warp_cli/src/local_control_tests.rs new file mode 100644 index 0000000000..5fe726e447 --- /dev/null +++ b/crates/warp_cli/src/local_control_tests.rs @@ -0,0 +1,110 @@ +use std::ffi::OsString; + +use clap::Parser as _; +use clap_complete::aot::Shell; +use local_control::protocol::{ControlError, ErrorCode}; +use serde_json::json; +use serial_test::serial; + +use super::*; + +const DISCOVERY_DIR_ENV: &str = "WARP_LOCAL_CONTROL_DISCOVERY_DIR"; + +fn set_discovery_dir(path: &std::path::Path) -> Option<OsString> { + let previous = std::env::var_os(DISCOVERY_DIR_ENV); + unsafe { std::env::set_var(DISCOVERY_DIR_ENV, path) }; + previous +} + +fn restore_discovery_dir(previous: Option<OsString>) { + match previous { + Some(value) => unsafe { std::env::set_var(DISCOVERY_DIR_ENV, value) }, + None => unsafe { std::env::remove_var(DISCOVERY_DIR_ENV) }, + } +} +#[test] +fn parses_first_slice_tab_create() { + let args = ControlArgs::try_parse_from(["warpctrl", "tab", "create", "--instance", "inst_123"]) + .expect("tab create parses"); + let ControlCommand::Tab(TabCommand::Create(target)) = args.command else { + panic!("expected tab create command"); + }; + assert_eq!(target.instance.as_deref(), Some("inst_123")); +} + +#[test] +fn parses_first_slice_instance_list() { + let args = ControlArgs::try_parse_from(["warpctrl", "instance", "list"]) + .expect("instance list parses"); + assert!(matches!( + args.command, + ControlCommand::Instance(InstanceCommand::List) + )); +} + +#[test] +fn parses_first_slice_app_smoke_metadata_commands() { + assert!(ControlArgs::try_parse_from(["warpctrl", "app", "ping"]).is_ok()); + assert!(ControlArgs::try_parse_from(["warpctrl", "app", "version"]).is_ok()); +} + +#[test] +fn parses_completion_generation_command() { + let args = ControlArgs::try_parse_from(["warpctrl", "completions", "bash"]) + .expect("completions parses"); + assert!(matches!( + args.command, + ControlCommand::Completions { + shell: Some(Shell::Bash) + } + )); +} + +#[test] +fn rejects_future_catalog_commands_not_in_first_slice() { + assert!(ControlArgs::try_parse_from(["warpctrl", "window", "list"]).is_err()); + assert!(ControlArgs::try_parse_from(["warpctrl", "tab", "list"]).is_err()); + assert!(ControlArgs::try_parse_from(["warpctrl", "setting", "list"]).is_err()); +} + +#[test] +fn generated_bash_completions_include_first_slice_commands() { + let completions = + generate_completion_string(Shell::Bash).expect("bash completions render to UTF-8"); + assert!(completions.contains("instance")); + assert!(completions.contains("tab")); + assert!(completions.contains("completions")); +} + +#[test] +fn structured_error_output_uses_stable_error_code() { + let error = ControlError::new(ErrorCode::NoInstance, "no local Warp control instances"); + let value = serde_json::to_value(ErrorSummary { + ok: false, + error: &error, + }) + .expect("error summary serializes"); + assert_eq!(value["ok"], json!(false)); + assert_eq!(value["error"]["code"], json!("no_instance")); + assert_eq!( + value["error"]["message"], + json!("no local Warp control instances") + ); +} + +#[test] +#[serial] +fn tab_create_without_discovery_records_reports_no_instance() { + let dir = std::env::temp_dir().join(format!( + "warpctrl-empty-discovery-{}", + uuid::Uuid::new_v4().simple() + )); + std::fs::create_dir_all(&dir).expect("temp discovery dir is created"); + let previous = set_discovery_dir(&dir); + let args = + ControlArgs::try_parse_from(["warpctrl", "--output-format", "json", "tab", "create"]) + .expect("tab create parses"); + let error = run_inner(args).expect_err("missing instance is rejected"); + restore_discovery_dir(previous); + assert_eq!(error.code, ErrorCode::NoInstance); +} diff --git a/crates/warp_features/src/lib.rs b/crates/warp_features/src/lib.rs index 9de842ab00..9a35c2d6f1 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -796,6 +796,9 @@ pub enum FeatureFlag { /// Enables tab configs — user-definable TOML templates for launching custom tab layouts. TabConfigs, + /// Enables Warp local control through the standalone warpctrl CLI. + WarpControlCli, + /// When enabled, free-tier users are blocked from AI features (no-AI experiment arm). FreeUserNoAi, @@ -945,6 +948,8 @@ pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[ FeatureFlag::RemoteCodebaseIndexing, FeatureFlag::GroupedTabs, FeatureFlag::AsyncFind, + FeatureFlag::RemoteCodeReview, + FeatureFlag::WarpControlCli, ]; /// Features enabled for feature preview build users (e.g.: Friends of Warp). diff --git a/script/linux/bundle b/script/linux/bundle index 85f92d6efc..9fea2b6f57 100755 --- a/script/linux/bundle +++ b/script/linux/bundle @@ -77,8 +77,8 @@ while (( "$#" )); do ;; --artifact) if [ -n "$2" ] && [ "${2:0:1}" != "-" ]; then - if [[ "$2" != "app" && "$2" != "cli" ]]; then - echo "Error: --artifact must be either 'app' or 'cli', got '$2'" >&2 + if [[ "$2" != "app" && "$2" != "cli" && "$2" != "warpctrl" ]]; then + echo "Error: --artifact must be 'app', 'cli', or 'warpctrl', got '$2'" >&2 exit 1 fi ARTIFACT="$2" @@ -118,13 +118,13 @@ elif [[ $RELEASE_CHANNEL = "local" || $RELEASE_CHANNEL = "dev" ]]; then # For dev bundles, we want to enable debug assertions to # catch violations that would otherwise silently pass in # a normal release build (e.g. in stable). - if [[ "$ARTIFACT" == "cli" ]]; then + if [[ "$ARTIFACT" == "cli" || "$ARTIFACT" == "warpctrl" ]]; then CARGO_PROFILE="release-cli-debug_assertions" else CARGO_PROFILE="release-lto-debug_assertions" fi else - if [[ "$ARTIFACT" == "cli" ]]; then + if [[ "$ARTIFACT" == "cli" || "$ARTIFACT" == "warpctrl" ]]; then CARGO_PROFILE="release-cli" else CARGO_PROFILE="release-lto" @@ -189,10 +189,13 @@ if [[ "$ARTIFACT" == "cli" ]]; then if [[ $RELEASE_CHANNEL != "oss" ]]; then BINARY_NAME="${BINARY_NAME/warp/oz}" fi +elif [[ "$ARTIFACT" == "warpctrl" ]]; then + WARP_BIN="warpctrl" + BINARY_NAME="warpctrl" fi # Artifact-specific configuration -if [[ "$ARTIFACT" == "cli" ]]; then +if [[ "$ARTIFACT" == "cli" || "$ARTIFACT" == "warpctrl" ]]; then FEATURES="$FEATURES,standalone" elif [[ "$ARTIFACT" == "app" ]]; then FEATURES="$FEATURES,gui" @@ -242,7 +245,7 @@ else fi # Prepare bundled resources for CLI builds. -if [[ "$ARTIFACT" == "cli" ]]; then +if [[ "$ARTIFACT" == "cli" || "$ARTIFACT" == "warpctrl" ]]; then echo "Preparing CLI resources directory" BUNDLED_RESOURCES_DIR="$OUT_DIR/resources" "$WORKSPACE_ROOT_DIR/script/prepare_bundled_resources" "$BUNDLED_RESOURCES_DIR" "$RELEASE_CHANNEL" "$CARGO_PROFILE" diff --git a/script/macos/bundle b/script/macos/bundle index 9048284c68..25289f4738 100755 --- a/script/macos/bundle +++ b/script/macos/bundle @@ -223,8 +223,8 @@ while (( "$#" )); do ;; --artifact) if [ -n "$2" ] && [ "${2:0:1}" != "-" ]; then - if [[ "$2" != "app" && "$2" != "cli" ]]; then - echo "Error: --artifact must be either 'app' or 'cli', got '$2'" >&2 + if [[ "$2" != "app" && "$2" != "cli" && "$2" != "warpctrl" ]]; then + echo "Error: --artifact must be 'app', 'cli', or 'warpctrl', got '$2'" >&2 exit 1 fi ARTIFACT="$2" @@ -250,13 +250,13 @@ elif [[ $RELEASE_CHANNEL = "local" || $RELEASE_CHANNEL = "dev" ]]; then # For dev bundles, we want to enable debug assertions to # catch violations that would otherwise silently pass in # a normal release build (e.g. in stable). - if [[ "$ARTIFACT" == "cli" ]]; then + if [[ "$ARTIFACT" == "cli" || "$ARTIFACT" == "warpctrl" ]]; then CARGO_PROFILE="release-cli-debug_assertions" else CARGO_PROFILE="release-lto-debug_assertions" fi else - if [[ "$ARTIFACT" == "cli" ]]; then + if [[ "$ARTIFACT" == "cli" || "$ARTIFACT" == "warpctrl" ]]; then CARGO_PROFILE="release-cli" else CARGO_PROFILE="release-lto" @@ -315,6 +315,9 @@ elif [[ $RELEASE_CHANNEL = "oss" ]]; then # (which would otherwise pull in the Sentry framework dependency). FEATURES="release_bundle,extern_plist" fi +if [[ "$ARTIFACT" == "warpctrl" ]]; then + WARP_BIN="warpctrl" +fi OUT_DIR="target/$TARGET_PROFILE_DIR/bundle/osx" DOCK_TILE_PLUGIN_DIR="target/$TARGET_PROFILE_DIR/WarpDockTilePlugin.docktileplugin" @@ -347,7 +350,7 @@ else fi # Set artifact-specific configuration. -if [[ "$ARTIFACT" == cli ]]; then +if [[ "$ARTIFACT" == cli || "$ARTIFACT" == warpctrl ]]; then UNIVERSAL_BINARY=false OPEN_AFTER_BUNDLE=false FEATURES="$FEATURES,standalone" @@ -370,7 +373,7 @@ fi # using the same feature flags and profile that we would be using in production. if [[ "$CHECK_ONLY" == "true" ]]; then cargo check --profile "$CARGO_PROFILE" --bin "$WARP_BIN" --target "$DEFAULT_TARGET" --features "$FEATURES" - if [[ $UNIVERSAL_BINARY = true && "$ARTIFACT" != "cli" ]]; then + if [[ $UNIVERSAL_BINARY = true && "$ARTIFACT" != "cli" && "$ARTIFACT" != "warpctrl" ]]; then cargo check --profile "$CARGO_PROFILE" --bin "$WARP_BIN" --target "$ADDITIONAL_TARGET" --features "$FEATURES" fi exit 0 @@ -561,7 +564,7 @@ EOF # Store the built artifact locations for GitHub Actions outputs. BINARY_PATH="target/$DEFAULT_TARGET/$TARGET_PROFILE_DIR/$WARP_BIN" DMG_PATH="$OUT_DIR/$FINAL_DMG_NAME" -elif [[ "$ARTIFACT" == "cli" ]]; then +elif [[ "$ARTIFACT" == "cli" || "$ARTIFACT" == "warpctrl" ]]; then if [[ $BUILD_BINARY == true ]]; then # Create Info.plist before building the app, since it's embedded at build time. # Apple's codesigning tools will detect Info.plist files in the same directory as an executable. @@ -666,7 +669,7 @@ if [[ $SELFSIGN = true ]]; then if [[ "$ARTIFACT" == app ]]; then echo "Self-signing $BUNDLE_DIR/$WARP_APP_NAME.app with ${SIGNING_CERT}..." codesign --force --deep --options runtime --sign "$SIGNING_CERT" "$BUNDLE_DIR/$WARP_APP_NAME.app" --entitlements script/Debug-Entitlements.plist - elif [[ "$ARTIFACT" == cli ]]; then + elif [[ "$ARTIFACT" == cli || "$ARTIFACT" == warpctrl ]]; then echo "Self-signing $OUT_DIR/$WARP_BIN with ${SIGNING_CERT}..." codesign --force --options runtime --sign "$SIGNING_CERT" "$OUT_DIR/$WARP_BIN" --entitlements script/Debug-Entitlements.plist fi @@ -675,7 +678,7 @@ elif [[ $CODESIGN = true ]]; then echo "Codesigning $BUNDLE_DIR/$WARP_APP_NAME.app..." # Use --deep so we sign bundled frameworks as well codesign --deep -f -o runtime --timestamp -s "$APPLE_TEAM_ID" "$BUNDLE_DIR/$WARP_APP_NAME.app" --entitlements script/Entitlements.plist - elif [[ "$ARTIFACT" == cli ]]; then + elif [[ "$ARTIFACT" == cli || "$ARTIFACT" == warpctrl ]]; then echo "Codesigning $OUT_DIR/$WARP_BIN..." codesign -f -o runtime --timestamp -s "$APPLE_TEAM_ID" "$OUT_DIR/$WARP_BIN" --entitlements script/Entitlements.plist @@ -786,7 +789,7 @@ if [[ $CODESIGN = true ]]; then echo "Verifying notarization ticket..." if [[ "$ARTIFACT" = app ]]; then xcrun stapler validate "$DMG_DIR/$DMG_NAME" - elif [[ "$ARTIFACT" = cli ]]; then + elif [[ "$ARTIFACT" = cli || "$ARTIFACT" = warpctrl ]]; then spctl -a -t open --context context:primary-signature -vv "$OUT_DIR/$WARP_BIN" fi fi From e05f824ca73f11a4d775dec9754c7908e51a0c41 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Sat, 23 May 2026 14:01:38 -0600 Subject: [PATCH 17/48] Tighten warpctrl foundation gates Co-Authored-By: Oz <oz-agent@warp.dev> --- app/src/local_control/mod.rs | 72 ++++-- app/src/local_control/mod_tests.rs | 59 ++++- app/src/local_control/permissions.rs | 14 +- app/src/settings/local_control.rs | 124 ++++++++-- app/src/settings/local_control_tests.rs | 153 ++++++++---- app/src/settings_view/scripting_page.rs | 274 +++++++++++++++++---- crates/local_control/src/discovery.rs | 88 +++++-- crates/local_control/src/protocol.rs | 1 + crates/local_control/src/protocol_tests.rs | 6 + 9 files changed, 605 insertions(+), 186 deletions(-) diff --git a/app/src/local_control/mod.rs b/app/src/local_control/mod.rs index 73de2054e0..b6cae1aba9 100644 --- a/app/src/local_control/mod.rs +++ b/app/src/local_control/mod.rs @@ -25,9 +25,7 @@ use warp_core::channel::ChannelState; use warpui::{Entity, ModelContext, ModelSpawner, SingletonEntity}; pub use bridge::LocalControlBridge; -use permissions::{ - ensure_action_allowed, ensure_feature_enabled, outside_warp_any_implemented_action_enabled, -}; +use permissions::{ensure_action_allowed, ensure_feature_enabled}; #[derive(Clone)] struct ControlServerState { @@ -38,6 +36,7 @@ struct ControlServerState { pub struct LocalControlServer { _runtime: Option<tokio::runtime::Runtime>, + control_endpoint: Option<ControlEndpoint>, _registered_instance: Option<RegisteredInstance>, } @@ -52,15 +51,29 @@ impl LocalControlServer { if !permissions::warp_control_cli_enabled() { return Self { _runtime: None, + control_endpoint: None, _registered_instance: None, }; } match Self::start(ctx) { - Ok(server) => server, + Ok(server) => { + ctx.subscribe_to_model( + &crate::settings::LocalControlSettings::handle(ctx), + |server, _, ctx| { + if let Err(error) = server.refresh_discovery_record(ctx) { + log::warn!( + "Failed to refresh local-control discovery record: {error:#}" + ); + } + }, + ); + server + } Err(error) => { log::warn!("Failed to start local-control server: {error:#}"); Self { _runtime: None, + control_endpoint: None, _registered_instance: None, } } @@ -69,12 +82,6 @@ impl LocalControlServer { fn start(ctx: &mut ModelContext<Self>) -> Result<Self, ControlError> { ensure_feature_enabled()?; - if !outside_warp_any_implemented_action_enabled(ctx) { - return Err(ControlError::new( - ErrorCode::LocalControlDisabled, - "outside-Warp local control is disabled", - )); - } let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(1) .enable_io() @@ -105,17 +112,8 @@ impl LocalControlServer { err.to_string(), ) })?; - let outside_warp_control_enabled = crate::settings::LocalControlSettings::as_ref(ctx) - .is_context_enabled(LocalControlInvocationContext::OutsideWarp); - let endpoint = - outside_warp_control_enabled.then_some(ControlEndpoint::localhost(port.port())); - let record = InstanceRecord::for_current_process( - endpoint, - ChannelState::channel().to_string(), - ChannelState::app_id().to_string(), - ChannelState::app_version().map(str::to_owned), - ActionKind::implemented_metadata(), - ); + let control_endpoint = ControlEndpoint::localhost(port.port()); + let record = discovery_record_for_settings(ctx, control_endpoint.clone()); let instance_id = record.instance_id.clone(); let bridge_spawner = LocalControlBridge::handle(ctx).update(ctx, |bridge, ctx| { bridge.set_instance_id(instance_id.clone()); @@ -138,9 +136,41 @@ impl LocalControlServer { }); Ok(Self { _runtime: Some(runtime), + control_endpoint: Some(control_endpoint), _registered_instance: Some(registered_instance), }) } + + fn refresh_discovery_record( + &mut self, + ctx: &mut ModelContext<Self>, + ) -> Result<(), ControlError> { + let Some(control_endpoint) = self.control_endpoint.clone() else { + return Ok(()); + }; + let Some(registered_instance) = &mut self._registered_instance else { + return Ok(()); + }; + let mut record = discovery_record_for_settings(ctx, control_endpoint); + record.instance_id = registered_instance.record().instance_id.clone(); + registered_instance.update(record) + } +} + +fn discovery_record_for_settings( + ctx: &ModelContext<LocalControlServer>, + control_endpoint: ControlEndpoint, +) -> InstanceRecord { + let outside_warp_control_enabled = crate::settings::LocalControlSettings::as_ref(ctx) + .is_context_enabled(LocalControlInvocationContext::OutsideWarp); + let endpoint = outside_warp_control_enabled.then_some(control_endpoint); + InstanceRecord::for_current_process( + endpoint, + ChannelState::channel().to_string(), + ChannelState::app_id().to_string(), + ChannelState::app_version().map(str::to_owned), + ActionKind::implemented_metadata(), + ) } async fn handle_credential_request( diff --git a/app/src/local_control/mod_tests.rs b/app/src/local_control/mod_tests.rs index 283c9c99a2..7ba3c4032f 100644 --- a/app/src/local_control/mod_tests.rs +++ b/app/src/local_control/mod_tests.rs @@ -13,34 +13,69 @@ use super::{ validate_tab_create_target, }; use crate::settings::{ - AllowInsideWarpControl, AllowInsideWarpReadOnly, AllowInsideWarpReadWrite, - AllowOutsideWarpControl, AllowOutsideWarpReadOnly, AllowOutsideWarpReadWrite, + AllowInsideWarpAppStateMutations, AllowInsideWarpControl, + AllowInsideWarpMetadataConfigurationMutations, AllowInsideWarpMetadataReads, + AllowInsideWarpUnderlyingDataMutations, AllowInsideWarpUnderlyingDataReads, + AllowOutsideWarpAppStateMutations, AllowOutsideWarpControl, + AllowOutsideWarpMetadataConfigurationMutations, AllowOutsideWarpMetadataReads, + AllowOutsideWarpUnderlyingDataMutations, AllowOutsideWarpUnderlyingDataReads, LocalControlSettings, }; fn settings_with_values( inside_enabled: bool, outside_enabled: bool, - inside_read_only: bool, - outside_read_only: bool, - inside_read_write: bool, - outside_read_write: bool, + inside_metadata_reads: bool, + outside_metadata_reads: bool, + inside_app_state_mutations: bool, + outside_app_state_mutations: bool, ) -> LocalControlSettings { LocalControlSettings { allow_inside_warp_control: AllowInsideWarpControl::new(Some(inside_enabled)), allow_outside_warp_control: AllowOutsideWarpControl::new(Some(outside_enabled)), - allow_inside_warp_read_only: AllowInsideWarpReadOnly::new(Some(inside_read_only)), - allow_outside_warp_read_only: AllowOutsideWarpReadOnly::new(Some(outside_read_only)), - allow_inside_warp_read_write: AllowInsideWarpReadWrite::new(Some(inside_read_write)), - allow_outside_warp_read_write: AllowOutsideWarpReadWrite::new(Some(outside_read_write)), + allow_inside_warp_metadata_reads: AllowInsideWarpMetadataReads::new(Some( + inside_metadata_reads, + )), + allow_outside_warp_metadata_reads: AllowOutsideWarpMetadataReads::new(Some( + outside_metadata_reads, + )), + allow_inside_warp_underlying_data_reads: AllowInsideWarpUnderlyingDataReads::new(Some( + true, + )), + allow_outside_warp_underlying_data_reads: AllowOutsideWarpUnderlyingDataReads::new(Some( + false, + )), + allow_inside_warp_app_state_mutations: AllowInsideWarpAppStateMutations::new(Some( + inside_app_state_mutations, + )), + allow_outside_warp_app_state_mutations: AllowOutsideWarpAppStateMutations::new(Some( + outside_app_state_mutations, + )), + allow_inside_warp_metadata_configuration_mutations: + AllowInsideWarpMetadataConfigurationMutations::new(Some(true)), + allow_outside_warp_metadata_configuration_mutations: + AllowOutsideWarpMetadataConfigurationMutations::new(Some(false)), + allow_inside_warp_underlying_data_mutations: AllowInsideWarpUnderlyingDataMutations::new( + Some(true), + ), + allow_outside_warp_underlying_data_mutations: AllowOutsideWarpUnderlyingDataMutations::new( + Some(false), + ), } } fn settings_with_outside_warp( outside_control: bool, - outside_read_write: bool, + outside_app_state_mutations: bool, ) -> LocalControlSettings { - settings_with_values(true, outside_control, true, false, true, outside_read_write) + settings_with_values( + true, + outside_control, + true, + false, + true, + outside_app_state_mutations, + ) } #[test] diff --git a/app/src/local_control/permissions.rs b/app/src/local_control/permissions.rs index 22ed248520..7d2fcc896b 100644 --- a/app/src/local_control/permissions.rs +++ b/app/src/local_control/permissions.rs @@ -5,7 +5,7 @@ use crate::settings::{ use ::local_control::{ActionKind, ControlError, ErrorCode, InvocationContext, PermissionCategory}; use warpui::{ModelContext, SingletonEntity}; -use crate::local_control::{LocalControlBridge, LocalControlServer}; +use crate::local_control::LocalControlBridge; pub(super) fn warp_control_cli_enabled() -> bool { FeatureFlag::WarpControlCli.is_enabled() @@ -21,17 +21,6 @@ pub(super) fn ensure_feature_enabled() -> Result<(), ControlError> { )) } -pub(super) fn outside_warp_any_implemented_action_enabled( - ctx: &ModelContext<LocalControlServer>, -) -> bool { - let settings = LocalControlSettings::as_ref(ctx); - ActionKind::implemented_metadata() - .into_iter() - .any(|metadata| { - outside_warp_permission_enabled_for_settings(settings, metadata.permission_category) - }) -} - #[cfg(test)] pub(crate) fn outside_warp_action_enabled_for_settings( settings: &LocalControlSettings, @@ -40,6 +29,7 @@ pub(crate) fn outside_warp_action_enabled_for_settings( outside_warp_permission_enabled_for_settings(settings, action.metadata().permission_category) } +#[cfg(test)] fn outside_warp_permission_enabled_for_settings( settings: &LocalControlSettings, permission: PermissionCategory, diff --git a/app/src/settings/local_control.rs b/app/src/settings/local_control.rs index 981371f684..0660fa486c 100644 --- a/app/src/settings/local_control.rs +++ b/app/src/settings/local_control.rs @@ -34,41 +34,95 @@ define_settings_group!(LocalControlSettings, settings: [ storage_key: "LocalControlAllowOutsideWarp", description: "Whether Warp control is allowed from external local clients.", }, - allow_inside_warp_read_only: AllowInsideWarpReadOnly { + allow_inside_warp_metadata_reads: AllowInsideWarpMetadataReads { type: bool, default: true, supported_platforms: SupportedPlatforms::DESKTOP, sync_to_cloud: SyncToCloud::Never, private: true, - storage_key: "LocalControlInsideWarpReadOnly", - description: "Whether verified Warp-managed terminal sessions may receive read-only local control grants.", + storage_key: "LocalControlInsideWarpMetadataReads", + description: "Whether verified Warp-managed terminal sessions may receive metadata-read local control grants.", }, - allow_outside_warp_read_only: AllowOutsideWarpReadOnly { + allow_outside_warp_metadata_reads: AllowOutsideWarpMetadataReads { type: bool, default: false, supported_platforms: SupportedPlatforms::DESKTOP, sync_to_cloud: SyncToCloud::Never, private: true, - storage_key: "LocalControlOutsideWarpReadOnly", - description: "Whether external local clients may receive read-only local control grants.", + storage_key: "LocalControlOutsideWarpMetadataReads", + description: "Whether external local clients may receive metadata-read local control grants.", }, - allow_inside_warp_read_write: AllowInsideWarpReadWrite { + allow_inside_warp_underlying_data_reads: AllowInsideWarpUnderlyingDataReads { type: bool, default: true, supported_platforms: SupportedPlatforms::DESKTOP, sync_to_cloud: SyncToCloud::Never, private: true, - storage_key: "LocalControlInsideWarpReadWrite", - description: "Whether verified Warp-managed terminal sessions may receive read-write local control grants.", + storage_key: "LocalControlInsideWarpUnderlyingDataReads", + description: "Whether verified Warp-managed terminal sessions may receive underlying-data-read local control grants.", }, - allow_outside_warp_read_write: AllowOutsideWarpReadWrite { + allow_outside_warp_underlying_data_reads: AllowOutsideWarpUnderlyingDataReads { type: bool, default: false, supported_platforms: SupportedPlatforms::DESKTOP, sync_to_cloud: SyncToCloud::Never, private: true, - storage_key: "LocalControlOutsideWarpReadWrite", - description: "Whether external local clients may receive read-write local control grants.", + storage_key: "LocalControlOutsideWarpUnderlyingDataReads", + description: "Whether external local clients may receive underlying-data-read local control grants.", + }, + allow_inside_warp_app_state_mutations: AllowInsideWarpAppStateMutations { + type: bool, + default: true, + supported_platforms: SupportedPlatforms::DESKTOP, + sync_to_cloud: SyncToCloud::Never, + private: true, + storage_key: "LocalControlInsideWarpAppStateMutations", + description: "Whether verified Warp-managed terminal sessions may receive app-state-mutation local control grants.", + }, + allow_outside_warp_app_state_mutations: AllowOutsideWarpAppStateMutations { + type: bool, + default: false, + supported_platforms: SupportedPlatforms::DESKTOP, + sync_to_cloud: SyncToCloud::Never, + private: true, + storage_key: "LocalControlOutsideWarpAppStateMutations", + description: "Whether external local clients may receive app-state-mutation local control grants.", + }, + allow_inside_warp_metadata_configuration_mutations: AllowInsideWarpMetadataConfigurationMutations { + type: bool, + default: true, + supported_platforms: SupportedPlatforms::DESKTOP, + sync_to_cloud: SyncToCloud::Never, + private: true, + storage_key: "LocalControlInsideWarpMetadataConfigurationMutations", + description: "Whether verified Warp-managed terminal sessions may receive metadata/configuration-mutation local control grants.", + }, + allow_outside_warp_metadata_configuration_mutations: AllowOutsideWarpMetadataConfigurationMutations { + type: bool, + default: false, + supported_platforms: SupportedPlatforms::DESKTOP, + sync_to_cloud: SyncToCloud::Never, + private: true, + storage_key: "LocalControlOutsideWarpMetadataConfigurationMutations", + description: "Whether external local clients may receive metadata/configuration-mutation local control grants.", + }, + allow_inside_warp_underlying_data_mutations: AllowInsideWarpUnderlyingDataMutations { + type: bool, + default: true, + supported_platforms: SupportedPlatforms::DESKTOP, + sync_to_cloud: SyncToCloud::Never, + private: true, + storage_key: "LocalControlInsideWarpUnderlyingDataMutations", + description: "Whether verified Warp-managed terminal sessions may receive underlying-data-mutation local control grants.", + }, + allow_outside_warp_underlying_data_mutations: AllowOutsideWarpUnderlyingDataMutations { + type: bool, + default: false, + supported_platforms: SupportedPlatforms::DESKTOP, + sync_to_cloud: SyncToCloud::Never, + private: true, + storage_key: "LocalControlOutsideWarpUnderlyingDataMutations", + description: "Whether external local clients may receive underlying-data-mutation local control grants.", }, ]); @@ -88,26 +142,44 @@ impl LocalControlSettings { match (context, permission) { ( LocalControlInvocationContext::InsideWarp, - LocalControlPermissionCategory::MetadataReads - | LocalControlPermissionCategory::UnderlyingDataReads, - ) => *self.allow_inside_warp_read_only, + LocalControlPermissionCategory::MetadataReads, + ) => *self.allow_inside_warp_metadata_reads, + ( + LocalControlInvocationContext::OutsideWarp, + LocalControlPermissionCategory::MetadataReads, + ) => *self.allow_outside_warp_metadata_reads, + ( + LocalControlInvocationContext::InsideWarp, + LocalControlPermissionCategory::UnderlyingDataReads, + ) => *self.allow_inside_warp_underlying_data_reads, + ( + LocalControlInvocationContext::OutsideWarp, + LocalControlPermissionCategory::UnderlyingDataReads, + ) => *self.allow_outside_warp_underlying_data_reads, + ( + LocalControlInvocationContext::InsideWarp, + LocalControlPermissionCategory::AppStateMutations, + ) => *self.allow_inside_warp_app_state_mutations, + ( + LocalControlInvocationContext::OutsideWarp, + LocalControlPermissionCategory::AppStateMutations, + ) => *self.allow_outside_warp_app_state_mutations, + ( + LocalControlInvocationContext::InsideWarp, + LocalControlPermissionCategory::MetadataConfigurationMutations, + ) => *self.allow_inside_warp_metadata_configuration_mutations, ( LocalControlInvocationContext::OutsideWarp, - LocalControlPermissionCategory::MetadataReads - | LocalControlPermissionCategory::UnderlyingDataReads, - ) => *self.allow_outside_warp_read_only, + LocalControlPermissionCategory::MetadataConfigurationMutations, + ) => *self.allow_outside_warp_metadata_configuration_mutations, ( LocalControlInvocationContext::InsideWarp, - LocalControlPermissionCategory::AppStateMutations - | LocalControlPermissionCategory::MetadataConfigurationMutations - | LocalControlPermissionCategory::UnderlyingDataMutations, - ) => *self.allow_inside_warp_read_write, + LocalControlPermissionCategory::UnderlyingDataMutations, + ) => *self.allow_inside_warp_underlying_data_mutations, ( LocalControlInvocationContext::OutsideWarp, - LocalControlPermissionCategory::AppStateMutations - | LocalControlPermissionCategory::MetadataConfigurationMutations - | LocalControlPermissionCategory::UnderlyingDataMutations, - ) => *self.allow_outside_warp_read_write, + LocalControlPermissionCategory::UnderlyingDataMutations, + ) => *self.allow_outside_warp_underlying_data_mutations, } } diff --git a/app/src/settings/local_control_tests.rs b/app/src/settings/local_control_tests.rs index 5bb14a7bc6..9393daa9b3 100644 --- a/app/src/settings/local_control_tests.rs +++ b/app/src/settings/local_control_tests.rs @@ -1,87 +1,91 @@ use settings::{Setting, SyncToCloud}; use super::{ - AllowInsideWarpControl, AllowInsideWarpReadOnly, AllowInsideWarpReadWrite, - AllowOutsideWarpControl, AllowOutsideWarpReadOnly, AllowOutsideWarpReadWrite, + AllowInsideWarpAppStateMutations, AllowInsideWarpControl, + AllowInsideWarpMetadataConfigurationMutations, AllowInsideWarpMetadataReads, + AllowInsideWarpUnderlyingDataMutations, AllowInsideWarpUnderlyingDataReads, + AllowOutsideWarpAppStateMutations, AllowOutsideWarpControl, + AllowOutsideWarpMetadataConfigurationMutations, AllowOutsideWarpMetadataReads, + AllowOutsideWarpUnderlyingDataMutations, AllowOutsideWarpUnderlyingDataReads, LocalControlInvocationContext, LocalControlPermissionCategory, LocalControlSettings, }; -fn settings_with_values( - inside_enabled: bool, - outside_enabled: bool, - inside_read_only: bool, - outside_read_only: bool, - inside_read_write: bool, - outside_read_write: bool, -) -> LocalControlSettings { +fn settings_with_values(inside_enabled: bool, outside_enabled: bool) -> LocalControlSettings { LocalControlSettings { allow_inside_warp_control: AllowInsideWarpControl::new(Some(inside_enabled)), allow_outside_warp_control: AllowOutsideWarpControl::new(Some(outside_enabled)), - allow_inside_warp_read_only: AllowInsideWarpReadOnly::new(Some(inside_read_only)), - allow_outside_warp_read_only: AllowOutsideWarpReadOnly::new(Some(outside_read_only)), - allow_inside_warp_read_write: AllowInsideWarpReadWrite::new(Some(inside_read_write)), - allow_outside_warp_read_write: AllowOutsideWarpReadWrite::new(Some(outside_read_write)), + allow_inside_warp_metadata_reads: AllowInsideWarpMetadataReads::new(Some(true)), + allow_outside_warp_metadata_reads: AllowOutsideWarpMetadataReads::new(Some(false)), + allow_inside_warp_underlying_data_reads: AllowInsideWarpUnderlyingDataReads::new(Some( + true, + )), + allow_outside_warp_underlying_data_reads: AllowOutsideWarpUnderlyingDataReads::new(Some( + false, + )), + allow_inside_warp_app_state_mutations: AllowInsideWarpAppStateMutations::new(Some(true)), + allow_outside_warp_app_state_mutations: AllowOutsideWarpAppStateMutations::new(Some(false)), + allow_inside_warp_metadata_configuration_mutations: + AllowInsideWarpMetadataConfigurationMutations::new(Some(true)), + allow_outside_warp_metadata_configuration_mutations: + AllowOutsideWarpMetadataConfigurationMutations::new(Some(false)), + allow_inside_warp_underlying_data_mutations: AllowInsideWarpUnderlyingDataMutations::new( + Some(true), + ), + allow_outside_warp_underlying_data_mutations: AllowOutsideWarpUnderlyingDataMutations::new( + Some(false), + ), } } #[test] fn defaults_allow_inside_warp_permissions_only() { - let settings = settings_with_values(true, false, true, false, true, false); + let settings = settings_with_values(true, false); - assert!(settings.allows( - LocalControlInvocationContext::InsideWarp, - LocalControlPermissionCategory::MetadataReads - )); - assert!(settings.allows( - LocalControlInvocationContext::InsideWarp, - LocalControlPermissionCategory::UnderlyingDataReads - )); - assert!(settings.allows( - LocalControlInvocationContext::InsideWarp, - LocalControlPermissionCategory::AppStateMutations - )); - assert!(settings.allows( - LocalControlInvocationContext::InsideWarp, - LocalControlPermissionCategory::MetadataConfigurationMutations - )); - assert!(settings.allows( - LocalControlInvocationContext::InsideWarp, - LocalControlPermissionCategory::UnderlyingDataMutations - )); - assert!(!settings.allows( - LocalControlInvocationContext::OutsideWarp, - LocalControlPermissionCategory::MetadataReads - )); - assert!(!settings.allows( - LocalControlInvocationContext::OutsideWarp, - LocalControlPermissionCategory::AppStateMutations - )); + for permission in [ + LocalControlPermissionCategory::MetadataReads, + LocalControlPermissionCategory::UnderlyingDataReads, + LocalControlPermissionCategory::AppStateMutations, + LocalControlPermissionCategory::MetadataConfigurationMutations, + LocalControlPermissionCategory::UnderlyingDataMutations, + ] { + assert!(settings.allows(LocalControlInvocationContext::InsideWarp, permission)); + assert!(!settings.allows(LocalControlInvocationContext::OutsideWarp, permission)); + } } #[test] fn generated_settings_are_private_local_only_with_expected_defaults() { assert!(*AllowInsideWarpControl::new(None)); assert!(!*AllowOutsideWarpControl::new(None)); - assert!(*AllowInsideWarpReadOnly::new(None)); - assert!(!*AllowOutsideWarpReadOnly::new(None)); - assert!(*AllowInsideWarpReadWrite::new(None)); - assert!(!*AllowOutsideWarpReadWrite::new(None)); + assert!(*AllowInsideWarpMetadataReads::new(None)); + assert!(!*AllowOutsideWarpMetadataReads::new(None)); + assert!(*AllowInsideWarpUnderlyingDataReads::new(None)); + assert!(!*AllowOutsideWarpUnderlyingDataReads::new(None)); + assert!(*AllowInsideWarpAppStateMutations::new(None)); + assert!(!*AllowOutsideWarpAppStateMutations::new(None)); + assert!(*AllowInsideWarpMetadataConfigurationMutations::new(None)); + assert!(!*AllowOutsideWarpMetadataConfigurationMutations::new(None)); + assert!(*AllowInsideWarpUnderlyingDataMutations::new(None)); + assert!(!*AllowOutsideWarpUnderlyingDataMutations::new(None)); assert_eq!(AllowInsideWarpControl::sync_to_cloud(), SyncToCloud::Never); assert_eq!(AllowOutsideWarpControl::sync_to_cloud(), SyncToCloud::Never); - assert_eq!(AllowInsideWarpReadOnly::sync_to_cloud(), SyncToCloud::Never); assert_eq!( - AllowOutsideWarpReadWrite::sync_to_cloud(), + AllowInsideWarpMetadataReads::sync_to_cloud(), + SyncToCloud::Never + ); + assert_eq!( + AllowOutsideWarpUnderlyingDataMutations::sync_to_cloud(), SyncToCloud::Never ); assert!(AllowInsideWarpControl::is_private()); assert!(AllowOutsideWarpControl::is_private()); - assert!(AllowInsideWarpReadOnly::is_private()); - assert!(AllowOutsideWarpReadWrite::is_private()); + assert!(AllowInsideWarpMetadataReads::is_private()); + assert!(AllowOutsideWarpUnderlyingDataMutations::is_private()); } #[test] fn disabled_context_blocks_enabled_granular_permissions() { - let settings = settings_with_values(false, false, true, true, true, true); + let settings = settings_with_values(false, false); assert!(!settings.allows( LocalControlInvocationContext::InsideWarp, @@ -92,3 +96,48 @@ fn disabled_context_blocks_enabled_granular_permissions() { LocalControlPermissionCategory::MetadataReads )); } + +#[test] +fn granular_permissions_are_independent() { + let settings = LocalControlSettings { + allow_inside_warp_control: AllowInsideWarpControl::new(Some(true)), + allow_outside_warp_control: AllowOutsideWarpControl::new(Some(true)), + allow_inside_warp_metadata_reads: AllowInsideWarpMetadataReads::new(Some(true)), + allow_outside_warp_metadata_reads: AllowOutsideWarpMetadataReads::new(Some(true)), + allow_inside_warp_underlying_data_reads: AllowInsideWarpUnderlyingDataReads::new(Some( + false, + )), + allow_outside_warp_underlying_data_reads: AllowOutsideWarpUnderlyingDataReads::new(Some( + false, + )), + allow_inside_warp_app_state_mutations: AllowInsideWarpAppStateMutations::new(Some(true)), + allow_outside_warp_app_state_mutations: AllowOutsideWarpAppStateMutations::new(Some(true)), + allow_inside_warp_metadata_configuration_mutations: + AllowInsideWarpMetadataConfigurationMutations::new(Some(false)), + allow_outside_warp_metadata_configuration_mutations: + AllowOutsideWarpMetadataConfigurationMutations::new(Some(false)), + allow_inside_warp_underlying_data_mutations: AllowInsideWarpUnderlyingDataMutations::new( + Some(false), + ), + allow_outside_warp_underlying_data_mutations: AllowOutsideWarpUnderlyingDataMutations::new( + Some(false), + ), + }; + + assert!(settings.allows( + LocalControlInvocationContext::OutsideWarp, + LocalControlPermissionCategory::MetadataReads + )); + assert!(!settings.allows( + LocalControlInvocationContext::OutsideWarp, + LocalControlPermissionCategory::UnderlyingDataReads + )); + assert!(settings.allows( + LocalControlInvocationContext::OutsideWarp, + LocalControlPermissionCategory::AppStateMutations + )); + assert!(!settings.allows( + LocalControlInvocationContext::OutsideWarp, + LocalControlPermissionCategory::MetadataConfigurationMutations + )); +} diff --git a/app/src/settings_view/scripting_page.rs b/app/src/settings_view/scripting_page.rs index a8c56ec30a..afa901e06a 100644 --- a/app/src/settings_view/scripting_page.rs +++ b/app/src/settings_view/scripting_page.rs @@ -9,8 +9,12 @@ use crate::appearance::Appearance; use crate::features::FeatureFlag; use crate::report_if_error; use crate::settings::{ - AllowInsideWarpControl, AllowInsideWarpReadOnly, AllowInsideWarpReadWrite, - AllowOutsideWarpControl, AllowOutsideWarpReadOnly, AllowOutsideWarpReadWrite, + AllowInsideWarpAppStateMutations, AllowInsideWarpControl, + AllowInsideWarpMetadataConfigurationMutations, AllowInsideWarpMetadataReads, + AllowInsideWarpUnderlyingDataMutations, AllowInsideWarpUnderlyingDataReads, + AllowOutsideWarpAppStateMutations, AllowOutsideWarpControl, + AllowOutsideWarpMetadataConfigurationMutations, AllowOutsideWarpMetadataReads, + AllowOutsideWarpUnderlyingDataMutations, AllowOutsideWarpUnderlyingDataReads, LocalControlInvocationContext, LocalControlSettings, }; use settings::{Setting as _, ToggleableSetting as _}; @@ -25,11 +29,17 @@ use warpui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewCon #[derive(Clone, Copy, Debug)] pub enum ScriptingToggle { InsideWarpControl, - InsideWarpReadOnly, - InsideWarpReadWrite, + InsideWarpMetadataReads, + InsideWarpUnderlyingDataReads, + InsideWarpAppStateMutations, + InsideWarpMetadataConfigurationMutations, + InsideWarpUnderlyingDataMutations, OutsideWarpControl, - OutsideWarpReadOnly, - OutsideWarpReadWrite, + OutsideWarpMetadataReads, + OutsideWarpUnderlyingDataReads, + OutsideWarpAppStateMutations, + OutsideWarpMetadataConfigurationMutations, + OutsideWarpUnderlyingDataMutations, } impl ScriptingToggle { @@ -37,8 +47,22 @@ impl ScriptingToggle { match self { Self::InsideWarpControl => "Warp control within Warp", Self::OutsideWarpControl => "Warp control outside Warp", - Self::InsideWarpReadOnly | Self::OutsideWarpReadOnly => "Allow read-only control", - Self::InsideWarpReadWrite | Self::OutsideWarpReadWrite => "Allow read-write control", + Self::InsideWarpMetadataReads | Self::OutsideWarpMetadataReads => { + "Allow metadata reads" + } + Self::InsideWarpUnderlyingDataReads | Self::OutsideWarpUnderlyingDataReads => { + "Allow underlying data reads" + } + Self::InsideWarpAppStateMutations | Self::OutsideWarpAppStateMutations => { + "Allow app-state mutations" + } + Self::InsideWarpMetadataConfigurationMutations + | Self::OutsideWarpMetadataConfigurationMutations => { + "Allow metadata/configuration mutations" + } + Self::InsideWarpUnderlyingDataMutations | Self::OutsideWarpUnderlyingDataMutations => { + "Allow underlying data mutations" + } } } @@ -50,17 +74,35 @@ impl ScriptingToggle { Self::OutsideWarpControl => { "Allows other local apps, terminals, IDEs, launch agents, and scripts to request Warp control." } - Self::InsideWarpReadOnly => { - "Allows commands inside Warp to query app information such as instances, windows, tabs, and protocol version." + Self::InsideWarpMetadataReads => { + "Allows commands inside Warp to query app metadata such as instances, windows, tabs, panes, and protocol version." + } + Self::OutsideWarpMetadataReads => { + "Allows external local clients to query app metadata after outside-Warp control is enabled." + } + Self::InsideWarpUnderlyingDataReads => { + "Allows commands inside Warp to read underlying user data such as terminal output, input buffers, or history when those commands are implemented." + } + Self::OutsideWarpUnderlyingDataReads => { + "Allows external local clients to read underlying user data when those commands are implemented." + } + Self::InsideWarpAppStateMutations => { + "Allows commands inside Warp to mutate Warp app state, such as creating a tab." } - Self::OutsideWarpReadOnly => { - "Allows external local clients to query app information after outside-Warp control is enabled." + Self::OutsideWarpAppStateMutations => { + "Allows external local clients to mutate Warp app state after outside-Warp control is enabled." } - Self::InsideWarpReadWrite => { - "Allows commands inside Warp to change Warp app state, such as creating a tab." + Self::InsideWarpMetadataConfigurationMutations => { + "Allows commands inside Warp to change metadata and configuration such as labels, themes, and allowlisted settings when those commands are implemented." } - Self::OutsideWarpReadWrite => { - "Allows external local clients to change Warp app state after outside-Warp control is enabled." + Self::OutsideWarpMetadataConfigurationMutations => { + "Allows external local clients to change metadata and configuration when those commands are implemented." + } + Self::InsideWarpUnderlyingDataMutations => { + "Allows commands inside Warp to mutate underlying user data when those commands are implemented." + } + Self::OutsideWarpUnderlyingDataMutations => { + "Allows external local clients to mutate underlying user data when those commands are implemented." } } } @@ -71,12 +113,36 @@ impl ScriptingToggle { Self::OutsideWarpControl => { "outside warp control external scripts automation local cli" } - Self::InsideWarpReadOnly => "inside warp read only query windows tabs panes instances", - Self::OutsideWarpReadOnly => { - "outside warp read only query windows tabs panes instances" + Self::InsideWarpMetadataReads => { + "inside warp metadata read query windows tabs panes instances" + } + Self::OutsideWarpMetadataReads => { + "outside warp metadata read query windows tabs panes instances" + } + Self::InsideWarpUnderlyingDataReads => { + "inside warp underlying data read terminal output input history blocks" + } + Self::OutsideWarpUnderlyingDataReads => { + "outside warp underlying data read terminal output input history blocks" + } + Self::InsideWarpAppStateMutations => { + "inside warp app state mutate change tab create window pane" + } + Self::OutsideWarpAppStateMutations => { + "outside warp app state mutate change tab create window pane" + } + Self::InsideWarpMetadataConfigurationMutations => { + "inside warp metadata configuration mutate settings theme labels" + } + Self::OutsideWarpMetadataConfigurationMutations => { + "outside warp metadata configuration mutate settings theme labels" + } + Self::InsideWarpUnderlyingDataMutations => { + "inside warp underlying data mutate input files drive" + } + Self::OutsideWarpUnderlyingDataMutations => { + "outside warp underlying data mutate input files drive" } - Self::InsideWarpReadWrite => "inside warp read write mutate change tab create", - Self::OutsideWarpReadWrite => "outside warp read write mutate change tab create", } } @@ -84,10 +150,28 @@ impl ScriptingToggle { match self { Self::InsideWarpControl => *settings.allow_inside_warp_control, Self::OutsideWarpControl => *settings.allow_outside_warp_control, - Self::InsideWarpReadOnly => *settings.allow_inside_warp_read_only, - Self::OutsideWarpReadOnly => *settings.allow_outside_warp_read_only, - Self::InsideWarpReadWrite => *settings.allow_inside_warp_read_write, - Self::OutsideWarpReadWrite => *settings.allow_outside_warp_read_write, + Self::InsideWarpMetadataReads => *settings.allow_inside_warp_metadata_reads, + Self::OutsideWarpMetadataReads => *settings.allow_outside_warp_metadata_reads, + Self::InsideWarpUnderlyingDataReads => { + *settings.allow_inside_warp_underlying_data_reads + } + Self::OutsideWarpUnderlyingDataReads => { + *settings.allow_outside_warp_underlying_data_reads + } + Self::InsideWarpAppStateMutations => *settings.allow_inside_warp_app_state_mutations, + Self::OutsideWarpAppStateMutations => *settings.allow_outside_warp_app_state_mutations, + Self::InsideWarpMetadataConfigurationMutations => { + *settings.allow_inside_warp_metadata_configuration_mutations + } + Self::OutsideWarpMetadataConfigurationMutations => { + *settings.allow_outside_warp_metadata_configuration_mutations + } + Self::InsideWarpUnderlyingDataMutations => { + *settings.allow_inside_warp_underlying_data_mutations + } + Self::OutsideWarpUnderlyingDataMutations => { + *settings.allow_outside_warp_underlying_data_mutations + } } } @@ -95,10 +179,28 @@ impl ScriptingToggle { match self { Self::InsideWarpControl => AllowInsideWarpControl::storage_key(), Self::OutsideWarpControl => AllowOutsideWarpControl::storage_key(), - Self::InsideWarpReadOnly => AllowInsideWarpReadOnly::storage_key(), - Self::OutsideWarpReadOnly => AllowOutsideWarpReadOnly::storage_key(), - Self::InsideWarpReadWrite => AllowInsideWarpReadWrite::storage_key(), - Self::OutsideWarpReadWrite => AllowOutsideWarpReadWrite::storage_key(), + Self::InsideWarpMetadataReads => AllowInsideWarpMetadataReads::storage_key(), + Self::OutsideWarpMetadataReads => AllowOutsideWarpMetadataReads::storage_key(), + Self::InsideWarpUnderlyingDataReads => { + AllowInsideWarpUnderlyingDataReads::storage_key() + } + Self::OutsideWarpUnderlyingDataReads => { + AllowOutsideWarpUnderlyingDataReads::storage_key() + } + Self::InsideWarpAppStateMutations => AllowInsideWarpAppStateMutations::storage_key(), + Self::OutsideWarpAppStateMutations => AllowOutsideWarpAppStateMutations::storage_key(), + Self::InsideWarpMetadataConfigurationMutations => { + AllowInsideWarpMetadataConfigurationMutations::storage_key() + } + Self::OutsideWarpMetadataConfigurationMutations => { + AllowOutsideWarpMetadataConfigurationMutations::storage_key() + } + Self::InsideWarpUnderlyingDataMutations => { + AllowInsideWarpUnderlyingDataMutations::storage_key() + } + Self::OutsideWarpUnderlyingDataMutations => { + AllowOutsideWarpUnderlyingDataMutations::storage_key() + } } } @@ -106,19 +208,47 @@ impl ScriptingToggle { match self { Self::InsideWarpControl => AllowInsideWarpControl::sync_to_cloud(), Self::OutsideWarpControl => AllowOutsideWarpControl::sync_to_cloud(), - Self::InsideWarpReadOnly => AllowInsideWarpReadOnly::sync_to_cloud(), - Self::OutsideWarpReadOnly => AllowOutsideWarpReadOnly::sync_to_cloud(), - Self::InsideWarpReadWrite => AllowInsideWarpReadWrite::sync_to_cloud(), - Self::OutsideWarpReadWrite => AllowOutsideWarpReadWrite::sync_to_cloud(), + Self::InsideWarpMetadataReads => AllowInsideWarpMetadataReads::sync_to_cloud(), + Self::OutsideWarpMetadataReads => AllowOutsideWarpMetadataReads::sync_to_cloud(), + Self::InsideWarpUnderlyingDataReads => { + AllowInsideWarpUnderlyingDataReads::sync_to_cloud() + } + Self::OutsideWarpUnderlyingDataReads => { + AllowOutsideWarpUnderlyingDataReads::sync_to_cloud() + } + Self::InsideWarpAppStateMutations => AllowInsideWarpAppStateMutations::sync_to_cloud(), + Self::OutsideWarpAppStateMutations => { + AllowOutsideWarpAppStateMutations::sync_to_cloud() + } + Self::InsideWarpMetadataConfigurationMutations => { + AllowInsideWarpMetadataConfigurationMutations::sync_to_cloud() + } + Self::OutsideWarpMetadataConfigurationMutations => { + AllowOutsideWarpMetadataConfigurationMutations::sync_to_cloud() + } + Self::InsideWarpUnderlyingDataMutations => { + AllowInsideWarpUnderlyingDataMutations::sync_to_cloud() + } + Self::OutsideWarpUnderlyingDataMutations => { + AllowOutsideWarpUnderlyingDataMutations::sync_to_cloud() + } } } fn parent_context(self) -> Option<LocalControlInvocationContext> { match self { - Self::InsideWarpReadOnly | Self::InsideWarpReadWrite => { + Self::InsideWarpMetadataReads + | Self::InsideWarpUnderlyingDataReads + | Self::InsideWarpAppStateMutations + | Self::InsideWarpMetadataConfigurationMutations + | Self::InsideWarpUnderlyingDataMutations => { Some(LocalControlInvocationContext::InsideWarp) } - Self::OutsideWarpReadOnly | Self::OutsideWarpReadWrite => { + Self::OutsideWarpMetadataReads + | Self::OutsideWarpUnderlyingDataReads + | Self::OutsideWarpAppStateMutations + | Self::OutsideWarpMetadataConfigurationMutations + | Self::OutsideWarpUnderlyingDataMutations => { Some(LocalControlInvocationContext::OutsideWarp) } Self::InsideWarpControl | Self::OutsideWarpControl => None, @@ -152,19 +282,37 @@ impl ScriptingSettingsPageView { ScriptingToggle::InsideWarpControl, )), Box::new(ScriptingToggleWidget::new( - ScriptingToggle::InsideWarpReadOnly, + ScriptingToggle::InsideWarpMetadataReads, )), Box::new(ScriptingToggleWidget::new( - ScriptingToggle::InsideWarpReadWrite, + ScriptingToggle::InsideWarpUnderlyingDataReads, + )), + Box::new(ScriptingToggleWidget::new( + ScriptingToggle::InsideWarpAppStateMutations, + )), + Box::new(ScriptingToggleWidget::new( + ScriptingToggle::InsideWarpMetadataConfigurationMutations, + )), + Box::new(ScriptingToggleWidget::new( + ScriptingToggle::InsideWarpUnderlyingDataMutations, )), Box::new(ScriptingToggleWidget::new( ScriptingToggle::OutsideWarpControl, )), Box::new(ScriptingToggleWidget::new( - ScriptingToggle::OutsideWarpReadOnly, + ScriptingToggle::OutsideWarpMetadataReads, + )), + Box::new(ScriptingToggleWidget::new( + ScriptingToggle::OutsideWarpUnderlyingDataReads, + )), + Box::new(ScriptingToggleWidget::new( + ScriptingToggle::OutsideWarpAppStateMutations, + )), + Box::new(ScriptingToggleWidget::new( + ScriptingToggle::OutsideWarpMetadataConfigurationMutations, )), Box::new(ScriptingToggleWidget::new( - ScriptingToggle::OutsideWarpReadWrite, + ScriptingToggle::OutsideWarpUnderlyingDataMutations, )), ], Some("Scripting"), @@ -195,24 +343,54 @@ impl TypedActionView for ScriptingSettingsPageView { .allow_outside_warp_control .toggle_and_save_value(ctx)); } - ScriptingToggle::InsideWarpReadOnly => { + ScriptingToggle::InsideWarpMetadataReads => { + report_if_error!(settings + .allow_inside_warp_metadata_reads + .toggle_and_save_value(ctx)); + } + ScriptingToggle::OutsideWarpMetadataReads => { + report_if_error!(settings + .allow_outside_warp_metadata_reads + .toggle_and_save_value(ctx)); + } + ScriptingToggle::InsideWarpUnderlyingDataReads => { + report_if_error!(settings + .allow_inside_warp_underlying_data_reads + .toggle_and_save_value(ctx)); + } + ScriptingToggle::OutsideWarpUnderlyingDataReads => { + report_if_error!(settings + .allow_outside_warp_underlying_data_reads + .toggle_and_save_value(ctx)); + } + ScriptingToggle::InsideWarpAppStateMutations => { + report_if_error!(settings + .allow_inside_warp_app_state_mutations + .toggle_and_save_value(ctx)); + } + ScriptingToggle::OutsideWarpAppStateMutations => { + report_if_error!(settings + .allow_outside_warp_app_state_mutations + .toggle_and_save_value(ctx)); + } + ScriptingToggle::InsideWarpMetadataConfigurationMutations => { report_if_error!(settings - .allow_inside_warp_read_only + .allow_inside_warp_metadata_configuration_mutations .toggle_and_save_value(ctx)); } - ScriptingToggle::OutsideWarpReadOnly => { + ScriptingToggle::OutsideWarpMetadataConfigurationMutations => { report_if_error!(settings - .allow_outside_warp_read_only + .allow_outside_warp_metadata_configuration_mutations .toggle_and_save_value(ctx)); } - ScriptingToggle::InsideWarpReadWrite => { + ScriptingToggle::InsideWarpUnderlyingDataMutations => { report_if_error!(settings - .allow_inside_warp_read_write + .allow_inside_warp_underlying_data_mutations .toggle_and_save_value(ctx)); } - ScriptingToggle::OutsideWarpReadWrite => { + ScriptingToggle::OutsideWarpUnderlyingDataMutations => { report_if_error!(settings - .allow_outside_warp_read_write + .allow_outside_warp_underlying_data_mutations .toggle_and_save_value(ctx)); } }); @@ -277,7 +455,7 @@ impl SettingsWidget for ScriptingIntroWidget { ) -> Box<dyn Element> { render_settings_info_banner( "Warp control lets local scripts automate allowlisted actions in a running Warp app.", - Some("Enable Warp control within Warp for commands launched from Warp-managed terminals, or outside Warp for other local apps and scripts. Each scope can allow read-only queries and read-write app changes separately."), + Some("Enable Warp control within Warp for commands launched from Warp-managed terminals, or outside Warp for other local apps and scripts. Each scope has separate grants for metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, and underlying data mutations."), appearance, ) } diff --git a/crates/local_control/src/discovery.rs b/crates/local_control/src/discovery.rs index e4f1ff2df3..8a7156feb2 100644 --- a/crates/local_control/src/discovery.rs +++ b/crates/local_control/src/discovery.rs @@ -111,28 +111,48 @@ impl RegisteredInstance { err.to_string(), ) })?; + set_private_dir_permissions(&dir); let path = record_path(&dir, &record.instance_id); - let bytes = serde_json::to_vec_pretty(&record).map_err(|err| { - ControlError::with_details( - ErrorCode::Internal, - "failed to serialize local-control discovery record", - err.to_string(), - ) - })?; - fs::write(&path, bytes).map_err(|err| { - ControlError::with_details( - ErrorCode::Internal, - "failed to write local-control discovery record", - err.to_string(), - ) - })?; - set_private_permissions(&path); + write_record(&path, &record)?; Ok(Self { record, path }) } pub fn record(&self) -> &InstanceRecord { &self.record } + + pub fn update(&mut self, record: InstanceRecord) -> Result<(), ControlError> { + let path = record_path( + self.path.parent().unwrap_or_else(|| Path::new(".")), + &record.instance_id, + ); + write_record(&path, &record)?; + if path != self.path { + let _ = fs::remove_file(&self.path); + self.path = path; + } + self.record = record; + Ok(()) + } +} + +fn write_record(path: &Path, record: &InstanceRecord) -> Result<(), ControlError> { + let bytes = serde_json::to_vec_pretty(record).map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to serialize local-control discovery record", + err.to_string(), + ) + })?; + fs::write(path, bytes).map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to write local-control discovery record", + err.to_string(), + ) + })?; + set_private_permissions(path); + Ok(()) } impl Drop for RegisteredInstance { @@ -202,6 +222,20 @@ fn record_path(dir: &Path, instance_id: &InstanceId) -> PathBuf { dir.join(format!("{}.json", instance_id.0)) } +#[cfg(unix)] +fn set_private_dir_permissions(path: &Path) { + use std::os::unix::fs::PermissionsExt as _; + + if let Ok(metadata) = fs::metadata(path) { + let mut permissions = metadata.permissions(); + permissions.set_mode(0o700); + let _ = fs::set_permissions(path, permissions); + } +} + +#[cfg(not(unix))] +fn set_private_dir_permissions(_path: &Path) {} + #[cfg(unix)] fn set_private_permissions(path: &Path) { use std::os::unix::fs::PermissionsExt as _; @@ -266,12 +300,36 @@ mod tests { assert!(record.credential_broker.is_none()); } + #[cfg(unix)] + #[test] + fn discovery_directory_is_owner_only_on_unix() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().expect("temp dir"); + let record = InstanceRecord::for_current_process( + Some(ControlEndpoint::localhost(4000)), + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + let _registered = + RegisteredInstance::register_in_dir_for_test(record, dir.path()).expect("registered"); + let mode = fs::metadata(dir.path()) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o700); + } + impl RegisteredInstance { fn register_in_dir_for_test( record: InstanceRecord, dir: &Path, ) -> Result<Self, ControlError> { fs::create_dir_all(dir).expect("create dir"); + set_private_dir_permissions(dir); let path = record_path(dir, &record.instance_id); let bytes = serde_json::to_vec_pretty(&record).expect("serialize"); fs::write(&path, bytes).expect("write"); diff --git a/crates/local_control/src/protocol.rs b/crates/local_control/src/protocol.rs index defb1ca072..77fffebcb0 100644 --- a/crates/local_control/src/protocol.rs +++ b/crates/local_control/src/protocol.rs @@ -139,6 +139,7 @@ pub enum ErrorCode { InvalidParams, NoInstance, AmbiguousInstance, + AmbiguousTarget, StaleTarget, TargetStateConflict, MissingTarget, diff --git a/crates/local_control/src/protocol_tests.rs b/crates/local_control/src/protocol_tests.rs index 9c943d0c71..0b75299fd3 100644 --- a/crates/local_control/src/protocol_tests.rs +++ b/crates/local_control/src/protocol_tests.rs @@ -22,6 +22,12 @@ fn response_error_serializes_machine_code() { ); } +#[test] +fn ambiguous_target_error_code_is_stable() { + let value = serde_json::to_value(ErrorCode::AmbiguousTarget).expect("code serializes"); + assert_eq!(value, serde_json::json!("ambiguous_target")); +} + #[test] fn input_run_is_not_in_the_allowlisted_catalog() { let action = serde_json::from_value::<ActionKind>(serde_json::json!("input.run")); From 338856e04a6790d317ca7e87458f569916dd84a7 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Sat, 23 May 2026 16:18:36 -0600 Subject: [PATCH 18/48] Document combined warpctrl stack base Co-Authored-By: Oz <oz-agent@warp.dev> --- specs/warp-control-cli/PRODUCT.md | 17 +++++++------ specs/warp-control-cli/TECH.md | 41 ++++++++++++++----------------- 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/specs/warp-control-cli/PRODUCT.md b/specs/warp-control-cli/PRODUCT.md index 31bbae0f3f..c37fa23e71 100644 --- a/specs/warp-control-cli/PRODUCT.md +++ b/specs/warp-control-cli/PRODUCT.md @@ -213,7 +213,7 @@ All commands that address a running app target accept the same selector flags wh - `--output-format <pretty|json|ndjson|text>` controls output shape and remains globally available. Within a selector family, specifying more than one form is invalid. For example, `--tab-id` conflicts with `--tab-index`, `--tab-title`, and `--tab`. Omitted lower-level selectors use active defaults only when that active target is unambiguous. Explicit IDs must resolve exactly or fail with `stale_target`; index/title/name/path selectors that match zero targets fail with `missing_target`, and selectors that match multiple targets fail with `ambiguous_target`. ### Read-only command set -The read-only branch `zach/warp-cli-readonly` should implement the following commands before mutating catalog expansion begins. Read-only does not mean one permission: metadata reads and underlying data reads are separate grant categories. +The read-only branches should implement the following commands before mutating catalog expansion begins: `zach/warp-cli-readonly-metadata` owns structural metadata reads, and `zach/warp-cli-readonly-data-settings` owns underlying-data reads plus read-only settings/appearance/docs. Read-only does not mean one permission: metadata reads and underlying data reads are separate grant categories. Metadata and capability reads: - `warpctrl instance list` - `warpctrl instance inspect [--instance <id>|--pid <pid>]` @@ -255,7 +255,7 @@ Authenticated read-only Warp Drive metadata and data reads, enabled only when th - `warpctrl drive list --type <workflow|notebook|env-var-collection|prompt|folder|ai-fact|mcp-server|space|trash> [selectors]` - `warpctrl drive inspect <id> [selectors]` ### Mutating command set -The stacked branch `zach/warp-cli-read-write` should build on `zach/warp-cli-readonly` and implement the following mutating commands. Mutating commands are split by what they mutate: app-state, metadata/configuration, or underlying data. Underlying data mutations require a separate and stronger permission than app-state or metadata/configuration mutations. +The mutating branches should build on the read-only stack and implement the following mutating commands: `zach/warp-cli-mutating-layout` owns app/window/tab/pane layout mutations, and `zach/warp-cli-mutating-input-settings-surfaces` owns the remaining input/session/settings/surface mutations. Mutating commands are split by what they mutate: app-state, metadata/configuration, or underlying data. Underlying data mutations require a separate and stronger permission than app-state or metadata/configuration mutations. App-state mutations for app, window, and surfaces: - `warpctrl app focus [selectors]` - `warpctrl window create [--shell <name>] [selectors]` @@ -345,12 +345,13 @@ These are underlying-data mutations because they can execute code, trigger exter ### Excluded from the public command surface The command surface must continue to exclude debug-only, crash-only, auth-token, heap-dump, and arbitrary internal dispatch actions even when those actions are available in command palette or keybinding registries. Examples that remain excluded are app crash/panic helpers, access-token copy helpers, heap profile dumps, debug reset actions, raw view-tree debugging, and broad internal action-by-string execution. ## Branch stacking and delivery model -The Warp Control CLI work should ship as a raw-git branch stack so specs, core scaffolding, read-only expansion, and mutating expansion remain reviewable independently: -- `zach/warp-cli-specs` is the spec-only branch. It owns `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs, and should not contain implementation changes. -- `zach/warp-cli` stacks on the specs branch and owns the first implementation slice: shared protocol, discovery/auth scaffolding, Settings > Scripting, local-control bridge/server, standalone `warpctrl` binary, packaging hooks, and the smallest safe end-to-end action. -- `zach/warp-cli-readonly` stacks on `zach/warp-cli` and fills in the read-only command set, including structural metadata reads and separately gated underlying-data reads such as terminal block output. -- `zach/warp-cli-read-write` stacks on `zach/warp-cli-readonly` and fills in approved mutating command families while preserving the initial prohibition on terminal command execution and agent-prompt submission. -Spec changes originate on `zach/warp-cli-specs` and are propagated upward through the stack with raw git so all implementation branches reflect the same product/security contract. Graphite is not part of this stack. If a lower branch merges first, higher branches should rebase onto the merged successor while preserving the approved spec content. +The Warp Control CLI work should ship as a raw-git branch stack so the combined specs/foundation slice, read-only expansion, and mutating expansion remain reviewable independently: +- `zach/warp-cli-core-foundation` is the bottom review branch and targets `master`. It owns `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs alongside the first implementation slice: shared protocol, discovery/auth scaffolding, Settings > Scripting, local-control bridge/server, standalone `warpctrl` binary, packaging hooks, and the smallest safe end-to-end action. +- `zach/warp-cli-readonly-metadata` stacks on `zach/warp-cli-core-foundation` and implements structural metadata reads, including instance/app health, active-chain, windows, tabs, panes, sessions, and action metadata. +- `zach/warp-cli-readonly-data-settings` stacks on `zach/warp-cli-readonly-metadata` and fills in underlying-data reads plus read-only settings/appearance/docs, including terminal block output, input-buffer reads, history reads, and allowlisted settings metadata. +- `zach/warp-cli-mutating-layout` stacks on `zach/warp-cli-readonly-data-settings` and implements app/window/tab/pane layout mutations. +- `zach/warp-cli-mutating-input-settings-surfaces` stacks on `zach/warp-cli-mutating-layout` and fills in approved input/session/settings/surface mutating command families while preserving the initial prohibition on terminal command execution and agent-prompt submission. +The previous `zach/warp-cli-specs` branch is retained only as migration-source/history material. New spec changes originate on `zach/warp-cli-core-foundation` and are propagated upward through the stack with raw git so all higher implementation branches reflect the same product/security contract. Graphite is not part of this stack. If a lower branch merges first, higher branches should rebase onto the merged successor while preserving the approved spec content. ## Built-in Warp Agent skill Warp should include a built-in Agent skill for `warpctrl`, analogous to the bundled `oz-platform` skill. The skill should teach Warp Agent when to use `warpctrl`, how to discover and target running instances, how to prefer read-only commands before mutating commands, how to request explicit user approval for underlying data mutations, and how to interpret structured errors. The skill should document the stable command hierarchy above, include concise recipes for common automation tasks, and avoid instructing agents to bypass the CLI by calling local-control HTTP endpoints directly. ## CLI implementation and documentation conventions diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index b22124f454..33a89b4048 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -315,20 +315,19 @@ Naming decision: ## Implementation Plan ### Branch stack Use raw git for the stack; do not use Graphite for these branches. -The durable review stack should optimize for reviewability rather than mirroring only broad product phases. The intended stack is: -1. `zach/warp-cli-specs` — spec-only branch. This branch owns `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs. It should not contain implementation changes. -2. `zach/warp-cli-core-foundation` — create this branch from `zach/warp-cli-specs`. It owns the shared implementation foundation: protocol crate shape, discovery/auth scaffolding, selector and error types, action metadata/catalog structure, Scripting settings surface, protected local-control settings, local-control server/bridge skeleton, standalone `warpctrl` binary skeleton, packaging hooks, module split, `WarpCtrlBehavior` scaffolding, and the minimum `tab.create` smoke path if needed to prove the end-to-end architecture. -3. `zach/warp-cli-readonly-metadata` — create this branch from `zach/warp-cli-core-foundation`. It implements structural metadata reads: instance/app health and active-chain commands, windows, tabs, panes, sessions, capability/action metadata, opaque IDs, metadata target shapes, and metadata-read permission enforcement. It must not expose terminal output, input buffers, history, file contents, Drive object contents, or other underlying user data. -4. `zach/warp-cli-readonly-data-settings` — create this branch from `zach/warp-cli-readonly-metadata`. It implements underlying-data reads and read-only settings/appearance/docs: block listing/inspection/output, input-buffer reads, history reads, theme/settings/keybinding/action reads, project/file app-state reads, authenticated Drive metadata/content reads where present, read-only docs, and the read-only `warpctrl` Agent skill. -5. `zach/warp-cli-mutating-layout` — create this branch from `zach/warp-cli-readonly-data-settings`. It implements layout and app-state mutations for app/window/tab/pane behavior: window create/focus/close, tab create/activate/move/close, tab metadata that belongs with layout review if appropriate, pane split/focus/navigate/resize/maximize/close, app-state mutation permission checks, and layout mutation tests. -6. `zach/warp-cli-mutating-input-settings-surfaces` — create this branch from `zach/warp-cli-mutating-layout`. It implements the remaining approved mutating command families: session activation/cycling/reopen, input insert/replace/clear, input mode switching, theme/system-theme/font/zoom changes, allowlisted setting set/toggle, settings/palette/search/panel/surface commands, file/project/Drive open commands that are app-state-only, mutating docs, and the mutating-command Agent skill updates. It must preserve the initial public prohibition on terminal command execution, workflow execution, accepted-command submission, and agent-prompt submission. +The durable review stack should optimize for reviewability rather than mirroring only broad product phases. The bottom review branch now combines specs and the shared foundation so reviewers can see the product/security contract next to the protocol, settings, bridge, and CLI scaffolding that enforce it. The intended stack is: +1. `zach/warp-cli-core-foundation` — create this branch from `master`. It owns the specs in `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs, plus the shared implementation foundation: protocol crate shape, discovery/auth scaffolding, selector and error types, action metadata/catalog structure, Scripting settings surface, protected local-control settings, local-control server/bridge skeleton, standalone `warpctrl` binary skeleton, packaging hooks, module split, `WarpCtrlBehavior` scaffolding, and the minimum `tab.create` smoke path if needed to prove the end-to-end architecture. +2. `zach/warp-cli-readonly-metadata` — create this branch from `zach/warp-cli-core-foundation`. It implements structural metadata reads: instance/app health and active-chain commands, windows, tabs, panes, sessions, capability/action metadata, opaque IDs, metadata target shapes, and metadata-read permission enforcement. It must not expose terminal output, input buffers, history, file contents, Drive object contents, or other underlying user data. +3. `zach/warp-cli-readonly-data-settings` — create this branch from `zach/warp-cli-readonly-metadata`. It implements underlying-data reads and read-only settings/appearance/docs: block listing/inspection/output, input-buffer reads, history reads, theme/settings/keybinding/action reads, project/file app-state reads, authenticated Drive metadata/content reads where present, read-only docs, and the read-only `warpctrl` Agent skill. +4. `zach/warp-cli-mutating-layout` — create this branch from `zach/warp-cli-readonly-data-settings`. It implements layout and app-state mutations for app/window/tab/pane behavior: window create/focus/close, tab create/activate/move/close, tab metadata that belongs with layout review if appropriate, pane split/focus/navigate/resize/maximize/close, app-state mutation permission checks, and layout mutation tests. +5. `zach/warp-cli-mutating-input-settings-surfaces` — create this branch from `zach/warp-cli-mutating-layout`. It implements the remaining approved mutating command families: session activation/cycling/reopen, input insert/replace/clear, input mode switching, theme/system-theme/font/zoom changes, allowlisted setting set/toggle, settings/palette/search/panel/surface commands, file/project/Drive open commands that are app-state-only, mutating docs, and the mutating-command Agent skill updates. It must preserve the initial public prohibition on terminal command execution, workflow execution, accepted-command submission, and agent-prompt submission. +The previous `zach/warp-cli-specs` branch is retained only as migration-source/history material. It is no longer a separate review PR or an authoritative branch in the active stack. The goal is to keep durable review branches close to roughly 2,000 lines of incremental changes where practical while avoiding a one-branch-per-command maintenance burden. Product phases still matter, but they are not the primary PR boundary. The durable branches are the review spine; short-lived shard branches can feed into them during implementation. -Spec changes are an important part of the stacking strategy. All spec changes must originate on `zach/warp-cli-specs`, which is the authoritative source for `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs. After a spec change lands there, propagate it upward through every stacked branch with raw git so the spec files stay synchronized across `zach/warp-cli-core-foundation`, `zach/warp-cli-readonly-metadata`, `zach/warp-cli-readonly-data-settings`, `zach/warp-cli-mutating-layout`, and `zach/warp-cli-mutating-input-settings-surfaces`. Do not make independent spec edits directly on the implementation branches except as part of resolving a propagation conflict in a way that preserves the authoritative specs-branch content. -Recommended raw-git setup after `zach/warp-cli-specs` is ready: +Spec changes are an important part of the stacking strategy. All new spec changes must originate on `zach/warp-cli-core-foundation`, which is the authoritative source for `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs. After a spec change lands there, propagate it upward through every stacked branch with raw git so the spec files stay synchronized across `zach/warp-cli-readonly-metadata`, `zach/warp-cli-readonly-data-settings`, `zach/warp-cli-mutating-layout`, and `zach/warp-cli-mutating-input-settings-surfaces`. Do not make independent spec edits directly on higher implementation branches except as part of resolving a propagation conflict in a way that preserves the authoritative bottom-branch content. +Recommended raw-git setup after `zach/warp-cli-core-foundation` is ready: ```bash git fetch origin -git checkout zach/warp-cli-specs -git checkout -b zach/warp-cli-core-foundation +git checkout -b zach/warp-cli-core-foundation origin/master git checkout -b zach/warp-cli-readonly-metadata git checkout -b zach/warp-cli-readonly-data-settings git checkout -b zach/warp-cli-mutating-layout @@ -336,20 +335,19 @@ git checkout -b zach/warp-cli-mutating-input-settings-surfaces ``` If a lower branch changes after higher branches exist, rebase each higher branch onto its immediate lower branch with raw git and resolve conflicts by preserving both the lower branch's stable API/permission model and the higher branch's owned behavior. ### Migrating from the earlier four-branch stack -The earlier four-branch stack (`zach/warp-cli-specs`, `zach/warp-cli`, `zach/warp-cli-readonly`, `zach/warp-cli-read-write`) should be treated as source material for the six-branch review stack, not as the final review structure. +The earlier four-branch stack (`zach/warp-cli-specs`, `zach/warp-cli`, `zach/warp-cli-readonly`, `zach/warp-cli-read-write`) should be treated as source material for the five-PR review stack, not as the final review structure. Recommended migration: 1. Create backup refs before rewriting or replacing anything: - `backup/warp-cli-specs` from `zach/warp-cli-specs`. - `backup/warp-cli` from `zach/warp-cli`. - `backup/warp-cli-readonly` from `zach/warp-cli-readonly`. - `backup/warp-cli-read-write` from `zach/warp-cli-read-write`. -2. Keep `zach/warp-cli-specs` as the bottom spec-only branch after rebasing it onto latest `origin/master`. -3. Create `zach/warp-cli-core-foundation` from `zach/warp-cli-specs` and bring over only the foundation pieces from `zach/warp-cli`. Prefer path-level checkout followed by selective editing or `git add -p`; do not preserve every old commit if that makes review boundaries worse. -4. Create `zach/warp-cli-readonly-metadata` from `zach/warp-cli-core-foundation` and bring over only metadata-read pieces from `zach/warp-cli-readonly`. -5. Create `zach/warp-cli-readonly-data-settings` from `zach/warp-cli-readonly-metadata` and bring over the remaining read-only underlying-data, settings, docs, and skill pieces from `zach/warp-cli-readonly`. -6. Create `zach/warp-cli-mutating-layout` from `zach/warp-cli-readonly-data-settings` and bring over only layout/app-state mutations from `zach/warp-cli-read-write`. -7. Create `zach/warp-cli-mutating-input-settings-surfaces` from `zach/warp-cli-mutating-layout` and bring over the remaining approved mutating input/session/settings/surface pieces from `zach/warp-cli-read-write`. -8. Recompute incremental diff sizes, validate compilation/tests for each branch, push the new stack, and retire or close the old broad branches once the new review stack is accepted. +2. Create `zach/warp-cli-core-foundation` from latest `origin/master` and bring over both the specs from `zach/warp-cli-specs` and only the foundation pieces from `zach/warp-cli`. Prefer path-level checkout followed by selective editing or `git add -p`; do not preserve every old commit if that makes review boundaries worse. +3. Create `zach/warp-cli-readonly-metadata` from `zach/warp-cli-core-foundation` and bring over only metadata-read pieces from `zach/warp-cli-readonly`. +4. Create `zach/warp-cli-readonly-data-settings` from `zach/warp-cli-readonly-metadata` and bring over the remaining read-only underlying-data, settings, docs, and skill pieces from `zach/warp-cli-readonly`. +5. Create `zach/warp-cli-mutating-layout` from `zach/warp-cli-readonly-data-settings` and bring over only layout/app-state mutations from `zach/warp-cli-read-write`. +6. Create `zach/warp-cli-mutating-input-settings-surfaces` from `zach/warp-cli-mutating-layout` and bring over the remaining approved mutating input/session/settings/surface pieces from `zach/warp-cli-read-write`. +7. Recompute incremental diff sizes, validate compilation/tests for each branch, push the new stack, and retire or close the old broad branches once the new review stack is accepted. Before redistributing feature work, prefer landing a mechanical module-split commit in `zach/warp-cli-core-foundation` so later branches do not all expand the same large files. The app-side target should be: - `app/src/local_control/mod.rs` for registration and top-level exports. - `app/src/local_control/bridge.rs` for the app request bridge. @@ -386,7 +384,7 @@ When `FeatureFlag::WarpControlCli` is disabled in the Warp app: The standalone `warpctrl` binary can still exist in a build where the app feature is disabled, but it should find no compatible enabled app instance and should return a structured no-instance or feature-disabled error rather than relying on hidden server state. ### Merge and review strategy Keep PR boundaries aligned with the stack: -- PR1: `zach/warp-cli-core-foundation` into `master` or the merged successor of `zach/warp-cli-specs` for shared protocol, CLI, settings, bridge, and module scaffolding. +- PR1: `zach/warp-cli-core-foundation` into `master` for the combined specs, shared protocol, CLI, settings, bridge, and module scaffolding. - PR2: `zach/warp-cli-readonly-metadata` into `zach/warp-cli-core-foundation` or its merged successor for metadata reads. - PR3: `zach/warp-cli-readonly-data-settings` into `zach/warp-cli-readonly-metadata` or its merged successor for underlying-data reads, settings reads, docs, and skill updates. - PR4: `zach/warp-cli-mutating-layout` into `zach/warp-cli-readonly-data-settings` or its merged successor for app/window/tab/pane layout mutations. @@ -508,8 +506,7 @@ Default file ownership for shards: The lead integrator merges or cherry-picks accepted shard work into the durable stack with raw git, in review order. Shard branches should not become independent long-lived PRs unless the lead intentionally splits review further; their default purpose is to feed the durable stack while preserving parallel implementation and focused context windows. ```mermaid flowchart LR - Specs["zach/warp-cli-specs<br/>spec-only"] --> Core["zach/warp-cli-core-foundation<br/>contracts + bridge"] - Core --> ROMeta["zach/warp-cli-readonly-metadata<br/>structural reads"] + Core["zach/warp-cli-core-foundation<br/>specs + contracts + bridge"] --> ROMeta["zach/warp-cli-readonly-metadata<br/>structural reads"] ROMeta --> ROData["zach/warp-cli-readonly-data-settings<br/>data/settings reads"] ROData --> MutLayout["zach/warp-cli-mutating-layout<br/>layout mutations"] MutLayout --> MutInput["zach/warp-cli-mutating-input-settings-surfaces<br/>input/settings/surfaces"] From 7d997e67e15465fcac114478ad53f5d1065042e6 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Sat, 23 May 2026 18:49:28 -0600 Subject: [PATCH 19/48] Document warpctrl core surfaces Co-Authored-By: Oz <oz-agent@warp.dev> --- app/src/bin/warpctrl.rs | 1 + app/src/local_control/bridge.rs | 5 +++++ app/src/local_control/handlers.rs | 1 + app/src/local_control/handlers/layout.rs | 1 + app/src/local_control/handlers/metadata.rs | 1 + app/src/local_control/mod.rs | 7 ++++++- app/src/local_control/permissions.rs | 1 + app/src/local_control/resolver.rs | 1 + app/src/settings/local_control.rs | 3 +++ app/src/settings_view/scripting_page.rs | 3 ++- crates/local_control/src/auth.rs | 7 +++++++ crates/local_control/src/catalog.rs | 11 +++++++++++ crates/local_control/src/client.rs | 1 + crates/local_control/src/discovery.rs | 6 ++++++ crates/local_control/src/lib.rs | 5 +++++ crates/local_control/src/protocol.rs | 8 ++++++++ crates/local_control/src/selection.rs | 2 ++ crates/local_control/src/selectors.rs | 8 ++++++++ crates/warp_cli/src/bin/warpctrl.rs | 1 + crates/warp_cli/src/local_control/commands.rs | 2 ++ crates/warp_cli/src/local_control/completions.rs | 1 + crates/warp_cli/src/local_control/mod.rs | 7 +++++++ crates/warp_cli/src/local_control/output.rs | 2 ++ crates/warp_cli/src/local_control/selectors.rs | 1 + 24 files changed, 84 insertions(+), 2 deletions(-) diff --git a/app/src/bin/warpctrl.rs b/app/src/bin/warpctrl.rs index bda8e50f74..c3a4656e58 100644 --- a/app/src/bin/warpctrl.rs +++ b/app/src/bin/warpctrl.rs @@ -1,3 +1,4 @@ +//! Thin binary wrapper for the standalone `warpctrl` executable bundled with Warp. use std::process::ExitCode; fn main() -> ExitCode { diff --git a/app/src/local_control/bridge.rs b/app/src/local_control/bridge.rs index fe614d0d3b..022f9b2869 100644 --- a/app/src/local_control/bridge.rs +++ b/app/src/local_control/bridge.rs @@ -1,3 +1,7 @@ +//! Bridge between protocol-level control requests and Warp application models. +//! +//! The bridge validates protocol version, selectors, credentials, and settings +//! before routing each supported action to an app-side handler. use ::local_control::auth::CredentialGrant; use ::local_control::{ ActionKind, ControlError, ErrorCode, InstanceId, RequestEnvelope, ResponseEnvelope, @@ -9,6 +13,7 @@ use crate::local_control::handlers::{layout, metadata}; use crate::local_control::permissions::{ensure_action_allowed, ensure_feature_enabled}; use crate::local_control::resolver::validate_action_params; +/// WarpUI model that executes already-authenticated local-control actions. pub struct LocalControlBridge { instance_id: Option<InstanceId>, } diff --git a/app/src/local_control/handlers.rs b/app/src/local_control/handlers.rs index be809e75fd..ca3f5fa511 100644 --- a/app/src/local_control/handlers.rs +++ b/app/src/local_control/handlers.rs @@ -1,2 +1,3 @@ +//! App-side action handlers invoked by the local-control bridge. pub(super) mod layout; pub(super) mod metadata; diff --git a/app/src/local_control/handlers/layout.rs b/app/src/local_control/handlers/layout.rs index 9749d158b1..bcf1d42640 100644 --- a/app/src/local_control/handlers/layout.rs +++ b/app/src/local_control/handlers/layout.rs @@ -1,3 +1,4 @@ +//! Layout mutation handlers for local-control actions. use ::local_control::protocol::TargetSelector; use ::local_control::{ActionKind, ControlError, ErrorCode, InstanceId}; use serde_json::json; diff --git a/app/src/local_control/handlers/metadata.rs b/app/src/local_control/handlers/metadata.rs index d1607483ce..ff6270a9df 100644 --- a/app/src/local_control/handlers/metadata.rs +++ b/app/src/local_control/handlers/metadata.rs @@ -1,3 +1,4 @@ +//! Metadata response builders for local-control introspection actions. use ::local_control::{ActionKind, InstanceId, PROTOCOL_VERSION}; use serde_json::json; use warp_core::channel::ChannelState; diff --git a/app/src/local_control/mod.rs b/app/src/local_control/mod.rs index b6cae1aba9..2bf2931053 100644 --- a/app/src/local_control/mod.rs +++ b/app/src/local_control/mod.rs @@ -1,3 +1,7 @@ +//! Local HTTP server entry point for Warp control requests. +//! +//! This module owns the in-process listener, discovery registration, credential +//! broker endpoint, and request handoff from Axum into the WarpUI model graph. mod bridge; mod handlers; mod permissions; @@ -27,13 +31,14 @@ use warpui::{Entity, ModelContext, ModelSpawner, SingletonEntity}; pub use bridge::LocalControlBridge; use permissions::{ensure_action_allowed, ensure_feature_enabled}; +/// Shared state made available to Axum handlers for one local-control server. #[derive(Clone)] struct ControlServerState { bridge_spawner: ModelSpawner<LocalControlBridge>, instance_id: InstanceId, credentials: Arc<Mutex<HashMap<String, CredentialGrant>>>, } - +/// Process-local server that exposes Warp control actions over localhost. pub struct LocalControlServer { _runtime: Option<tokio::runtime::Runtime>, control_endpoint: Option<ControlEndpoint>, diff --git a/app/src/local_control/permissions.rs b/app/src/local_control/permissions.rs index 7d2fcc896b..6c7c448675 100644 --- a/app/src/local_control/permissions.rs +++ b/app/src/local_control/permissions.rs @@ -1,3 +1,4 @@ +//! Permission checks that map protocol action metadata onto local settings. use crate::features::FeatureFlag; use crate::settings::{ LocalControlInvocationContext, LocalControlPermissionCategory, LocalControlSettings, diff --git a/app/src/local_control/resolver.rs b/app/src/local_control/resolver.rs index d9db9bda88..7a9086d058 100644 --- a/app/src/local_control/resolver.rs +++ b/app/src/local_control/resolver.rs @@ -1,3 +1,4 @@ +//! Target and parameter validation for the first local-control action slice. use ::local_control::protocol::{PaneTarget, TabTarget, TargetSelector, WindowTarget}; use ::local_control::{ActionKind, ControlError, ErrorCode}; use warpui::ModelContext; diff --git a/app/src/settings/local_control.rs b/app/src/settings/local_control.rs index 0660fa486c..d5b1f925ae 100644 --- a/app/src/settings/local_control.rs +++ b/app/src/settings/local_control.rs @@ -1,11 +1,14 @@ +//! Private local settings that gate Warp control by invocation context and risk category. use settings::{macros::define_settings_group, SupportedPlatforms, SyncToCloud}; +/// Source context for a local-control request. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum LocalControlInvocationContext { InsideWarp, OutsideWarp, } +/// Coarse permission buckets used to gate groups of control actions. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum LocalControlPermissionCategory { MetadataReads, diff --git a/app/src/settings_view/scripting_page.rs b/app/src/settings_view/scripting_page.rs index afa901e06a..216d2e2973 100644 --- a/app/src/settings_view/scripting_page.rs +++ b/app/src/settings_view/scripting_page.rs @@ -1,3 +1,4 @@ +//! Settings UI for local scripting and Warp control permissions. use super::{ settings_page::{ render_body_item, render_settings_info_banner, LocalOnlyIconState, MatchData, PageType, @@ -26,6 +27,7 @@ use warpui::ui_components::components::UiComponent; use warpui::ui_components::switch::SwitchStateHandle; use warpui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle}; +/// Toggle rows shown on the Settings > Scripting page for local-control gates. #[derive(Clone, Copy, Debug)] pub enum ScriptingToggle { InsideWarpControl, @@ -255,7 +257,6 @@ impl ScriptingToggle { } } } - #[derive(Clone, Debug)] pub enum ScriptingSettingsPageAction { Toggle(ScriptingToggle), diff --git a/crates/local_control/src/auth.rs b/crates/local_control/src/auth.rs index ec7e9c4d21..28be243e2b 100644 --- a/crates/local_control/src/auth.rs +++ b/crates/local_control/src/auth.rs @@ -1,3 +1,4 @@ +//! Credential request, issuance, and validation types for local control. use base64::Engine as _; use chrono::{DateTime, Duration, Utc}; use rand::RngCore as _; @@ -10,6 +11,7 @@ use crate::protocol::{ PermissionCategory, RiskTier, StateDataCategory, }; +/// Bearer token used to authorize a single scoped local-control credential. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AuthToken(String); @@ -60,6 +62,7 @@ impl AuthToken { } } +/// Request for a short-lived credential scoped to one action and invocation context. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CredentialRequest { pub protocol_version: u32, @@ -102,6 +105,7 @@ impl CredentialRequest { } } +/// Client-facing credential response containing a bearer secret and its grant metadata. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ScopedCredential { pub bearer_token: String, @@ -114,6 +118,7 @@ impl ScopedCredential { } } +/// Server-issued authorization grant for a single action. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CredentialGrant { pub credential_id: String, @@ -127,6 +132,8 @@ pub struct CredentialGrant { pub issued_at: DateTime<Utc>, pub expires_at: DateTime<Utc>, } + +/// Authenticated user context attached to a credential grant when required. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AuthenticatedUserGrant { pub required: bool, diff --git a/crates/local_control/src/catalog.rs b/crates/local_control/src/catalog.rs index 7815350358..6ddd129662 100644 --- a/crates/local_control/src/catalog.rs +++ b/crates/local_control/src/catalog.rs @@ -1,7 +1,9 @@ +//! Action catalog and metadata used for discovery, permissions, and CLI support. use serde::{Deserialize, Serialize}; pub const PROTOCOL_VERSION: u32 = 1; +/// Runtime context from which a control request was initiated. #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum InvocationContext { @@ -9,6 +11,7 @@ pub enum InvocationContext { OutsideWarp, } +/// Proof that lets Warp distinguish trusted in-app terminals from external clients. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ExecutionContextProof { @@ -16,6 +19,7 @@ pub enum ExecutionContextProof { ExternalClient, } +/// User-facing risk tier for an action. #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RiskTier { @@ -25,6 +29,7 @@ pub enum RiskTier { MutatingDestructiveOrExecution, } +/// Category of Warp state or data an action reads or mutates. #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum StateDataCategory { @@ -35,6 +40,7 @@ pub enum StateDataCategory { UnderlyingDataMutation, } +/// Settings permission bucket required before an action may execute. #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PermissionCategory { @@ -45,11 +51,13 @@ pub enum PermissionCategory { MutateUnderlyingData, } +/// Whether an action requires an authenticated Warp user context. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AuthenticatedUserRequirement { pub required: bool, } +/// Level of Warp hierarchy an action targets. #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TargetScope { @@ -63,6 +71,7 @@ pub enum TargetScope { Surface, } +/// Whether an action has an app-side implementation in this stack layer. #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ActionImplementationStatus { @@ -70,6 +79,7 @@ pub enum ActionImplementationStatus { Stub, } +/// Discoverable metadata describing one local-control action. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ActionMetadata { pub kind: ActionKind, @@ -84,6 +94,7 @@ pub struct ActionMetadata { pub target_scope: TargetScope, } +/// Stable protocol name for every planned `warpctrl` action. #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum ActionKind { #[serde(rename = "instance.list")] diff --git a/crates/local_control/src/client.rs b/crates/local_control/src/client.rs index 90dd10f016..a2b04d9ed4 100644 --- a/crates/local_control/src/client.rs +++ b/crates/local_control/src/client.rs @@ -1,3 +1,4 @@ +//! Blocking client helpers used by the standalone `warpctrl` CLI. use crate::auth::{CredentialRequest, ScopedCredential}; use crate::discovery::InstanceRecord; use crate::protocol::{ diff --git a/crates/local_control/src/discovery.rs b/crates/local_control/src/discovery.rs index 8a7156feb2..dcafc7cb5e 100644 --- a/crates/local_control/src/discovery.rs +++ b/crates/local_control/src/discovery.rs @@ -1,3 +1,4 @@ +//! Filesystem discovery records for running local Warp instances. use std::fs; use std::path::{Path, PathBuf}; @@ -8,6 +9,7 @@ use crate::protocol::{ActionMetadata, ControlError, ErrorCode, PROTOCOL_VERSION} const DISCOVERY_DIR_ENV: &str = "WARP_LOCAL_CONTROL_DISCOVERY_DIR"; +/// Stable identifier for one running Warp instance. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct InstanceId(pub String); @@ -24,6 +26,7 @@ impl Default for InstanceId { } } +/// Loopback HTTP endpoint for a running local-control server. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ControlEndpoint { pub host: String, @@ -47,11 +50,13 @@ impl ControlEndpoint { } } +/// Discovery reference to the endpoint that issues scoped credentials. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CredentialBrokerReference { pub endpoint: ControlEndpoint, } +/// Filesystem-published metadata for a running Warp app process. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct InstanceRecord { pub protocol_version: u32, @@ -96,6 +101,7 @@ impl InstanceRecord { } } +/// RAII registration that publishes and removes one discovery record. pub struct RegisteredInstance { record: InstanceRecord, path: PathBuf, diff --git a/crates/local_control/src/lib.rs b/crates/local_control/src/lib.rs index abb368b88b..f44b377256 100644 --- a/crates/local_control/src/lib.rs +++ b/crates/local_control/src/lib.rs @@ -1,3 +1,8 @@ +//! Shared protocol, discovery, authentication, and client types for local Warp control. +//! +//! The `local_control` crate is intentionally UI-agnostic so the Warp app and +//! `warpctrl` CLI can share the same wire envelopes, action catalog, discovery +//! records, selectors, and credential validation rules. pub mod auth; pub mod catalog; pub mod client; diff --git a/crates/local_control/src/protocol.rs b/crates/local_control/src/protocol.rs index 77fffebcb0..a9afc71c16 100644 --- a/crates/local_control/src/protocol.rs +++ b/crates/local_control/src/protocol.rs @@ -1,3 +1,4 @@ +//! Wire protocol envelopes and error types for Warp local control. use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -10,6 +11,7 @@ pub use crate::selectors::{ PaneSelector, PaneTarget, TabSelector, TabTarget, TargetSelector, WindowSelector, WindowTarget, }; +/// Top-level request sent by a local-control client to a Warp instance. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RequestEnvelope { pub protocol_version: u32, @@ -30,6 +32,7 @@ impl RequestEnvelope { } } +/// Requested action and action-specific JSON parameters. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Action { pub kind: ActionKind, @@ -46,6 +49,7 @@ impl Action { } } +/// Top-level response returned by a Warp instance for a control request. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ResponseEnvelope { pub protocol_version: u32, @@ -71,6 +75,7 @@ impl ResponseEnvelope { } } +/// Success or error payload for a control response. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "status", rename_all = "snake_case")] pub enum ControlResponse { @@ -78,6 +83,7 @@ pub enum ControlResponse { Error { error: ControlError }, } +/// Error envelope used when a request cannot be decoded into a full request envelope. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ErrorResponseEnvelope { pub protocol_version: u32, @@ -93,6 +99,7 @@ impl ErrorResponseEnvelope { } } +/// Structured error returned by local-control protocol and transport layers. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)] #[error("{code}: {message}")] pub struct ControlError { @@ -124,6 +131,7 @@ impl ControlError { } } +/// Stable error code surfaced to CLI clients and automation. #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ErrorCode { diff --git a/crates/local_control/src/selection.rs b/crates/local_control/src/selection.rs index bf9d45a89b..5d2cb0164a 100644 --- a/crates/local_control/src/selection.rs +++ b/crates/local_control/src/selection.rs @@ -1,6 +1,8 @@ +//! Instance selection helpers for local-control clients. use crate::discovery::{InstanceId, InstanceRecord}; use crate::protocol::{ControlError, ErrorCode}; +/// CLI-level selector for choosing one discovered Warp instance. #[derive(Debug, Clone, PartialEq, Eq)] pub enum InstanceSelector { Active, diff --git a/crates/local_control/src/selectors.rs b/crates/local_control/src/selectors.rs index 8dcfaba456..e76ba95a3f 100644 --- a/crates/local_control/src/selectors.rs +++ b/crates/local_control/src/selectors.rs @@ -1,17 +1,22 @@ +//! Serializable selectors for targeting windows, tabs, and panes. use serde::{Deserialize, Serialize}; +/// Opaque window identifier supplied by Warp metadata. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] pub struct WindowSelector(pub String); +/// Opaque tab identifier supplied by Warp metadata. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] pub struct TabSelector(pub String); +/// Opaque pane identifier supplied by Warp metadata. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] pub struct PaneSelector(pub String); +/// Hierarchical target for actions that operate on a specific Warp surface. #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub struct TargetSelector { @@ -23,6 +28,7 @@ pub struct TargetSelector { pub pane: Option<PaneTarget>, } +/// Window-level target selector. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum WindowTarget { @@ -32,6 +38,7 @@ pub enum WindowTarget { Title { title: String }, } +/// Tab-level target selector. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum TabTarget { @@ -41,6 +48,7 @@ pub enum TabTarget { Title { title: String }, } +/// Pane-level target selector. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum PaneTarget { diff --git a/crates/warp_cli/src/bin/warpctrl.rs b/crates/warp_cli/src/bin/warpctrl.rs index bda8e50f74..f66e3e7774 100644 --- a/crates/warp_cli/src/bin/warpctrl.rs +++ b/crates/warp_cli/src/bin/warpctrl.rs @@ -1,3 +1,4 @@ +//! Binary entry point for the standalone `warpctrl` CLI. use std::process::ExitCode; fn main() -> ExitCode { diff --git a/crates/warp_cli/src/local_control/commands.rs b/crates/warp_cli/src/local_control/commands.rs index 32defccc4a..6bc57585c5 100644 --- a/crates/warp_cli/src/local_control/commands.rs +++ b/crates/warp_cli/src/local_control/commands.rs @@ -1,3 +1,4 @@ +//! Implementations for user-facing `warpctrl` command groups. use local_control::protocol::{ Action, ActionKind, ActionMetadata, ControlError, ErrorCode, RequestEnvelope, }; @@ -10,6 +11,7 @@ use crate::local_control::output::{write_json, write_json_line}; use crate::local_control::selectors::instance_selector; use crate::local_control::{AppCommand, InstanceCommand, TabCommand, TargetArgs}; +/// Display-oriented projection of a discoverable Warp instance. #[derive(Serialize)] struct InstanceSummary { instance_id: String, diff --git a/crates/warp_cli/src/local_control/completions.rs b/crates/warp_cli/src/local_control/completions.rs index 119318d91a..2664e2520a 100644 --- a/crates/warp_cli/src/local_control/completions.rs +++ b/crates/warp_cli/src/local_control/completions.rs @@ -1,3 +1,4 @@ +//! Shell completion generation for `warpctrl`. use clap_complete::aot::{Shell, generate}; use local_control::protocol::{ControlError, ErrorCode}; diff --git a/crates/warp_cli/src/local_control/mod.rs b/crates/warp_cli/src/local_control/mod.rs index 6524c75ed5..12f6cce282 100644 --- a/crates/warp_cli/src/local_control/mod.rs +++ b/crates/warp_cli/src/local_control/mod.rs @@ -1,3 +1,4 @@ +//! Command-line interface for controlling a running local Warp app. mod commands; mod completions; mod output; @@ -13,6 +14,7 @@ use commands::{run_app_command, run_instance_command, run_tab_command}; use completions::generate_completions_to_stdout; use output::write_control_error; +/// Parsed top-level arguments for `warpctrl`. #[derive(Debug, Parser)] #[command( name = "warpctrl", @@ -59,6 +61,7 @@ impl ControlArgs { } } +/// Top-level `warpctrl` command groups. #[derive(Debug, Clone, Subcommand)] pub enum ControlCommand { /// Inspect local Warp app instances. @@ -95,12 +98,14 @@ pub enum ControlCommand { }, } +/// Commands that inspect locally discoverable Warp instances. #[derive(Debug, Clone, Subcommand)] pub enum InstanceCommand { /// List locally discoverable Warp instances. List, } +/// Commands that inspect the selected Warp app instance. #[derive(Debug, Clone, Subcommand)] pub enum AppCommand { /// Check that the selected local Warp app responds. @@ -110,12 +115,14 @@ pub enum AppCommand { Version(TargetArgs), } +/// Commands that control tabs in the selected Warp app instance. #[derive(Debug, Clone, Subcommand)] pub enum TabCommand { /// Create a new terminal tab in the active window. Create(TargetArgs), } +/// Common flags for selecting which running Warp instance receives a command. #[derive(Debug, Clone, Args, Default)] pub struct TargetArgs { /// Target a specific local Warp instance id from `warp instance list`. diff --git a/crates/warp_cli/src/local_control/output.rs b/crates/warp_cli/src/local_control/output.rs index 99c3af7b3e..de70846039 100644 --- a/crates/warp_cli/src/local_control/output.rs +++ b/crates/warp_cli/src/local_control/output.rs @@ -1,3 +1,4 @@ +//! Output rendering helpers for `warpctrl`. use std::io::Write as _; use local_control::protocol::{ControlError, ErrorCode}; @@ -5,6 +6,7 @@ use serde::Serialize; use crate::agent::OutputFormat; +/// JSON/NDJSON error payload emitted by `warpctrl`. #[derive(Serialize)] pub(crate) struct ErrorSummary<'a> { pub ok: bool, diff --git a/crates/warp_cli/src/local_control/selectors.rs b/crates/warp_cli/src/local_control/selectors.rs index 97da547b7a..8633c2cc31 100644 --- a/crates/warp_cli/src/local_control/selectors.rs +++ b/crates/warp_cli/src/local_control/selectors.rs @@ -1,3 +1,4 @@ +//! CLI argument conversion into shared local-control selectors. use local_control::selection::InstanceSelector; use crate::local_control::TargetArgs; From bed8ee0e3c6d577f1da8829d5c31ea85fea37709 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Sun, 24 May 2026 14:10:26 -0600 Subject: [PATCH 20/48] Address warpctrl foundation review feedback Co-Authored-By: Oz <oz-agent@warp.dev> --- app/src/local_control/mod.rs | 24 +++++- app/src/local_control/resolver.rs | 5 ++ app/src/settings/local_control.rs | 7 ++ crates/local_control/src/auth.rs | 11 ++- crates/local_control/src/discovery.rs | 93 ++------------------- crates/local_control/src/discovery_tests.rs | 84 +++++++++++++++++++ crates/local_control/src/selection.rs | 49 +---------- crates/local_control/src/selection_tests.rs | 45 ++++++++++ specs/warp-control-cli/PRODUCT.md | 1 + 9 files changed, 183 insertions(+), 136 deletions(-) create mode 100644 crates/local_control/src/discovery_tests.rs create mode 100644 crates/local_control/src/selection_tests.rs diff --git a/app/src/local_control/mod.rs b/app/src/local_control/mod.rs index 2bf2931053..e93f8e7798 100644 --- a/app/src/local_control/mod.rs +++ b/app/src/local_control/mod.rs @@ -2,6 +2,25 @@ //! //! This module owns the in-process listener, discovery registration, credential //! broker endpoint, and request handoff from Axum into the WarpUI model graph. +//! +//! Authentication is split into two localhost endpoints. Clients first request a +//! short-lived scoped credential from `/v1/control/credentials`; the localhost +//! server running inside Warp checks the feature flag, requested invocation +//! context, action metadata, execution-context proof, and Settings > Scripting +//! permissions before minting a bearer token. The client then presents that +//! bearer token to `/v1/control`, where the server looks up the in-memory grant, +//! verifies it still matches the requested action, and only then hands the +//! request to the main-thread `LocalControlBridge`. +//! +//! The Settings > Scripting gates used here are provisional foundation-branch +//! authority. They are private and local-only, but private preferences are not +//! equivalent to tamper-resistant secure storage; before outside-Warp control +//! or broader grants ship, the authoritative enablement bits should move to +//! protected storage where the platform supports it. +//! +//! This foundation branch intentionally keeps raw bearer tokens out of +//! discovery records: discovery only exposes endpoint metadata and credential +//! broker references when outside-Warp control is enabled. mod bridge; mod handlers; mod permissions; @@ -31,14 +50,15 @@ use warpui::{Entity, ModelContext, ModelSpawner, SingletonEntity}; pub use bridge::LocalControlBridge; use permissions::{ensure_action_allowed, ensure_feature_enabled}; -/// Shared state made available to Axum handlers for one local-control server. +/// Shared state made available to Axum handlers for one localhost server +/// running inside Warp. #[derive(Clone)] struct ControlServerState { bridge_spawner: ModelSpawner<LocalControlBridge>, instance_id: InstanceId, credentials: Arc<Mutex<HashMap<String, CredentialGrant>>>, } -/// Process-local server that exposes Warp control actions over localhost. +/// Process-local localhost server running inside Warp for control actions. pub struct LocalControlServer { _runtime: Option<tokio::runtime::Runtime>, control_endpoint: Option<ControlEndpoint>, diff --git a/app/src/local_control/resolver.rs b/app/src/local_control/resolver.rs index 7a9086d058..9c5f7ea1a8 100644 --- a/app/src/local_control/resolver.rs +++ b/app/src/local_control/resolver.rs @@ -45,6 +45,11 @@ pub(crate) fn validate_tab_create_target(target: &TargetSelector) -> Result<(), Ok(()) } +/// Validates action-specific params implemented by this branch stack layer. +/// +/// This is intentionally narrow while `zach/warp-cli-core-foundation` is the +/// bottom branch of the stack: later branches add their own params and expand +/// this validation alongside the corresponding action handlers. pub(crate) fn validate_action_params(action: &::local_control::Action) -> Result<(), ControlError> { if action.kind != ActionKind::TabCreate { return Ok(()); diff --git a/app/src/settings/local_control.rs b/app/src/settings/local_control.rs index d5b1f925ae..a38dbd3278 100644 --- a/app/src/settings/local_control.rs +++ b/app/src/settings/local_control.rs @@ -1,4 +1,11 @@ //! Private local settings that gate Warp control by invocation context and risk category. +//! +//! These settings are local-only and kept out of the user-visible settings file, +//! but this foundation branch still stores them in the existing private +//! preferences backend. Before outside-Warp control or broader grants ship, +//! the authoritative enablement bits should move to protected storage where +//! available, such as macOS Keychain or the platform equivalent, so external +//! apps cannot enable local control by editing ordinary preferences. use settings::{macros::define_settings_group, SupportedPlatforms, SyncToCloud}; /// Source context for a local-control request. diff --git a/crates/local_control/src/auth.rs b/crates/local_control/src/auth.rs index 28be243e2b..115f3b0a46 100644 --- a/crates/local_control/src/auth.rs +++ b/crates/local_control/src/auth.rs @@ -84,6 +84,14 @@ impl CredentialRequest { } } + /// Verifies whether the caller may claim its requested invocation context. + /// + /// External callers do not receive elevated trust from this proof and are + /// allowed only when the selected Warp instance enables outside-Warp + /// control. Inside-Warp callers must eventually present an app-issued, + /// session-bound `VerifiedWarpTerminal` proof; until that broker path lands, + /// this foundation branch rejects inside-Warp credential requests rather + /// than trusting a caller-declared label or spoofable environment variable. pub fn verify_execution_context_proof(&self) -> Result<(), ControlError> { match (&self.invocation_context, &self.execution_context_proof) { (InvocationContext::InsideWarp, _) => Err(ControlError::new( @@ -118,7 +126,8 @@ impl ScopedCredential { } } -/// Server-issued authorization grant for a single action. +/// Authorization grant issued by the localhost server running inside Warp for a +/// single action. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CredentialGrant { pub credential_id: String, diff --git a/crates/local_control/src/discovery.rs b/crates/local_control/src/discovery.rs index dcafc7cb5e..870a0fa94b 100644 --- a/crates/local_control/src/discovery.rs +++ b/crates/local_control/src/discovery.rs @@ -162,6 +162,11 @@ fn write_record(path: &Path, record: &InstanceRecord) -> Result<(), ControlError } impl Drop for RegisteredInstance { + // Drop-time cleanup is the best-effort fast path for graceful shutdown. + // `list_instances_from_dir` is the robust cleanup path: it treats records + // whose PID is no longer alive as stale, removes them, and ignores malformed + // or unreadable records so a crash can leave at most a temporary zombie + // reference that is pruned on the next discovery scan. fn drop(&mut self) { let _ = fs::remove_file(&self.path); } @@ -257,89 +262,5 @@ fn set_private_permissions(path: &Path) { fn set_private_permissions(_path: &Path) {} #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn registered_instance_round_trips_discovery_record() { - let dir = tempfile::tempdir().expect("temp dir"); - let record = InstanceRecord::for_current_process( - Some(ControlEndpoint::localhost(4000)), - "local", - "dev.warp.WarpLocal", - Some("test".to_owned()), - crate::protocol::ActionKind::implemented_metadata(), - ); - let _registered = RegisteredInstance::register_in_dir_for_test(record.clone(), dir.path()) - .expect("registered"); - let records = list_instances_from_dir(dir.path()); - assert_eq!(records, vec![record]); - } - - #[test] - fn serialized_discovery_record_does_not_contain_raw_credential_material() { - let raw_secret = "raw-secret-token-material"; - let record = InstanceRecord::for_current_process( - Some(ControlEndpoint::localhost(4000)), - "local", - "dev.warp.WarpLocal", - Some("test".to_owned()), - crate::protocol::ActionKind::implemented_metadata(), - ); - let serialized = serde_json::to_string_pretty(&record).expect("serialize"); - assert!(!serialized.contains(raw_secret)); - assert!(!serialized.contains("auth_token")); - assert!(!serialized.contains("bearer_token")); - } - - #[test] - fn disabled_outside_warp_record_does_not_expose_actionable_authority() { - let record = InstanceRecord::for_current_process( - None, - "local", - "dev.warp.WarpLocal", - Some("test".to_owned()), - crate::protocol::ActionKind::implemented_metadata(), - ); - assert!(!record.outside_warp_control_enabled); - assert!(record.endpoint.is_none()); - assert!(record.credential_broker.is_none()); - } - - #[cfg(unix)] - #[test] - fn discovery_directory_is_owner_only_on_unix() { - use std::os::unix::fs::PermissionsExt as _; - - let dir = tempfile::tempdir().expect("temp dir"); - let record = InstanceRecord::for_current_process( - Some(ControlEndpoint::localhost(4000)), - "local", - "dev.warp.WarpLocal", - Some("test".to_owned()), - crate::protocol::ActionKind::implemented_metadata(), - ); - let _registered = - RegisteredInstance::register_in_dir_for_test(record, dir.path()).expect("registered"); - let mode = fs::metadata(dir.path()) - .expect("metadata") - .permissions() - .mode() - & 0o777; - assert_eq!(mode, 0o700); - } - - impl RegisteredInstance { - fn register_in_dir_for_test( - record: InstanceRecord, - dir: &Path, - ) -> Result<Self, ControlError> { - fs::create_dir_all(dir).expect("create dir"); - set_private_dir_permissions(dir); - let path = record_path(dir, &record.instance_id); - let bytes = serde_json::to_vec_pretty(&record).expect("serialize"); - fs::write(&path, bytes).expect("write"); - Ok(Self { record, path }) - } - } -} +#[path = "discovery_tests.rs"] +mod tests; diff --git a/crates/local_control/src/discovery_tests.rs b/crates/local_control/src/discovery_tests.rs new file mode 100644 index 0000000000..37504e011a --- /dev/null +++ b/crates/local_control/src/discovery_tests.rs @@ -0,0 +1,84 @@ +use std::fs; +use std::path::Path; + +use super::*; + +#[test] +fn registered_instance_round_trips_discovery_record() { + let dir = tempfile::tempdir().expect("temp dir"); + let record = InstanceRecord::for_current_process( + Some(ControlEndpoint::localhost(4000)), + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + let _registered = RegisteredInstance::register_in_dir_for_test(record.clone(), dir.path()) + .expect("registered"); + let records = list_instances_from_dir(dir.path()); + assert_eq!(records, vec![record]); +} + +#[test] +fn serialized_discovery_record_does_not_contain_raw_credential_material() { + let raw_secret = "raw-secret-token-material"; + let record = InstanceRecord::for_current_process( + Some(ControlEndpoint::localhost(4000)), + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + let serialized = serde_json::to_string_pretty(&record).expect("serialize"); + assert!(!serialized.contains(raw_secret)); + assert!(!serialized.contains("auth_token")); + assert!(!serialized.contains("bearer_token")); +} + +#[test] +fn disabled_outside_warp_record_does_not_expose_actionable_authority() { + let record = InstanceRecord::for_current_process( + None, + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + assert!(!record.outside_warp_control_enabled); + assert!(record.endpoint.is_none()); + assert!(record.credential_broker.is_none()); +} + +#[cfg(unix)] +#[test] +fn discovery_directory_is_owner_only_on_unix() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().expect("temp dir"); + let record = InstanceRecord::for_current_process( + Some(ControlEndpoint::localhost(4000)), + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + let _registered = + RegisteredInstance::register_in_dir_for_test(record, dir.path()).expect("registered"); + let mode = fs::metadata(dir.path()) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o700); +} + +impl RegisteredInstance { + fn register_in_dir_for_test(record: InstanceRecord, dir: &Path) -> Result<Self, ControlError> { + fs::create_dir_all(dir).expect("create dir"); + set_private_dir_permissions(dir); + let path = record_path(dir, &record.instance_id); + let bytes = serde_json::to_vec_pretty(&record).expect("serialize"); + fs::write(&path, bytes).expect("write"); + Ok(Self { record, path }) + } +} diff --git a/crates/local_control/src/selection.rs b/crates/local_control/src/selection.rs index 5d2cb0164a..b090f86b12 100644 --- a/crates/local_control/src/selection.rs +++ b/crates/local_control/src/selection.rs @@ -54,50 +54,5 @@ fn select_active(records: &[InstanceRecord]) -> Result<InstanceRecord, ControlEr } #[cfg(test)] -mod tests { - use chrono::Utc; - - use super::*; - use crate::discovery::ControlEndpoint; - use crate::protocol::{ActionKind, PROTOCOL_VERSION}; - - fn record(id: &str, pid: u32) -> InstanceRecord { - InstanceRecord { - protocol_version: PROTOCOL_VERSION, - instance_id: InstanceId(id.to_owned()), - pid, - channel: "local".to_owned(), - app_id: "dev.warp.WarpLocal".to_owned(), - app_version: None, - started_at: Utc::now(), - executable_path: None, - endpoint: Some(ControlEndpoint::localhost(4000)), - credential_broker: Some(crate::discovery::CredentialBrokerReference { - endpoint: ControlEndpoint::localhost(4000), - }), - outside_warp_control_enabled: true, - actions: vec![ActionKind::TabCreate.metadata()], - } - } - - #[test] - fn selects_instance_by_id() { - let records = vec![record("one", 1), record("two", 2)]; - let selected = select_instance(&records, &InstanceSelector::Id(InstanceId("two".into()))) - .expect("selected"); - assert_eq!(selected.pid, 2); - } - - #[test] - fn active_selector_rejects_ambiguity() { - let records = vec![record("one", 1), record("two", 2)]; - let err = select_instance(&records, &InstanceSelector::Active).expect_err("ambiguous"); - assert_eq!(err.code, ErrorCode::AmbiguousInstance); - } - - #[test] - fn active_selector_rejects_no_instances() { - let err = select_instance(&[], &InstanceSelector::Active).expect_err("no instance"); - assert_eq!(err.code, ErrorCode::NoInstance); - } -} +#[path = "selection_tests.rs"] +mod tests; diff --git a/crates/local_control/src/selection_tests.rs b/crates/local_control/src/selection_tests.rs new file mode 100644 index 0000000000..4a4ba4ca4b --- /dev/null +++ b/crates/local_control/src/selection_tests.rs @@ -0,0 +1,45 @@ +use chrono::Utc; + +use super::*; +use crate::discovery::{ControlEndpoint, CredentialBrokerReference}; +use crate::protocol::{ActionKind, PROTOCOL_VERSION}; + +fn record(id: &str, pid: u32) -> InstanceRecord { + InstanceRecord { + protocol_version: PROTOCOL_VERSION, + instance_id: InstanceId(id.to_owned()), + pid, + channel: "local".to_owned(), + app_id: "dev.warp.WarpLocal".to_owned(), + app_version: None, + started_at: Utc::now(), + executable_path: None, + endpoint: Some(ControlEndpoint::localhost(4000)), + credential_broker: Some(CredentialBrokerReference { + endpoint: ControlEndpoint::localhost(4000), + }), + outside_warp_control_enabled: true, + actions: vec![ActionKind::TabCreate.metadata()], + } +} + +#[test] +fn selects_instance_by_id() { + let records = vec![record("one", 1), record("two", 2)]; + let selected = select_instance(&records, &InstanceSelector::Id(InstanceId("two".into()))) + .expect("selected"); + assert_eq!(selected.pid, 2); +} + +#[test] +fn active_selector_rejects_ambiguity() { + let records = vec![record("one", 1), record("two", 2)]; + let err = select_instance(&records, &InstanceSelector::Active).expect_err("ambiguous"); + assert_eq!(err.code, ErrorCode::AmbiguousInstance); +} + +#[test] +fn active_selector_rejects_no_instances() { + let err = select_instance(&[], &InstanceSelector::Active).expect_err("no instance"); + assert_eq!(err.code, ErrorCode::NoInstance); +} diff --git a/specs/warp-control-cli/PRODUCT.md b/specs/warp-control-cli/PRODUCT.md index c37fa23e71..b2989c9577 100644 --- a/specs/warp-control-cli/PRODUCT.md +++ b/specs/warp-control-cli/PRODUCT.md @@ -362,6 +362,7 @@ Warp should include a built-in Agent skill for `warpctrl`, analogous to the bund CLI documentation should be generated from the command catalog instead of maintained by hand in multiple places: - The typed action catalog is the source of truth for command names, selector flags, parameters, output formats, state/data category, required permission, authenticated-user requirement, support status, and examples. - `warpctrl help`, shell completions, markdown reference docs, the built-in Warp Agent skill, and the operator README should be generated or checked from that catalog so they cannot drift silently. +- A later branch should add native Warp completions for `warpctrl` in addition to shell completions so Warp can suggest commands, flags, selectors, and action names directly in the input editor. - Generated documentation must distinguish implemented commands from planned catalog entries. A command may appear in specs as planned, but public operator docs must not imply it is usable until the selected app build advertises support for it. - CI or presubmit checks should fail when CLI parser/help output, generated reference docs, completions, or the built-in skill are stale relative to the command catalog. ## Action classification and permission model From a5be127091f6a74742d6ab4f7c1926a7a4ca1e3d Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Sun, 24 May 2026 15:14:26 -0600 Subject: [PATCH 21/48] Clarify warpctrl outside-Warp foundation Co-Authored-By: Oz <oz-agent@warp.dev> --- app/src/local_control/mod.rs | 16 +- app/src/local_control/mod_tests.rs | 55 +++---- app/src/local_control/permissions.rs | 26 ++- app/src/settings/local_control.rs | 146 +++-------------- app/src/settings/local_control_tests.rs | 95 +++-------- app/src/settings_view/scripting_page.rs | 178 ++------------------- crates/local_control/src/auth_tests.rs | 16 +- crates/local_control/src/catalog.rs | 11 +- crates/local_control/src/protocol_tests.rs | 5 +- specs/warp-control-cli/PRODUCT.md | 9 +- specs/warp-control-cli/SECURITY.md | 8 +- specs/warp-control-cli/TECH.md | 52 +++--- 12 files changed, 171 insertions(+), 446 deletions(-) diff --git a/app/src/local_control/mod.rs b/app/src/local_control/mod.rs index e93f8e7798..a1fd7b0bf9 100644 --- a/app/src/local_control/mod.rs +++ b/app/src/local_control/mod.rs @@ -7,10 +7,13 @@ //! short-lived scoped credential from `/v1/control/credentials`; the localhost //! server running inside Warp checks the feature flag, requested invocation //! context, action metadata, execution-context proof, and Settings > Scripting -//! permissions before minting a bearer token. The client then presents that -//! bearer token to `/v1/control`, where the server looks up the in-memory grant, -//! verifies it still matches the requested action, and only then hands the -//! request to the main-thread `LocalControlBridge`. +//! permissions before minting a bearer token. This foundation branch currently +//! supports only outside-Warp credential requests; verified inside-Warp +//! terminal credentials remain future work until the app-issued proof broker is +//! implemented. The client then presents that bearer token to `/v1/control`, +//! where the server looks up the in-memory grant, verifies it still matches the +//! requested action, and only then hands the request to the main-thread +//! `LocalControlBridge`. //! //! The Settings > Scripting gates used here are provisional foundation-branch //! authority. They are private and local-only, but private preferences are not @@ -30,7 +33,6 @@ use std::collections::HashMap; use std::net::SocketAddr; use std::sync::{Arc, Mutex}; -use crate::settings::LocalControlInvocationContext; use ::local_control::auth::{CredentialGrant, CredentialRequest, ScopedCredential}; use ::local_control::{ ActionKind, AuthToken, ControlEndpoint, ControlError, ControlResponse, ErrorCode, @@ -186,8 +188,8 @@ fn discovery_record_for_settings( ctx: &ModelContext<LocalControlServer>, control_endpoint: ControlEndpoint, ) -> InstanceRecord { - let outside_warp_control_enabled = crate::settings::LocalControlSettings::as_ref(ctx) - .is_context_enabled(LocalControlInvocationContext::OutsideWarp); + let outside_warp_control_enabled = + crate::settings::LocalControlSettings::as_ref(ctx).outside_warp_control_enabled(); let endpoint = outside_warp_control_enabled.then_some(control_endpoint); InstanceRecord::for_current_process( endpoint, diff --git a/app/src/local_control/mod_tests.rs b/app/src/local_control/mod_tests.rs index 7ba3c4032f..3af5d3877f 100644 --- a/app/src/local_control/mod_tests.rs +++ b/app/src/local_control/mod_tests.rs @@ -13,9 +13,6 @@ use super::{ validate_tab_create_target, }; use crate::settings::{ - AllowInsideWarpAppStateMutations, AllowInsideWarpControl, - AllowInsideWarpMetadataConfigurationMutations, AllowInsideWarpMetadataReads, - AllowInsideWarpUnderlyingDataMutations, AllowInsideWarpUnderlyingDataReads, AllowOutsideWarpAppStateMutations, AllowOutsideWarpControl, AllowOutsideWarpMetadataConfigurationMutations, AllowOutsideWarpMetadataReads, AllowOutsideWarpUnderlyingDataMutations, AllowOutsideWarpUnderlyingDataReads, @@ -23,41 +20,23 @@ use crate::settings::{ }; fn settings_with_values( - inside_enabled: bool, outside_enabled: bool, - inside_metadata_reads: bool, outside_metadata_reads: bool, - inside_app_state_mutations: bool, outside_app_state_mutations: bool, ) -> LocalControlSettings { LocalControlSettings { - allow_inside_warp_control: AllowInsideWarpControl::new(Some(inside_enabled)), allow_outside_warp_control: AllowOutsideWarpControl::new(Some(outside_enabled)), - allow_inside_warp_metadata_reads: AllowInsideWarpMetadataReads::new(Some( - inside_metadata_reads, - )), allow_outside_warp_metadata_reads: AllowOutsideWarpMetadataReads::new(Some( outside_metadata_reads, )), - allow_inside_warp_underlying_data_reads: AllowInsideWarpUnderlyingDataReads::new(Some( - true, - )), allow_outside_warp_underlying_data_reads: AllowOutsideWarpUnderlyingDataReads::new(Some( false, )), - allow_inside_warp_app_state_mutations: AllowInsideWarpAppStateMutations::new(Some( - inside_app_state_mutations, - )), allow_outside_warp_app_state_mutations: AllowOutsideWarpAppStateMutations::new(Some( outside_app_state_mutations, )), - allow_inside_warp_metadata_configuration_mutations: - AllowInsideWarpMetadataConfigurationMutations::new(Some(true)), allow_outside_warp_metadata_configuration_mutations: AllowOutsideWarpMetadataConfigurationMutations::new(Some(false)), - allow_inside_warp_underlying_data_mutations: AllowInsideWarpUnderlyingDataMutations::new( - Some(true), - ), allow_outside_warp_underlying_data_mutations: AllowOutsideWarpUnderlyingDataMutations::new( Some(false), ), @@ -68,14 +47,7 @@ fn settings_with_outside_warp( outside_control: bool, outside_app_state_mutations: bool, ) -> LocalControlSettings { - settings_with_values( - true, - outside_control, - true, - false, - true, - outside_app_state_mutations, - ) + settings_with_values(outside_control, false, outside_app_state_mutations) } #[test] @@ -191,27 +163,40 @@ fn feature_flag_disabled_denies_local_control() { } #[test] -fn disabled_context_denies_before_granular_permission() { - let settings = settings_with_values(false, true, true, true, true, true); +fn disabled_outside_warp_denies_before_granular_permission() { + let settings = settings_with_values(false, true, true); let err = ensure_settings_allow_action( &settings, - InvocationContext::InsideWarp, + InvocationContext::OutsideWarp, ActionKind::TabCreate, ) - .expect_err("inside-Warp parent context is disabled"); + .expect_err("outside-Warp parent context is disabled"); assert_eq!(err.code, ErrorCode::LocalControlDisabled); } #[test] -fn disabled_granular_permission_denies_with_insufficient_permissions() { - let settings = settings_with_values(true, true, true, true, false, true); +fn inside_warp_context_is_not_implemented() { + let settings = settings_with_values(true, true, true); let err = ensure_settings_allow_action( &settings, InvocationContext::InsideWarp, ActionKind::TabCreate, ) + .expect_err("inside-Warp grants are not implemented"); + assert_eq!(err.code, ErrorCode::ExecutionContextNotAllowed); +} + +#[test] +fn disabled_granular_permission_denies_with_insufficient_permissions() { + let settings = settings_with_values(true, true, false); + + let err = ensure_settings_allow_action( + &settings, + InvocationContext::OutsideWarp, + ActionKind::TabCreate, + ) .expect_err("read-write permission is disabled"); assert_eq!(err.code, ErrorCode::InsufficientPermissions); } diff --git a/app/src/local_control/permissions.rs b/app/src/local_control/permissions.rs index 6c7c448675..7586c1bf7a 100644 --- a/app/src/local_control/permissions.rs +++ b/app/src/local_control/permissions.rs @@ -1,8 +1,6 @@ //! Permission checks that map protocol action metadata onto local settings. use crate::features::FeatureFlag; -use crate::settings::{ - LocalControlInvocationContext, LocalControlPermissionCategory, LocalControlSettings, -}; +use crate::settings::{LocalControlPermissionCategory, LocalControlSettings}; use ::local_control::{ActionKind, ControlError, ErrorCode, InvocationContext, PermissionCategory}; use warpui::{ModelContext, SingletonEntity}; @@ -35,9 +33,7 @@ fn outside_warp_permission_enabled_for_settings( settings: &LocalControlSettings, permission: PermissionCategory, ) -> bool { - let context = LocalControlInvocationContext::OutsideWarp; - settings.is_context_enabled(context) - && settings.is_permission_enabled(context, local_permission(permission)) + settings.allows_outside_warp(local_permission(permission)) } #[cfg(test)] @@ -48,13 +44,6 @@ pub(crate) fn capabilities() -> Vec<ActionKind> { .collect() } -fn local_invocation_context(context: InvocationContext) -> LocalControlInvocationContext { - match context { - InvocationContext::InsideWarp => LocalControlInvocationContext::InsideWarp, - InvocationContext::OutsideWarp => LocalControlInvocationContext::OutsideWarp, - } -} - fn local_permission(permission: PermissionCategory) -> LocalControlPermissionCategory { match permission { PermissionCategory::ReadMetadata => LocalControlPermissionCategory::MetadataReads, @@ -85,15 +74,20 @@ pub(crate) fn ensure_settings_allow_action( context: InvocationContext, action: ActionKind, ) -> Result<(), ControlError> { - let context = local_invocation_context(context); - if !settings.is_context_enabled(context) { + if context == InvocationContext::InsideWarp { + return Err(ControlError::new( + ErrorCode::ExecutionContextNotAllowed, + "inside-Warp local-control grants are not implemented", + )); + } + if !settings.outside_warp_control_enabled() { return Err(ControlError::new( ErrorCode::LocalControlDisabled, "local control is disabled for this invocation context", )); } let permission = local_permission(action.metadata().permission_category); - if !settings.is_permission_enabled(context, permission) { + if !settings.outside_warp_permission_enabled(permission) { return Err(ControlError::new( ErrorCode::InsufficientPermissions, format!( diff --git a/app/src/settings/local_control.rs b/app/src/settings/local_control.rs index a38dbd3278..aa08766fa5 100644 --- a/app/src/settings/local_control.rs +++ b/app/src/settings/local_control.rs @@ -1,20 +1,13 @@ -//! Private local settings that gate Warp control by invocation context and risk category. +//! Private local settings that gate outside-Warp control by risk category. //! //! These settings are local-only and kept out of the user-visible settings file, //! but this foundation branch still stores them in the existing private -//! preferences backend. Before outside-Warp control or broader grants ship, -//! the authoritative enablement bits should move to protected storage where -//! available, such as macOS Keychain or the platform equivalent, so external -//! apps cannot enable local control by editing ordinary preferences. +//! preferences backend. Before outside-Warp control ships, the authoritative +//! enablement bits should move to protected storage where available, such as +//! macOS Keychain or the platform equivalent, so external apps cannot enable +//! local control by editing ordinary preferences. use settings::{macros::define_settings_group, SupportedPlatforms, SyncToCloud}; -/// Source context for a local-control request. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum LocalControlInvocationContext { - InsideWarp, - OutsideWarp, -} - /// Coarse permission buckets used to gate groups of control actions. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum LocalControlPermissionCategory { @@ -26,15 +19,6 @@ pub enum LocalControlPermissionCategory { } define_settings_group!(LocalControlSettings, settings: [ - allow_inside_warp_control: AllowInsideWarpControl { - type: bool, - default: true, - supported_platforms: SupportedPlatforms::DESKTOP, - sync_to_cloud: SyncToCloud::Never, - private: true, - storage_key: "LocalControlAllowInsideWarp", - description: "Whether Warp control is allowed from verified Warp-managed terminal sessions.", - }, allow_outside_warp_control: AllowOutsideWarpControl { type: bool, default: false, @@ -44,15 +28,6 @@ define_settings_group!(LocalControlSettings, settings: [ storage_key: "LocalControlAllowOutsideWarp", description: "Whether Warp control is allowed from external local clients.", }, - allow_inside_warp_metadata_reads: AllowInsideWarpMetadataReads { - type: bool, - default: true, - supported_platforms: SupportedPlatforms::DESKTOP, - sync_to_cloud: SyncToCloud::Never, - private: true, - storage_key: "LocalControlInsideWarpMetadataReads", - description: "Whether verified Warp-managed terminal sessions may receive metadata-read local control grants.", - }, allow_outside_warp_metadata_reads: AllowOutsideWarpMetadataReads { type: bool, default: false, @@ -62,15 +37,6 @@ define_settings_group!(LocalControlSettings, settings: [ storage_key: "LocalControlOutsideWarpMetadataReads", description: "Whether external local clients may receive metadata-read local control grants.", }, - allow_inside_warp_underlying_data_reads: AllowInsideWarpUnderlyingDataReads { - type: bool, - default: true, - supported_platforms: SupportedPlatforms::DESKTOP, - sync_to_cloud: SyncToCloud::Never, - private: true, - storage_key: "LocalControlInsideWarpUnderlyingDataReads", - description: "Whether verified Warp-managed terminal sessions may receive underlying-data-read local control grants.", - }, allow_outside_warp_underlying_data_reads: AllowOutsideWarpUnderlyingDataReads { type: bool, default: false, @@ -80,15 +46,6 @@ define_settings_group!(LocalControlSettings, settings: [ storage_key: "LocalControlOutsideWarpUnderlyingDataReads", description: "Whether external local clients may receive underlying-data-read local control grants.", }, - allow_inside_warp_app_state_mutations: AllowInsideWarpAppStateMutations { - type: bool, - default: true, - supported_platforms: SupportedPlatforms::DESKTOP, - sync_to_cloud: SyncToCloud::Never, - private: true, - storage_key: "LocalControlInsideWarpAppStateMutations", - description: "Whether verified Warp-managed terminal sessions may receive app-state-mutation local control grants.", - }, allow_outside_warp_app_state_mutations: AllowOutsideWarpAppStateMutations { type: bool, default: false, @@ -98,15 +55,6 @@ define_settings_group!(LocalControlSettings, settings: [ storage_key: "LocalControlOutsideWarpAppStateMutations", description: "Whether external local clients may receive app-state-mutation local control grants.", }, - allow_inside_warp_metadata_configuration_mutations: AllowInsideWarpMetadataConfigurationMutations { - type: bool, - default: true, - supported_platforms: SupportedPlatforms::DESKTOP, - sync_to_cloud: SyncToCloud::Never, - private: true, - storage_key: "LocalControlInsideWarpMetadataConfigurationMutations", - description: "Whether verified Warp-managed terminal sessions may receive metadata/configuration-mutation local control grants.", - }, allow_outside_warp_metadata_configuration_mutations: AllowOutsideWarpMetadataConfigurationMutations { type: bool, default: false, @@ -116,15 +64,6 @@ define_settings_group!(LocalControlSettings, settings: [ storage_key: "LocalControlOutsideWarpMetadataConfigurationMutations", description: "Whether external local clients may receive metadata/configuration-mutation local control grants.", }, - allow_inside_warp_underlying_data_mutations: AllowInsideWarpUnderlyingDataMutations { - type: bool, - default: true, - supported_platforms: SupportedPlatforms::DESKTOP, - sync_to_cloud: SyncToCloud::Never, - private: true, - storage_key: "LocalControlInsideWarpUnderlyingDataMutations", - description: "Whether verified Warp-managed terminal sessions may receive underlying-data-mutation local control grants.", - }, allow_outside_warp_underlying_data_mutations: AllowOutsideWarpUnderlyingDataMutations { type: bool, default: false, @@ -137,68 +76,35 @@ define_settings_group!(LocalControlSettings, settings: [ ]); impl LocalControlSettings { - pub fn is_context_enabled(&self, context: LocalControlInvocationContext) -> bool { - match context { - LocalControlInvocationContext::InsideWarp => *self.allow_inside_warp_control, - LocalControlInvocationContext::OutsideWarp => *self.allow_outside_warp_control, - } + pub fn outside_warp_control_enabled(&self) -> bool { + *self.allow_outside_warp_control } - pub fn is_permission_enabled( + pub fn outside_warp_permission_enabled( &self, - context: LocalControlInvocationContext, permission: LocalControlPermissionCategory, ) -> bool { - match (context, permission) { - ( - LocalControlInvocationContext::InsideWarp, - LocalControlPermissionCategory::MetadataReads, - ) => *self.allow_inside_warp_metadata_reads, - ( - LocalControlInvocationContext::OutsideWarp, - LocalControlPermissionCategory::MetadataReads, - ) => *self.allow_outside_warp_metadata_reads, - ( - LocalControlInvocationContext::InsideWarp, - LocalControlPermissionCategory::UnderlyingDataReads, - ) => *self.allow_inside_warp_underlying_data_reads, - ( - LocalControlInvocationContext::OutsideWarp, - LocalControlPermissionCategory::UnderlyingDataReads, - ) => *self.allow_outside_warp_underlying_data_reads, - ( - LocalControlInvocationContext::InsideWarp, - LocalControlPermissionCategory::AppStateMutations, - ) => *self.allow_inside_warp_app_state_mutations, - ( - LocalControlInvocationContext::OutsideWarp, - LocalControlPermissionCategory::AppStateMutations, - ) => *self.allow_outside_warp_app_state_mutations, - ( - LocalControlInvocationContext::InsideWarp, - LocalControlPermissionCategory::MetadataConfigurationMutations, - ) => *self.allow_inside_warp_metadata_configuration_mutations, - ( - LocalControlInvocationContext::OutsideWarp, - LocalControlPermissionCategory::MetadataConfigurationMutations, - ) => *self.allow_outside_warp_metadata_configuration_mutations, - ( - LocalControlInvocationContext::InsideWarp, - LocalControlPermissionCategory::UnderlyingDataMutations, - ) => *self.allow_inside_warp_underlying_data_mutations, - ( - LocalControlInvocationContext::OutsideWarp, - LocalControlPermissionCategory::UnderlyingDataMutations, - ) => *self.allow_outside_warp_underlying_data_mutations, + match permission { + LocalControlPermissionCategory::MetadataReads => { + *self.allow_outside_warp_metadata_reads + } + LocalControlPermissionCategory::UnderlyingDataReads => { + *self.allow_outside_warp_underlying_data_reads + } + LocalControlPermissionCategory::AppStateMutations => { + *self.allow_outside_warp_app_state_mutations + } + LocalControlPermissionCategory::MetadataConfigurationMutations => { + *self.allow_outside_warp_metadata_configuration_mutations + } + LocalControlPermissionCategory::UnderlyingDataMutations => { + *self.allow_outside_warp_underlying_data_mutations + } } } - pub fn allows( - &self, - context: LocalControlInvocationContext, - permission: LocalControlPermissionCategory, - ) -> bool { - self.is_context_enabled(context) && self.is_permission_enabled(context, permission) + pub fn allows_outside_warp(&self, permission: LocalControlPermissionCategory) -> bool { + self.outside_warp_control_enabled() && self.outside_warp_permission_enabled(permission) } } diff --git a/app/src/settings/local_control_tests.rs b/app/src/settings/local_control_tests.rs index 9393daa9b3..b2b6f306e4 100644 --- a/app/src/settings/local_control_tests.rs +++ b/app/src/settings/local_control_tests.rs @@ -1,36 +1,22 @@ use settings::{Setting, SyncToCloud}; use super::{ - AllowInsideWarpAppStateMutations, AllowInsideWarpControl, - AllowInsideWarpMetadataConfigurationMutations, AllowInsideWarpMetadataReads, - AllowInsideWarpUnderlyingDataMutations, AllowInsideWarpUnderlyingDataReads, AllowOutsideWarpAppStateMutations, AllowOutsideWarpControl, AllowOutsideWarpMetadataConfigurationMutations, AllowOutsideWarpMetadataReads, AllowOutsideWarpUnderlyingDataMutations, AllowOutsideWarpUnderlyingDataReads, - LocalControlInvocationContext, LocalControlPermissionCategory, LocalControlSettings, + LocalControlPermissionCategory, LocalControlSettings, }; -fn settings_with_values(inside_enabled: bool, outside_enabled: bool) -> LocalControlSettings { +fn settings_with_values(outside_enabled: bool) -> LocalControlSettings { LocalControlSettings { - allow_inside_warp_control: AllowInsideWarpControl::new(Some(inside_enabled)), allow_outside_warp_control: AllowOutsideWarpControl::new(Some(outside_enabled)), - allow_inside_warp_metadata_reads: AllowInsideWarpMetadataReads::new(Some(true)), allow_outside_warp_metadata_reads: AllowOutsideWarpMetadataReads::new(Some(false)), - allow_inside_warp_underlying_data_reads: AllowInsideWarpUnderlyingDataReads::new(Some( - true, - )), allow_outside_warp_underlying_data_reads: AllowOutsideWarpUnderlyingDataReads::new(Some( false, )), - allow_inside_warp_app_state_mutations: AllowInsideWarpAppStateMutations::new(Some(true)), allow_outside_warp_app_state_mutations: AllowOutsideWarpAppStateMutations::new(Some(false)), - allow_inside_warp_metadata_configuration_mutations: - AllowInsideWarpMetadataConfigurationMutations::new(Some(true)), allow_outside_warp_metadata_configuration_mutations: AllowOutsideWarpMetadataConfigurationMutations::new(Some(false)), - allow_inside_warp_underlying_data_mutations: AllowInsideWarpUnderlyingDataMutations::new( - Some(true), - ), allow_outside_warp_underlying_data_mutations: AllowOutsideWarpUnderlyingDataMutations::new( Some(false), ), @@ -38,8 +24,8 @@ fn settings_with_values(inside_enabled: bool, outside_enabled: bool) -> LocalCon } #[test] -fn defaults_allow_inside_warp_permissions_only() { - let settings = settings_with_values(true, false); +fn defaults_disable_outside_warp_permissions() { + let settings = settings_with_values(false); for permission in [ LocalControlPermissionCategory::MetadataReads, @@ -48,96 +34,67 @@ fn defaults_allow_inside_warp_permissions_only() { LocalControlPermissionCategory::MetadataConfigurationMutations, LocalControlPermissionCategory::UnderlyingDataMutations, ] { - assert!(settings.allows(LocalControlInvocationContext::InsideWarp, permission)); - assert!(!settings.allows(LocalControlInvocationContext::OutsideWarp, permission)); + assert!(!settings.allows_outside_warp(permission)); } } #[test] fn generated_settings_are_private_local_only_with_expected_defaults() { - assert!(*AllowInsideWarpControl::new(None)); assert!(!*AllowOutsideWarpControl::new(None)); - assert!(*AllowInsideWarpMetadataReads::new(None)); assert!(!*AllowOutsideWarpMetadataReads::new(None)); - assert!(*AllowInsideWarpUnderlyingDataReads::new(None)); assert!(!*AllowOutsideWarpUnderlyingDataReads::new(None)); - assert!(*AllowInsideWarpAppStateMutations::new(None)); assert!(!*AllowOutsideWarpAppStateMutations::new(None)); - assert!(*AllowInsideWarpMetadataConfigurationMutations::new(None)); assert!(!*AllowOutsideWarpMetadataConfigurationMutations::new(None)); - assert!(*AllowInsideWarpUnderlyingDataMutations::new(None)); assert!(!*AllowOutsideWarpUnderlyingDataMutations::new(None)); - assert_eq!(AllowInsideWarpControl::sync_to_cloud(), SyncToCloud::Never); assert_eq!(AllowOutsideWarpControl::sync_to_cloud(), SyncToCloud::Never); - assert_eq!( - AllowInsideWarpMetadataReads::sync_to_cloud(), - SyncToCloud::Never - ); assert_eq!( AllowOutsideWarpUnderlyingDataMutations::sync_to_cloud(), SyncToCloud::Never ); - assert!(AllowInsideWarpControl::is_private()); assert!(AllowOutsideWarpControl::is_private()); - assert!(AllowInsideWarpMetadataReads::is_private()); + assert!(AllowOutsideWarpMetadataReads::is_private()); assert!(AllowOutsideWarpUnderlyingDataMutations::is_private()); } #[test] fn disabled_context_blocks_enabled_granular_permissions() { - let settings = settings_with_values(false, false); + let settings = LocalControlSettings { + allow_outside_warp_control: AllowOutsideWarpControl::new(Some(false)), + allow_outside_warp_metadata_reads: AllowOutsideWarpMetadataReads::new(Some(true)), + allow_outside_warp_underlying_data_reads: AllowOutsideWarpUnderlyingDataReads::new(Some( + true, + )), + allow_outside_warp_app_state_mutations: AllowOutsideWarpAppStateMutations::new(Some(true)), + allow_outside_warp_metadata_configuration_mutations: + AllowOutsideWarpMetadataConfigurationMutations::new(Some(true)), + allow_outside_warp_underlying_data_mutations: AllowOutsideWarpUnderlyingDataMutations::new( + Some(true), + ), + }; - assert!(!settings.allows( - LocalControlInvocationContext::InsideWarp, - LocalControlPermissionCategory::AppStateMutations - )); - assert!(!settings.allows( - LocalControlInvocationContext::OutsideWarp, - LocalControlPermissionCategory::MetadataReads - )); + assert!(!settings.allows_outside_warp(LocalControlPermissionCategory::AppStateMutations)); + assert!(!settings.allows_outside_warp(LocalControlPermissionCategory::MetadataReads)); } #[test] fn granular_permissions_are_independent() { let settings = LocalControlSettings { - allow_inside_warp_control: AllowInsideWarpControl::new(Some(true)), allow_outside_warp_control: AllowOutsideWarpControl::new(Some(true)), - allow_inside_warp_metadata_reads: AllowInsideWarpMetadataReads::new(Some(true)), allow_outside_warp_metadata_reads: AllowOutsideWarpMetadataReads::new(Some(true)), - allow_inside_warp_underlying_data_reads: AllowInsideWarpUnderlyingDataReads::new(Some( - false, - )), allow_outside_warp_underlying_data_reads: AllowOutsideWarpUnderlyingDataReads::new(Some( false, )), - allow_inside_warp_app_state_mutations: AllowInsideWarpAppStateMutations::new(Some(true)), allow_outside_warp_app_state_mutations: AllowOutsideWarpAppStateMutations::new(Some(true)), - allow_inside_warp_metadata_configuration_mutations: - AllowInsideWarpMetadataConfigurationMutations::new(Some(false)), allow_outside_warp_metadata_configuration_mutations: AllowOutsideWarpMetadataConfigurationMutations::new(Some(false)), - allow_inside_warp_underlying_data_mutations: AllowInsideWarpUnderlyingDataMutations::new( - Some(false), - ), allow_outside_warp_underlying_data_mutations: AllowOutsideWarpUnderlyingDataMutations::new( Some(false), ), }; - assert!(settings.allows( - LocalControlInvocationContext::OutsideWarp, - LocalControlPermissionCategory::MetadataReads - )); - assert!(!settings.allows( - LocalControlInvocationContext::OutsideWarp, - LocalControlPermissionCategory::UnderlyingDataReads - )); - assert!(settings.allows( - LocalControlInvocationContext::OutsideWarp, - LocalControlPermissionCategory::AppStateMutations - )); - assert!(!settings.allows( - LocalControlInvocationContext::OutsideWarp, - LocalControlPermissionCategory::MetadataConfigurationMutations - )); + assert!(settings.allows_outside_warp(LocalControlPermissionCategory::MetadataReads)); + assert!(!settings.allows_outside_warp(LocalControlPermissionCategory::UnderlyingDataReads)); + assert!(settings.allows_outside_warp(LocalControlPermissionCategory::AppStateMutations)); + assert!(!settings + .allows_outside_warp(LocalControlPermissionCategory::MetadataConfigurationMutations)); } diff --git a/app/src/settings_view/scripting_page.rs b/app/src/settings_view/scripting_page.rs index 216d2e2973..e118431a84 100644 --- a/app/src/settings_view/scripting_page.rs +++ b/app/src/settings_view/scripting_page.rs @@ -10,13 +10,10 @@ use crate::appearance::Appearance; use crate::features::FeatureFlag; use crate::report_if_error; use crate::settings::{ - AllowInsideWarpAppStateMutations, AllowInsideWarpControl, - AllowInsideWarpMetadataConfigurationMutations, AllowInsideWarpMetadataReads, - AllowInsideWarpUnderlyingDataMutations, AllowInsideWarpUnderlyingDataReads, AllowOutsideWarpAppStateMutations, AllowOutsideWarpControl, AllowOutsideWarpMetadataConfigurationMutations, AllowOutsideWarpMetadataReads, AllowOutsideWarpUnderlyingDataMutations, AllowOutsideWarpUnderlyingDataReads, - LocalControlInvocationContext, LocalControlSettings, + LocalControlSettings, }; use settings::{Setting as _, ToggleableSetting as _}; use std::cell::RefCell; @@ -27,15 +24,9 @@ use warpui::ui_components::components::UiComponent; use warpui::ui_components::switch::SwitchStateHandle; use warpui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle}; -/// Toggle rows shown on the Settings > Scripting page for local-control gates. +/// Toggle rows shown on the Settings > Scripting page for outside-Warp local-control gates. #[derive(Clone, Copy, Debug)] pub enum ScriptingToggle { - InsideWarpControl, - InsideWarpMetadataReads, - InsideWarpUnderlyingDataReads, - InsideWarpAppStateMutations, - InsideWarpMetadataConfigurationMutations, - InsideWarpUnderlyingDataMutations, OutsideWarpControl, OutsideWarpMetadataReads, OutsideWarpUnderlyingDataReads, @@ -47,62 +38,34 @@ pub enum ScriptingToggle { impl ScriptingToggle { fn label(self) -> &'static str { match self { - Self::InsideWarpControl => "Warp control within Warp", Self::OutsideWarpControl => "Warp control outside Warp", - Self::InsideWarpMetadataReads | Self::OutsideWarpMetadataReads => { - "Allow metadata reads" - } - Self::InsideWarpUnderlyingDataReads | Self::OutsideWarpUnderlyingDataReads => { - "Allow underlying data reads" - } - Self::InsideWarpAppStateMutations | Self::OutsideWarpAppStateMutations => { - "Allow app-state mutations" - } - Self::InsideWarpMetadataConfigurationMutations - | Self::OutsideWarpMetadataConfigurationMutations => { + Self::OutsideWarpMetadataReads => "Allow metadata reads", + Self::OutsideWarpUnderlyingDataReads => "Allow underlying data reads", + Self::OutsideWarpAppStateMutations => "Allow app-state mutations", + Self::OutsideWarpMetadataConfigurationMutations => { "Allow metadata/configuration mutations" } - Self::InsideWarpUnderlyingDataMutations | Self::OutsideWarpUnderlyingDataMutations => { - "Allow underlying data mutations" - } + Self::OutsideWarpUnderlyingDataMutations => "Allow underlying data mutations", } } fn description(self) -> &'static str { match self { - Self::InsideWarpControl => { - "Allows control commands launched from verified Warp-managed terminal sessions." - } Self::OutsideWarpControl => { "Allows other local apps, terminals, IDEs, launch agents, and scripts to request Warp control." } - Self::InsideWarpMetadataReads => { - "Allows commands inside Warp to query app metadata such as instances, windows, tabs, panes, and protocol version." - } Self::OutsideWarpMetadataReads => { "Allows external local clients to query app metadata after outside-Warp control is enabled." } - Self::InsideWarpUnderlyingDataReads => { - "Allows commands inside Warp to read underlying user data such as terminal output, input buffers, or history when those commands are implemented." - } Self::OutsideWarpUnderlyingDataReads => { "Allows external local clients to read underlying user data when those commands are implemented." } - Self::InsideWarpAppStateMutations => { - "Allows commands inside Warp to mutate Warp app state, such as creating a tab." - } Self::OutsideWarpAppStateMutations => { "Allows external local clients to mutate Warp app state after outside-Warp control is enabled." } - Self::InsideWarpMetadataConfigurationMutations => { - "Allows commands inside Warp to change metadata and configuration such as labels, themes, and allowlisted settings when those commands are implemented." - } Self::OutsideWarpMetadataConfigurationMutations => { "Allows external local clients to change metadata and configuration when those commands are implemented." } - Self::InsideWarpUnderlyingDataMutations => { - "Allows commands inside Warp to mutate underlying user data when those commands are implemented." - } Self::OutsideWarpUnderlyingDataMutations => { "Allows external local clients to mutate underlying user data when those commands are implemented." } @@ -111,37 +74,21 @@ impl ScriptingToggle { fn search_terms(self) -> &'static str { match self { - Self::InsideWarpControl => "inside warp control terminal scripting automation", Self::OutsideWarpControl => { "outside warp control external scripts automation local cli" } - Self::InsideWarpMetadataReads => { - "inside warp metadata read query windows tabs panes instances" - } Self::OutsideWarpMetadataReads => { "outside warp metadata read query windows tabs panes instances" } - Self::InsideWarpUnderlyingDataReads => { - "inside warp underlying data read terminal output input history blocks" - } Self::OutsideWarpUnderlyingDataReads => { "outside warp underlying data read terminal output input history blocks" } - Self::InsideWarpAppStateMutations => { - "inside warp app state mutate change tab create window pane" - } Self::OutsideWarpAppStateMutations => { "outside warp app state mutate change tab create window pane" } - Self::InsideWarpMetadataConfigurationMutations => { - "inside warp metadata configuration mutate settings theme labels" - } Self::OutsideWarpMetadataConfigurationMutations => { "outside warp metadata configuration mutate settings theme labels" } - Self::InsideWarpUnderlyingDataMutations => { - "inside warp underlying data mutate input files drive" - } Self::OutsideWarpUnderlyingDataMutations => { "outside warp underlying data mutate input files drive" } @@ -150,27 +97,15 @@ impl ScriptingToggle { fn value(self, settings: &LocalControlSettings) -> bool { match self { - Self::InsideWarpControl => *settings.allow_inside_warp_control, Self::OutsideWarpControl => *settings.allow_outside_warp_control, - Self::InsideWarpMetadataReads => *settings.allow_inside_warp_metadata_reads, Self::OutsideWarpMetadataReads => *settings.allow_outside_warp_metadata_reads, - Self::InsideWarpUnderlyingDataReads => { - *settings.allow_inside_warp_underlying_data_reads - } Self::OutsideWarpUnderlyingDataReads => { *settings.allow_outside_warp_underlying_data_reads } - Self::InsideWarpAppStateMutations => *settings.allow_inside_warp_app_state_mutations, Self::OutsideWarpAppStateMutations => *settings.allow_outside_warp_app_state_mutations, - Self::InsideWarpMetadataConfigurationMutations => { - *settings.allow_inside_warp_metadata_configuration_mutations - } Self::OutsideWarpMetadataConfigurationMutations => { *settings.allow_outside_warp_metadata_configuration_mutations } - Self::InsideWarpUnderlyingDataMutations => { - *settings.allow_inside_warp_underlying_data_mutations - } Self::OutsideWarpUnderlyingDataMutations => { *settings.allow_outside_warp_underlying_data_mutations } @@ -179,27 +114,15 @@ impl ScriptingToggle { fn storage_key(self) -> &'static str { match self { - Self::InsideWarpControl => AllowInsideWarpControl::storage_key(), Self::OutsideWarpControl => AllowOutsideWarpControl::storage_key(), - Self::InsideWarpMetadataReads => AllowInsideWarpMetadataReads::storage_key(), Self::OutsideWarpMetadataReads => AllowOutsideWarpMetadataReads::storage_key(), - Self::InsideWarpUnderlyingDataReads => { - AllowInsideWarpUnderlyingDataReads::storage_key() - } Self::OutsideWarpUnderlyingDataReads => { AllowOutsideWarpUnderlyingDataReads::storage_key() } - Self::InsideWarpAppStateMutations => AllowInsideWarpAppStateMutations::storage_key(), Self::OutsideWarpAppStateMutations => AllowOutsideWarpAppStateMutations::storage_key(), - Self::InsideWarpMetadataConfigurationMutations => { - AllowInsideWarpMetadataConfigurationMutations::storage_key() - } Self::OutsideWarpMetadataConfigurationMutations => { AllowOutsideWarpMetadataConfigurationMutations::storage_key() } - Self::InsideWarpUnderlyingDataMutations => { - AllowInsideWarpUnderlyingDataMutations::storage_key() - } Self::OutsideWarpUnderlyingDataMutations => { AllowOutsideWarpUnderlyingDataMutations::storage_key() } @@ -208,55 +131,35 @@ impl ScriptingToggle { fn sync_to_cloud(self) -> SyncToCloud { match self { - Self::InsideWarpControl => AllowInsideWarpControl::sync_to_cloud(), Self::OutsideWarpControl => AllowOutsideWarpControl::sync_to_cloud(), - Self::InsideWarpMetadataReads => AllowInsideWarpMetadataReads::sync_to_cloud(), Self::OutsideWarpMetadataReads => AllowOutsideWarpMetadataReads::sync_to_cloud(), - Self::InsideWarpUnderlyingDataReads => { - AllowInsideWarpUnderlyingDataReads::sync_to_cloud() - } Self::OutsideWarpUnderlyingDataReads => { AllowOutsideWarpUnderlyingDataReads::sync_to_cloud() } - Self::InsideWarpAppStateMutations => AllowInsideWarpAppStateMutations::sync_to_cloud(), Self::OutsideWarpAppStateMutations => { AllowOutsideWarpAppStateMutations::sync_to_cloud() } - Self::InsideWarpMetadataConfigurationMutations => { - AllowInsideWarpMetadataConfigurationMutations::sync_to_cloud() - } Self::OutsideWarpMetadataConfigurationMutations => { AllowOutsideWarpMetadataConfigurationMutations::sync_to_cloud() } - Self::InsideWarpUnderlyingDataMutations => { - AllowInsideWarpUnderlyingDataMutations::sync_to_cloud() - } Self::OutsideWarpUnderlyingDataMutations => { AllowOutsideWarpUnderlyingDataMutations::sync_to_cloud() } } } - fn parent_context(self) -> Option<LocalControlInvocationContext> { + fn requires_outside_control(self) -> bool { match self { - Self::InsideWarpMetadataReads - | Self::InsideWarpUnderlyingDataReads - | Self::InsideWarpAppStateMutations - | Self::InsideWarpMetadataConfigurationMutations - | Self::InsideWarpUnderlyingDataMutations => { - Some(LocalControlInvocationContext::InsideWarp) - } + Self::OutsideWarpControl => false, Self::OutsideWarpMetadataReads | Self::OutsideWarpUnderlyingDataReads | Self::OutsideWarpAppStateMutations | Self::OutsideWarpMetadataConfigurationMutations - | Self::OutsideWarpUnderlyingDataMutations => { - Some(LocalControlInvocationContext::OutsideWarp) - } - Self::InsideWarpControl | Self::OutsideWarpControl => None, + | Self::OutsideWarpUnderlyingDataMutations => true, } } } + #[derive(Clone, Debug)] pub enum ScriptingSettingsPageAction { Toggle(ScriptingToggle), @@ -279,24 +182,6 @@ impl ScriptingSettingsPageView { page: PageType::new_uncategorized( vec![ Box::new(ScriptingIntroWidget), - Box::new(ScriptingToggleWidget::new( - ScriptingToggle::InsideWarpControl, - )), - Box::new(ScriptingToggleWidget::new( - ScriptingToggle::InsideWarpMetadataReads, - )), - Box::new(ScriptingToggleWidget::new( - ScriptingToggle::InsideWarpUnderlyingDataReads, - )), - Box::new(ScriptingToggleWidget::new( - ScriptingToggle::InsideWarpAppStateMutations, - )), - Box::new(ScriptingToggleWidget::new( - ScriptingToggle::InsideWarpMetadataConfigurationMutations, - )), - Box::new(ScriptingToggleWidget::new( - ScriptingToggle::InsideWarpUnderlyingDataMutations, - )), Box::new(ScriptingToggleWidget::new( ScriptingToggle::OutsideWarpControl, )), @@ -334,61 +219,31 @@ impl TypedActionView for ScriptingSettingsPageView { match action { ScriptingSettingsPageAction::Toggle(toggle) => { LocalControlSettings::handle(ctx).update(ctx, |settings, ctx| match toggle { - ScriptingToggle::InsideWarpControl => { - report_if_error!(settings - .allow_inside_warp_control - .toggle_and_save_value(ctx)); - } ScriptingToggle::OutsideWarpControl => { report_if_error!(settings .allow_outside_warp_control .toggle_and_save_value(ctx)); } - ScriptingToggle::InsideWarpMetadataReads => { - report_if_error!(settings - .allow_inside_warp_metadata_reads - .toggle_and_save_value(ctx)); - } ScriptingToggle::OutsideWarpMetadataReads => { report_if_error!(settings .allow_outside_warp_metadata_reads .toggle_and_save_value(ctx)); } - ScriptingToggle::InsideWarpUnderlyingDataReads => { - report_if_error!(settings - .allow_inside_warp_underlying_data_reads - .toggle_and_save_value(ctx)); - } ScriptingToggle::OutsideWarpUnderlyingDataReads => { report_if_error!(settings .allow_outside_warp_underlying_data_reads .toggle_and_save_value(ctx)); } - ScriptingToggle::InsideWarpAppStateMutations => { - report_if_error!(settings - .allow_inside_warp_app_state_mutations - .toggle_and_save_value(ctx)); - } ScriptingToggle::OutsideWarpAppStateMutations => { report_if_error!(settings .allow_outside_warp_app_state_mutations .toggle_and_save_value(ctx)); } - ScriptingToggle::InsideWarpMetadataConfigurationMutations => { - report_if_error!(settings - .allow_inside_warp_metadata_configuration_mutations - .toggle_and_save_value(ctx)); - } ScriptingToggle::OutsideWarpMetadataConfigurationMutations => { report_if_error!(settings .allow_outside_warp_metadata_configuration_mutations .toggle_and_save_value(ctx)); } - ScriptingToggle::InsideWarpUnderlyingDataMutations => { - report_if_error!(settings - .allow_inside_warp_underlying_data_mutations - .toggle_and_save_value(ctx)); - } ScriptingToggle::OutsideWarpUnderlyingDataMutations => { report_if_error!(settings .allow_outside_warp_underlying_data_mutations @@ -445,7 +300,7 @@ impl SettingsWidget for ScriptingIntroWidget { type View = ScriptingSettingsPageView; fn search_terms(&self) -> &str { - "scripting warp control automation warpctrl local cli inside outside read only read write" + "scripting warp control automation warpctrl local cli outside read only read write" } fn render( @@ -456,7 +311,7 @@ impl SettingsWidget for ScriptingIntroWidget { ) -> Box<dyn Element> { render_settings_info_banner( "Warp control lets local scripts automate allowlisted actions in a running Warp app.", - Some("Enable Warp control within Warp for commands launched from Warp-managed terminals, or outside Warp for other local apps and scripts. Each scope has separate grants for metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, and underlying data mutations."), + Some("This foundation branch supports outside-Warp local clients only. Verified Warp-managed terminal invocations are planned for a later implementation and are currently rejected by the credential broker."), appearance, ) } @@ -485,10 +340,7 @@ impl SettingsWidget for ScriptingToggleWidget { fn should_render(&self, app: &AppContext) -> bool { let settings = LocalControlSettings::as_ref(app); - match self.toggle.parent_context() { - Some(context) => settings.is_context_enabled(context), - None => true, - } + !self.toggle.requires_outside_control() || settings.outside_warp_control_enabled() } fn render( @@ -523,7 +375,7 @@ impl SettingsWidget for ScriptingToggleWidget { .finish(), Some(self.toggle.description().to_owned()), ); - if self.toggle.parent_context().is_some() { + if self.toggle.requires_outside_control() { Container::new(item).with_margin_left(16.).finish() } else { item diff --git a/crates/local_control/src/auth_tests.rs b/crates/local_control/src/auth_tests.rs index c29f44e64e..e0e524c21b 100644 --- a/crates/local_control/src/auth_tests.rs +++ b/crates/local_control/src/auth_tests.rs @@ -60,7 +60,7 @@ fn scoped_credential_carries_permission_and_authenticated_user_metadata() { let grant = CredentialGrant::new( InstanceId("inst_test".to_owned()), ActionKind::TabCreate, - InvocationContext::InsideWarp, + InvocationContext::OutsideWarp, Duration::minutes(5), ); assert_eq!(grant.risk_tier, RiskTier::MutatingNonDestructive); @@ -81,7 +81,7 @@ fn mismatched_permission_metadata_is_rejected() { let mut grant = CredentialGrant::new( InstanceId("inst_test".to_owned()), ActionKind::TabCreate, - InvocationContext::InsideWarp, + InvocationContext::OutsideWarp, Duration::minutes(5), ); grant.permission_category = PermissionCategory::ReadMetadata; @@ -100,6 +100,18 @@ fn credential_request_rejects_unverified_inside_warp_context() { assert_eq!(err.code, ErrorCode::ExecutionContextNotAllowed); } +#[test] +fn credential_request_rejects_placeholder_inside_warp_terminal_proof() { + let mut request = CredentialRequest::new(ActionKind::TabCreate, InvocationContext::InsideWarp); + request.execution_context_proof = Some(ExecutionContextProof::VerifiedWarpTerminal { + proof_id: "proof".to_owned(), + }); + let err = request + .verify_execution_context_proof() + .expect_err("placeholder proof is rejected until broker support exists"); + assert_eq!(err.code, ErrorCode::ExecutionContextNotAllowed); +} + #[test] fn credential_request_rejects_terminal_proof_for_external_client() { let mut request = CredentialRequest::new(ActionKind::TabCreate, InvocationContext::OutsideWarp); diff --git a/crates/local_control/src/catalog.rs b/crates/local_control/src/catalog.rs index 6ddd129662..6f92fca348 100644 --- a/crates/local_control/src/catalog.rs +++ b/crates/local_control/src/catalog.rs @@ -11,7 +11,11 @@ pub enum InvocationContext { OutsideWarp, } -/// Proof that lets Warp distinguish trusted in-app terminals from external clients. +/// Future proof shape for distinguishing verified Warp terminals from external clients. +/// +/// `VerifiedWarpTerminal` is currently a protocol placeholder only. The +/// foundation implementation rejects inside-Warp credential requests until the +/// app-issued terminal-session proof broker is implemented. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ExecutionContextProof { @@ -309,10 +313,7 @@ impl ActionKind { Self::InstanceList | Self::AppPing | Self::AppVersion | Self::TabCreate => ( ActionImplementationStatus::Implemented, false, - vec![ - InvocationContext::InsideWarp, - InvocationContext::OutsideWarp, - ], + vec![InvocationContext::OutsideWarp], ), _ => (ActionImplementationStatus::Stub, true, Vec::new()), }; diff --git a/crates/local_control/src/protocol_tests.rs b/crates/local_control/src/protocol_tests.rs index 0b75299fd3..af03f60868 100644 --- a/crates/local_control/src/protocol_tests.rs +++ b/crates/local_control/src/protocol_tests.rs @@ -59,10 +59,7 @@ fn tab_create_metadata_is_first_slice_logged_out_safe_mutation() { ); assert_eq!( metadata.allowed_invocation_contexts, - vec![ - InvocationContext::InsideWarp, - InvocationContext::OutsideWarp - ] + vec![InvocationContext::OutsideWarp] ); } diff --git a/specs/warp-control-cli/PRODUCT.md b/specs/warp-control-cli/PRODUCT.md index b2989c9577..27eaa219d5 100644 --- a/specs/warp-control-cli/PRODUCT.md +++ b/specs/warp-control-cli/PRODUCT.md @@ -177,6 +177,7 @@ Non-goals: - Focusing a window that has closed. - Setting a theme that is not available in that instance. 33. The first `warpctrl` implementation slice should ship the smallest end-to-end vertical slice that proves: + - The current implementation supports outside-Warp local-control requests only; verified inside-Warp requests are specified for future work and rejected until the app-issued terminal proof broker exists. - Process discovery and target resolution work. - A standalone CLI binary can reach a running local Warp process without launching or initializing the GUI app. - `warpctrl tab create` creates a new terminal tab in the selected running instance. @@ -346,7 +347,7 @@ These are underlying-data mutations because they can execute code, trigger exter The command surface must continue to exclude debug-only, crash-only, auth-token, heap-dump, and arbitrary internal dispatch actions even when those actions are available in command palette or keybinding registries. Examples that remain excluded are app crash/panic helpers, access-token copy helpers, heap profile dumps, debug reset actions, raw view-tree debugging, and broad internal action-by-string execution. ## Branch stacking and delivery model The Warp Control CLI work should ship as a raw-git branch stack so the combined specs/foundation slice, read-only expansion, and mutating expansion remain reviewable independently: -- `zach/warp-cli-core-foundation` is the bottom review branch and targets `master`. It owns `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs alongside the first implementation slice: shared protocol, discovery/auth scaffolding, Settings > Scripting, local-control bridge/server, standalone `warpctrl` binary, packaging hooks, and the smallest safe end-to-end action. +- `zach/warp-cli-core-foundation` is the bottom review branch and targets `master`. It owns `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs alongside the first implementation slice: shared protocol, discovery/auth scaffolding, outside-Warp Settings > Scripting gates, local-control bridge/server, standalone `warpctrl` binary, packaging hooks, and the smallest safe end-to-end action. Verified inside-Warp invocation is documented for future implementation but is not supported by this branch. - `zach/warp-cli-readonly-metadata` stacks on `zach/warp-cli-core-foundation` and implements structural metadata reads, including instance/app health, active-chain, windows, tabs, panes, sessions, and action metadata. - `zach/warp-cli-readonly-data-settings` stacks on `zach/warp-cli-readonly-metadata` and fills in underlying-data reads plus read-only settings/appearance/docs, including terminal block output, input-buffer reads, history reads, and allowlisted settings metadata. - `zach/warp-cli-mutating-layout` stacks on `zach/warp-cli-readonly-data-settings` and implements app/window/tab/pane layout mutations. @@ -418,17 +419,17 @@ The CLI should expose a small auth/status flow for actions that require a logged - Raw Firebase, server, OAuth, or cloud API tokens are never exported to `warpctrl`, shell scripts, generated docs, logs, or JSON output. This login protocol applies only to actions whose allowlist entry requires a true logged-in Warp user. Logged-out-safe local actions continue to use local-control credentials without requiring Warp account login. ### Execution context policy -`warpctrl` should distinguish verified invocations from inside Warp-managed terminal sessions from external invocations. +`warpctrl` should eventually distinguish verified invocations from inside Warp-managed terminal sessions from external invocations. The current foundation branch supports external invocation only and must reject verified Warp-terminal claims until the proof broker is implemented. - **Verified Warp-terminal invocation:** a `warpctrl` process started inside a Warp-managed terminal session and able to present an app-issued execution-context proof. The top-level setting for this context should default to on. When the selected app has a logged-in Warp user, this context can receive authenticated-user grants if the user's Scripting permissions allow that grant. - **External invocation:** a `warpctrl` process started outside Warp's terminal, such as from another terminal app, launch agent, IDE, or background script. The top-level setting for this context must default to off. When disabled, external invocations receive no local-control credentials, including logged-out-safe metadata credentials. - The app must not trust a caller-declared label. Environment variables may help discover the context, but the broker must verify a session-bound capability or equivalent proof before issuing in-Warp-only grants. ### Settings surface -Warp should add a new top-level Settings pane page named **Scripting**. This page should own settings for local scripting and automation surfaces, including Warp control. For Warp control, it should include two top-level toggles: +Warp should add a new top-level Settings pane page named **Scripting**. This page should own settings for local scripting and automation surfaces, including Warp control. The current foundation branch should expose only outside-Warp Warp control settings. In the long-term model, once verified Warp-terminal invocation is implemented, Warp control should include two top-level toggles: - **Allow Warp control from inside Warp:** default on. Controls `warpctrl` invocations from verified Warp-managed terminal sessions. - **Allow Warp control from outside Warp:** default off. Controls `warpctrl` invocations from external terminals, scripts, IDEs, launch agents, and other same-user processes. The Scripting page should explain that inside-Warp control is scoped to commands launched from Warp-managed terminals, while outside-Warp control allows other local apps and scripts to talk to Warp's control plane. Disabling either top-level toggle should invalidate credentials for that invocation context. ### Granular local-control permissions -The Scripting settings page should expose granular permissions beneath the inside-Warp and outside-Warp toggles. Recommended controls: +In the long-term model, the Scripting settings page should expose granular permissions beneath the inside-Warp and outside-Warp toggles. The current foundation branch exposes only the outside-Warp subset. Recommended controls: - Allow metadata reads. - Allow underlying data reads. - Allow app-state mutations. diff --git a/specs/warp-control-cli/SECURITY.md b/specs/warp-control-cli/SECURITY.md index 522a2c2cc7..5cdadce55d 100644 --- a/specs/warp-control-cli/SECURITY.md +++ b/specs/warp-control-cli/SECURITY.md @@ -3,6 +3,8 @@ The correct architecture is not a single shared localhost bearer token with client-side conventions. The CLI, app bridge, and protocol must treat security as a local app-enforced capability system: discovery finds compatible instances, secure storage protects raw credential material, broker-issued credentials identify the granted scopes, the running Warp app's local-control bridge enforces action categories before dispatch, and target resolution never silently retargets a request. The action-category model is primarily a safety and intent mechanism, not a hard security boundary against malicious same-user software. It lets a user, script, or agent intentionally request metadata-only, data-read, app-state mutation, metadata/configuration mutation, or underlying-data mutation access so it does not accidentally mutate state, expose sensitive content, or execute commands. It should not be described as strong access control against a process that can already run arbitrary commands as the user. `warpctrl` has two distinct authorization dimensions: local-control authority and Warp user authority. Local-control authority proves the request is allowed to control the local app. Warp user authority proves the selected Warp app has a real logged-in Warp user and the request is allowed to act on user-authenticated data such as Warp Drive objects, AI conversation traces, synced settings, or cloud-backed user state. Logged-out users should retain a smaller local-only control surface, but authenticated-user actions require a true logged-in Warp user in the selected app. +## Current foundation status +The current foundation implementation supports outside-Warp local-control requests only. Verified inside-Warp invocation is specified as future work because the app-issued terminal-session proof broker, proof injection path, and session registry do not exist yet. Until those pieces land, `InvocationContext::InsideWarp` requests must be rejected with `execution_context_not_allowed`, implemented action metadata must not advertise inside-Warp support, and Settings > Scripting must not expose inside-Warp enablement or permission toggles. ## Security goals - Allow trusted local users and approved automation to control a running Warp instance through a stable, scriptable interface. - Prevent unauthenticated localhost clients from invoking read or mutating control actions. @@ -88,18 +90,21 @@ Compared with these systems, `warpctrl` should combine: - VS Code's preference for typed public commands and separate treatment of remote control. The resulting architecture should not claim to solve arbitrary same-user malicious-app isolation. Its real value is preventing web-origin and other-user access, preventing unauthenticated direct localhost calls, keeping raw credentials out of plaintext discovery, giving honest scripts and agents narrow safety grants, and routing high-risk operations through local Warp app validation and user/policy approval. ## Authoritative enablement model +This section describes the long-term model. The current foundation branch implements only the outside-Warp half of this model and rejects inside-Warp requests until app-issued Warp-terminal proofs are implemented. Warp control has two top-level enablement states based on invocation context: - **Allow scripting from inside Warp:** controls `warpctrl` invocations from verified Warp-managed terminal sessions. This should default to on so commands run inside Warp can use local control subject to granular permissions. - **Allow scripting from outside Warp:** controls `warpctrl` invocations from external terminals, scripts, launch agents, IDEs, or other same-user processes. This must default to off. Both controls should live in a new top-level Settings pane page named **Scripting**. The Scripting page owns the user-facing controls for local scripting surfaces, including Warp control, and should explain the difference between commands run inside Warp and commands run from other apps. The visible UI settings are not enough by themselves. The authoritative enablement states must be stored in protected local storage that ordinary same-user apps cannot update by writing a plist, settings database row, registry key, JSON file, or synced cloud preference. This avoids turning outside-Warp control into a feature that any process can silently enable before invoking `warpctrl`. +Current foundation implementation note: outside-Warp enablement and granular permission bits are represented in the typed `LocalControlSettings` group as private, local-only settings. Each implemented setting must use `private: true`, `SyncToCloud::Never`, an explicit private storage key, and no `toml_path`, so it is excluded from `settings.toml`, the generated settings schema, Settings Sync, Warp Drive, and user-editable or server-backed settings surfaces. This private-settings path is an interim storage boundary, not the final protected-storage requirement; before public shipment, these authoritative bits must move to platform protected storage where available. Enablement requirements: - The settings are local-only and must not sync through Settings Sync, Warp Drive, or server-backed user preferences. +- The implemented foundation settings must remain private and absent from user-visible settings files, generated schemas, local-control settings read/write commands, and any allowlisted settings mutation catalog. - Only the running Warp app, through the Settings > Scripting UI, should be able to enable or disable the authoritative states. - `warpctrl`, shell scripts, config files, command-line flags, registry edits, defaults writes, and direct local-control protocol requests must not be able to enable either setting. - The in-Warp setting may default to enabled, but turning it off should prevent verified Warp-terminal invocations from receiving local-control grants. - The outside-Warp setting defaults to disabled and should require an intentional user gesture before enabling; the UI should explain that it allows scripts and automation from other apps to control Warp. -- The Scripting page should expose granular local-control permission settings rather than a single all-powerful switch. +- The Scripting page should expose granular local-control permission settings for implemented invocation contexts rather than a single all-powerful switch. - Each setting should be easy to disable from the same UI, and disabling either setting should revoke or invalidate active local-control credentials for that invocation context. - If enterprise or managed-device policy is added later, policy may force-disable either setting or allow an administrator-controlled default, but policy should be separate from user-editable local settings. Disabled-state behavior: @@ -170,6 +175,7 @@ A valid credential for one instance or target must not imply authority over anot - Kernel, hypervisor, or administrator-level compromise. - Security semantics for remote URL control endpoints. Remote control requires a separate transport and identity design before it can ship. ## Architecture overview +The full security model has eight layers. The current foundation branch implements the outside-Warp path and keeps the inside-Warp execution-context layer as a rejected future protocol concept until proof verification exists. The security model has eight layers: 1. **Protected enablement:** Use protected local storage for separate inside-Warp and outside-Warp enablement states, with inside-Warp on by default and outside-Warp off by default. 2. **Discovery:** Find compatible live Warp instances without granting broad authority. diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index 33a89b4048..77bb8d552d 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -1,6 +1,6 @@ # Context `PRODUCT.md` defines a standalone local Warp control CLI binary, provisionally named `warpctrl`, with an allowlisted action catalog, deterministic addressing across multiple running Warp app processes, and an incremental implementation plan. -`SECURITY.md` is the normative security architecture for this feature. Implementation work must follow it for separate inside-Warp and outside-Warp enablement, the top-level Settings > Scripting surface, protected enablement storage, granular permissions, discovery metadata, credential storage, scoped safety grants, verified execution context, authenticated-user requirements, localhost/browser protections, permission-category enforcement, deterministic target resolution, and local app-side validation. If this technical plan and `SECURITY.md` disagree, update the plan before implementing rather than treating the security architecture as optional follow-up work. +`SECURITY.md` is the normative security architecture for this feature. Implementation work must follow it for the top-level Settings > Scripting surface, protected enablement storage, granular permissions, discovery metadata, credential storage, scoped safety grants, verified execution context, authenticated-user requirements, localhost/browser protections, permission-category enforcement, deterministic target resolution, and local app-side validation. The long-term architecture includes separate verified inside-Warp and outside-Warp invocation contexts, but the current foundation implementation supports outside-Warp requests only and must reject `InvocationContext::InsideWarp` until the verified Warp-terminal proof broker lands. If this technical plan and `SECURITY.md` disagree, update the plan before implementing rather than treating the security architecture as optional follow-up work. The existing app already has three relevant building blocks: - `crates/http_server/src/lib.rs (7-61)` runs a native-only loopback Axum server on fixed port `9277`. - `app/src/lib.rs (1993-2001)` registers that HTTP server in the native app and currently merges only installation-detection and profiling routers. @@ -26,14 +26,16 @@ The most important constraint surfaced by this code is that the current fixed-po ### 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: -- Local control scripting has separate inside-Warp and outside-Warp enablement states. Inside-Warp control for verified Warp-managed terminal sessions defaults on; outside-Warp control for external terminals, scripts, IDEs, launch agents, and other same-user processes defaults off. -- Both controls live under a new top-level Settings pane page named **Scripting**. +- Long term, local control scripting has separate inside-Warp and outside-Warp enablement states. Inside-Warp control for verified Warp-managed terminal sessions can default on only after the app-issued proof broker exists; outside-Warp control for external terminals, scripts, IDEs, launch agents, and other same-user processes defaults off. +- In the current foundation slice, only outside-Warp enablement and permissions are implemented. Inside-Warp credential requests must be rejected and inside-Warp settings must not be exposed in the UI. +- In the long-term model, both controls live under a new top-level Settings pane page named **Scripting**. - The authoritative enablement states are local-only, not Settings Sync'd, and stored in protected local storage rather than ordinary user-editable settings. +- The current foundation branch must mark all implemented outside-Warp local-control settings as `private: true` and `sync_to_cloud: SyncToCloud::Never`. They must not appear in the user-visible `settings.toml` file, generated settings schema, Settings Sync, Warp Drive, server-backed preferences, or any future `warpctrl settings` surface. - `warpctrl`, direct protocol requests, shell scripts, config files, registry/plist edits, defaults writes, and server-backed preferences must not be able to enable either setting. - Discovery records do not publish actionable endpoints or credential references for disabled outside-Warp control. - Credential issuance is unavailable when the request's invocation context is disabled. - Raw credential material is kept out of plaintext discovery records and stored in platform secure storage where available. -- The broker distinguishes verified Warp-terminal invocations from external invocations using an app-issued execution-context proof, not a caller-declared label. +- The broker distinguishes verified Warp-terminal invocations from external invocations using an app-issued execution-context proof, not a caller-declared label. Until that broker exists, `InsideWarp` is a reserved protocol concept that must not receive credentials. - External invocations default to a smaller logged-out-safe action set that does not touch user-authenticated data. - Verified Warp-terminal invocations may receive authenticated-user grants only when the selected app has a true logged-in Warp user and local-control settings allow authenticated-user actions from Warp terminals. - The app rejects disabled, unauthenticated, expired, revoked, insufficient-scope, unsupported, malformed, ambiguous, missing-target, and stale-target requests with structured errors. @@ -120,7 +122,7 @@ This design preserves the current `9277` behavior while avoiding cross-process p Mutating localhost routes should not copy the permissive CORS posture of `/install_detection`. Recommended local trust model: - No browser-readable CORS allowance on control endpoints. -- The relevant inside-Warp or outside-Warp Scripting setting must allow the request context before credentials are minted or sensitive control requests are accepted. +- The relevant implemented Scripting setting must allow the request context before credentials are minted or sensitive control requests are accepted. In the current foundation branch that means outside-Warp only; future inside-Warp support must add its own verified setting gate. - The authoritative enablement bit must live in protected local storage and must not be writable by `warpctrl` or ordinary same-user preference/config edits. - Per-instance raw credential material must be kept out of plaintext discovery records and stored in platform secure storage where practical. - The CLI may load or request scoped credentials through an app-owned broker/helper, but it must not mint authority itself. @@ -132,7 +134,18 @@ Recommended local trust model: - Health metadata exposed without credentials, if needed for stale-record pruning, must not reveal mutating capabilities, credentials, or sensitive target state. This keeps the protocol local and scriptable without creating an ambient browser-to-localhost control surface. Do not ship the first slice as a plaintext discovery bearer token, even for same-user human CLI use. The first slice is the foundation for underlying data reads, app-state mutations, metadata/configuration mutations, and underlying data mutations, so it must establish the protected enablement, credential storage, scoped grant, and app-side enforcement model from `SECURITY.md`. -### 4. App-side request bridge onto the UI/application context +### 4. Future verified Warp-terminal invocation context +The current foundation branch does not implement verified inside-Warp invocation. `InvocationContext::InsideWarp` and `ExecutionContextProof::VerifiedWarpTerminal` may remain in the shared protocol as reserved future concepts, but the credential broker must reject them until the proof broker described here exists. +Minimum implementable design: +- When Warp creates or Warpifies a terminal session, the app creates a high-entropy per-session capability and records verifier state in an app-owned terminal-session registry. +- The registry entry is bound to the selected app instance, terminal/session identifier, issuing process generation, expiry, and revocation state. +- The shell receives only proof material needed by `warpctrl`, such as an opaque handle plus a short-lived token or challenge-response input. Plain environment variables may carry handles or hints, but a caller-set variable must not be sufficient authority. +- `warpctrl` invoked from that terminal sends `InvocationContext::InsideWarp` and `ExecutionContextProof::VerifiedWarpTerminal` to `/v1/control/credentials` when it has proof material. Without proof material it must use `OutsideWarp`. +- The broker verifies the proof against the app-owned registry, including app instance, session liveness, expiry, revocation, and nonce or challenge binding before minting any inside-Warp scoped credential. +- The broker then checks Settings > Scripting and permission-category policy for the requested action. A valid proof raises the maximum eligible grant set; it does not bypass user settings, action metadata, authenticated-user requirements, target scopes, or bridge enforcement. +- The minted credential records `invocation_context: InsideWarp`, the granted permission category, expiry, instance, and any authenticated-user subject. The app bridge revalidates the grant and current app policy on every control request. +Hardened follow-ups can strengthen this minimum design by storing secret material in platform secure storage, exposing only opaque handles through the shell, using Unix-domain-socket or named-pipe peer-credential checks, binding proofs to broker challenges, and invalidating proofs on shell/session teardown, app logout, user switch, or Settings > Scripting changes. These hardening layers improve direct-token theft resistance, but they do not create a perfect security boundary against malicious same-user software with process, filesystem, Accessibility, or screen-observation access. +### 5. App-side request bridge onto the UI/application context The HTTP handler runs on a Tokio runtime thread owned by the local-control server. It cannot directly access or mutate Warp's UI models, views, or app context because all WarpUI state is single-threaded and owned by the main app event loop. The bridge solves this by sending a closure from the Tokio handler thread to the main thread, executing it in the model's context, and returning the result to the waiting HTTP handler. #### Thread model - **Tokio runtime thread (HTTP handler):** Owns the Axum router, receives HTTP requests, authenticates, deserializes the `RequestEnvelope`. Cannot touch `AppContext`, views, or models. @@ -197,7 +210,7 @@ To add a new action to the bridge: 5. Inside the match arm, use `ctx` (which is a `&mut ModelContext<LocalControlBridge>` that derefs to `&mut AppContext`) to resolve selectors and dispatch the action onto existing app types. 6. Return a `ResponseEnvelope::ok(...)` or `ResponseEnvelope::error(...)` with the result. The bridge closure has access to the full `AppContext` API surface, including `ctx.windows()`, `ctx.window_ids()`, `ctx.views_of_type::<T>(window_id)`, `handle.update(ctx, ...)`, and `handle.read(ctx, ...)`. This makes it straightforward to wire new actions to existing UI behavior without introducing new concurrency concerns. -### 5. Target resolution model +### 6. Target resolution model Implement target resolution as a reusable component rather than scattering lookup logic across handlers. Recommended resolution order: 1. Select instance in the CLI/discovery layer. @@ -226,7 +239,7 @@ Implementation references: - Session selectors: `--session <active|id:<id>|index:<n>>`, `--session-id <id>`, and `--session-index <n>`, with one form allowed. - Block/file/Drive selectors only on commands that need them: `--block-id <id>`, path arguments or `--path <path>` plus `--line`/`--column`, and Drive object ID arguments or `--drive-id <id>`. The CLI converts these flags into the protocol `TargetSelector` before sending the request. It must not rely on positional entity IDs for commands like `window close 1`; target entities are selected through the shared selector flags so command arguments remain reserved for action parameters. -### 6. Allowlisted handler families +### 7. Allowlisted handler families Use one handler module per action family. The protocol layer owns parsing/validation; handler modules own target resolution and delegation to existing app logic. Recommended modules/families: - Discovery/state: @@ -257,16 +270,17 @@ Recommended shape: - Keep the mapping one-way from internal behavior to public catalog metadata. `WarpCtrlBehavior::Exposed(ControlActionKind::TabCreate)` means the action is represented by the public `tab.create` command; it does not mean raw `WorkspaceAction::AddTerminalTab` is serializable or dispatchable over the protocol. - Add tests that collect reviewed action kinds and verify every `ControlActionKind` has protocol metadata, permission metadata, CLI parser coverage, generated-doc coverage, and an app-side handler before it can be advertised as supported. The `warpui::Action` trait should not be extended for this purpose because it currently has a blanket implementation for any `Any + Debug + Send + Sync` type. The enforcement point is the concrete user-visible action enums and binding/action registration surfaces, where exhaustive review can be required without weakening the allowlisted protocol boundary. -### 7. First slice: prove discovery and `tab.create` +### 8. First slice: prove discovery and `tab.create` The first `warpctrl` implementation slice should land the minimum cross-cutting architecture plus a single representative tab mutation: - Shared protocol types and error envelopes. - `FeatureFlag::WarpControlCli` and Cargo feature `warp_control_cli`, with app-side runtime gating for settings, discovery, bridge registration, and local-control endpoints. -- New top-level Settings > Scripting page with separate protected inside-Warp and outside-Warp enablement states, rendered only while `FeatureFlag::WarpControlCli` is enabled. -- Protected local-only enablement storage where inside-Warp control defaults on and outside-Warp control defaults off. -- Granular local-control permission storage under Settings > Scripting for metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, underlying data mutations, and authenticated-user-action categories. +- New top-level Settings > Scripting page rendered only while `FeatureFlag::WarpControlCli` is enabled. The current foundation exposes outside-Warp local-control settings only; verified inside-Warp controls are deferred until the proof broker exists. +- Protected local-only enablement storage where outside-Warp control defaults off. Future inside-Warp enablement must use the same protected-storage class before it is exposed. +- As an interim foundation step, the outside-Warp top-level enablement and granular permission bits live in the typed `LocalControlSettings` group as private settings with `SyncToCloud::Never`, explicit private storage keys, and no `toml_path`. This keeps them out of the user-visible settings file and generated settings schema while leaving the protected-storage migration as a required pre-ship hardening step. +- Granular outside-Warp local-control permission storage under Settings > Scripting for metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, and underlying data mutations. Future inside-Warp permissions should be added only with verified terminal proof support. - Discovery registry and CLI instance selection. - A standalone `warpctrl` binary or artifact path that runs control commands without starting the GUI app runtime. -- Per-process authenticated local-control server that refuses sensitive work when the request's inside-Warp or outside-Warp context is disabled. +- Per-process authenticated local-control server that refuses sensitive work when outside-Warp control is disabled and rejects inside-Warp credential requests until verified terminal proof support is implemented. - Scoped credential issuance/storage with no raw credentials in plaintext discovery records, including execution-context fields and authenticated-user grant fields. - App-side request bridge and selector resolver. - Action-category mapping and app-side safety-grant enforcement. @@ -279,7 +293,7 @@ Why `tab.create` first: - It exercises the protected enablement and scoped-grant model before higher-risk action families depend on it. - It gives operators a concise end-to-end smoke test: discover a running instance, create a tab, and confirm the live app changed. The PR should also introduce the shell-facing CLI command grammar that the remainder of the protocol will reuse and establish a lightweight CLI startup path distinct from GUI startup. -### 8. Follow-up slices: fill out the remaining protocol in parallel +### 9. Follow-up slices: fill out the remaining protocol in parallel After the first slice validates discovery, auth, selector resolution, CLI syntax, and server-to-app execution, follow-up slices can add the remaining allowlisted catalog in parallelized action-family groups. The baseline code should make new action additions mostly additive: - Extend `ControlAction`. - Update the relevant `WarpCtrlBehavior` mappings for the internal app actions that implement, overlap with, exclude, or defer the behavior. @@ -287,7 +301,7 @@ After the first slice validates discovery, auth, selector resolution, CLI syntax - Add a handler. - Add validation/tests. - Add CLI surface/tests. -### 9. CLI parsing and output libraries +### 10. CLI parsing and output libraries The `warpctrl` CLI must use the same argument parsing and output libraries as the existing Oz CLI so that conventions, derive patterns, and shell-completion generation remain consistent across both binaries. - **clap** (with the `derive` feature) for argument parsing, subcommand trees, and help generation. Both binaries share the `warp_cli` crate, so parser types defined there are reused directly. - **serde** / **serde_json** for JSON request/response serialization and for `--output-format json` output. @@ -295,7 +309,7 @@ The `warpctrl` CLI must use the same argument parsing and output libraries as th - The `OutputFormat` enum (`Pretty`, `Json`, `Ndjson`, `Text`) is shared from `warp_cli::agent::OutputFormat` so human-readable vs. machine-readable output follows the same conventions. - New subcommand types for `warpctrl` live in `warp_cli::local_control` and follow the same `#[derive(Parser)]` / `#[derive(Subcommand)]` / `#[derive(Args)]` patterns used by the Oz CLI's top-level `Args` and `CliCommand` types. Do not introduce alternative parsing libraries (e.g., `structopt`, `argh`) or alternative serialization approaches. Keeping one set of libraries across both CLIs reduces dependency weight, ensures consistent `--help` formatting, and lets contributors move between the two surfaces without learning a different stack. -### 10. CLI packaging and release shape +### 11. CLI packaging and release shape The shipped product shape should be a separate bundled `warpctrl` CLI binary that reuses shared CLI/protocol crates but does not depend on launching the GUI binary in command mode. Follow the Oz CLI release model as closely as practical: - macOS: - Add a standalone control CLI artifact path next to the existing Oz standalone CLI artifact flow. @@ -426,7 +440,7 @@ sequenceDiagram ## Testing and validation Map tests directly to `PRODUCT.md` behavior. - Security architecture: - - Protected enablement tests proving inside-Warp control defaults on, outside-Warp control defaults off, and disabled contexts reject credential issuance, sensitive discovery, and mutating requests with `local_control_disabled`. + - Protected enablement tests proving outside-Warp control defaults off, disabled outside-Warp context rejects credential issuance, sensitive discovery, and mutating requests with `local_control_disabled`, and current inside-Warp credential requests are rejected with `execution_context_not_allowed`. - Tests proving discovery in disabled state exposes no actionable endpoint authority or credential reference. - Credential-storage tests proving raw credentials are not written into plaintext discovery records. - Execution-context tests proving external clients cannot receive grants reserved for verified Warp-terminal invocations. @@ -459,9 +473,7 @@ Map tests directly to `PRODUCT.md` behavior. ### Computer-use CLI verification Before any stacked PR is considered ready for review, run an end-to-end computer-use verification pass against a cloud built validation artifact from that branch. The validation artifact is a Warp app build and standalone `warpctrl` binary built by the validation agent from the exact commit under review, with `warp_control_cli` compiled in and `FeatureFlag::WarpControlCli` enabled at runtime. The verifier must launch the built Warp app, enable the Scripting settings needed for the command category under test, and capture screenshots or screen recordings that prove the user-visible result of each basic command family. Screenshots should be saved as durable artifacts and named by branch, invocation context, command family, and command name. -The verifier must exercise both invocation contexts: -- **Inside Warp terminal:** run `warpctrl` from a terminal session inside the feature-flag-enabled Warp app. This path must prove the app-issued Warp-terminal execution-context proof is accepted and that inside-Warp settings gate the command categories. -- **Outside Warp terminal:** run the same `warpctrl` binary from an external terminal or automation shell outside the built Warp app. This path must prove outside-Warp control is default-off, then works only after enabling outside-Warp scripting and the required granular permissions in Settings > Scripting. +The verifier must exercise every invocation context implemented by the branch under review. For the current foundation branch, inside-Warp verification means proving `InsideWarp` requests are rejected and no inside-Warp Settings > Scripting controls are exposed. Once verified Warp-terminal support is implemented, the verifier must also run `warpctrl` from a terminal session inside the feature-flag-enabled Warp app and prove the app-issued execution-context proof is accepted and inside-Warp settings gate command categories. The outside-Warp path must run the same `warpctrl` binary from an external terminal or automation shell outside the built Warp app and prove outside-Warp control is default-off, then works only after enabling outside-Warp scripting and the required granular permissions in Settings > Scripting. The verification matrix should cover every implemented command in `PRODUCT.md` at least once in JSON output mode and, where there is a visible UI effect, with a screenshot after the command runs. At minimum: - read-only metadata commands show successful CLI output and, for active/focus/list commands, a visible target that matches the output; - underlying data read commands show successful CLI output only when the underlying-data-read permission is enabled and a denial screenshot/output when it is disabled; From a8a09571fefbcde809c5c58a006e5082e394c94b Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Sun, 24 May 2026 18:48:49 -0600 Subject: [PATCH 22/48] Expand warpctrl authenticated scripting spec Co-Authored-By: Oz <oz-agent@warp.dev> --- specs/warp-control-cli/PRODUCT.md | 90 ++++++++++++++++++------------ specs/warp-control-cli/SECURITY.md | 66 ++++++++++++---------- specs/warp-control-cli/TECH.md | 87 ++++++++++++++++++++++------- 3 files changed, 158 insertions(+), 85 deletions(-) diff --git a/specs/warp-control-cli/PRODUCT.md b/specs/warp-control-cli/PRODUCT.md index 27eaa219d5..e3e7d2ca07 100644 --- a/specs/warp-control-cli/PRODUCT.md +++ b/specs/warp-control-cli/PRODUCT.md @@ -105,7 +105,7 @@ Non-goals: - Replace the active input buffer. - Clear the active input buffer where that matches existing user behavior. - Switch input mode between terminal and agent modes only where that mode switch is already user-visible and valid for the selected target. - The initial public version must not submit terminal input, press Enter, run terminal commands, accept suggested commands, launch workflows into a terminal, or submit agent prompts. At most, it may stage text into an active input buffer for the user to review and confirm manually. Command execution and agent-prompt submission may be reserved as future protocol concepts only after a separate product/security review. + Input staging commands must not submit terminal input or press Enter. The separate `input run` execution action may submit a command only in the later execution-underlying branch, after authenticated scripting identity, underlying-data-mutation permission, audit coverage, and explicit target resolution are implemented. Accepted-command submission and agent-prompt submission remain future protocol concepts that require separate product/security review. 21. Appearance actions: - List available themes. - Set the current fixed theme. @@ -138,13 +138,14 @@ Non-goals: - Open a path in a new tab or window. - Open a repository picker or repo path flow where the current app already supports it. These should remain allowlisted intent actions rather than arbitrary filesystem RPCs. -26. The following categories are explicitly excluded from the initial public allowlist even if there are internal actions for them: +26. The following categories are explicitly excluded from the public allowlist even when internal actions exist for them: - Crash, panic, heap-dump, token-copying, debug-reset, and similar developer/debug helpers. - - Arbitrary auth manipulation. - - Arbitrary cloud object mutation or broad Warp Drive CRUD. + - Arbitrary auth manipulation outside the explicit authenticated-scripting flows. + - Arbitrary cloud object mutation or broad Warp Drive CRUD outside the typed Drive actions in this spec. - Arbitrary internal view dispatch by string. - Arbitrary setting names outside the allowlist. - - Terminal command execution, workflow execution, accepted-command submission, and agent-prompt submission in the initial public version. + - Accepted-command submission and agent-prompt submission until they receive a separate product/security review. + Terminal command execution, file writes/deletes, and typed Warp Drive object mutations are no longer excluded from the full product scope, but they belong to later authenticated underlying-data mutation branches and require stronger authenticated-user and permission gates than app-state or settings mutations. 27. CLI command names should be noun-oriented and discoverable. During the provisional standalone-binary phase, the control CLI should expose a `warpctrl ...` command surface: - `warpctrl instance list` - `warpctrl app active` @@ -193,8 +194,8 @@ The product surface must distinguish what kind of state a command touches. This - **Underlying data reads** expose user content or data-bearing state without changing it: terminal output, block contents, command history, input buffer contents, file contents, Warp Drive object contents, AI conversation content, and any other content that could contain user data or secrets. - **App-state mutations** change visible local Warp UI state without directly changing user data: opening or focusing windows, creating or closing tabs, splitting panes, focusing panes, opening panels, opening command surfaces, opening files in Warp, and editing the input buffer without submitting it. - **Metadata/configuration mutations** change persistent configuration or metadata, but not primary user content: changing themes, font size, zoom, allowlisted settings, keybindings, tab names, pane names, and tab colors. -- **Underlying data mutations** can change user data or cause external side effects: writing/creating/deleting files, CRUD operations on Warp Drive objects, mutating AI conversation history, and future execution actions such as running terminal commands, running workflows that execute commands, accepting suggested commands, or submitting agent prompts. -A command that touches multiple categories must require the strongest applicable permission. For example, `file open` is an app-state mutation, while `file write` is an underlying data mutation; `input insert` is an app-state mutation, while a future `input run` action would be an underlying data mutation because it executes a command in the target session. +- **Underlying data mutations** can change user data or cause external side effects: writing/creating/deleting files, typed CRUD operations on Warp Drive objects, inserting content into Warp Drive views, running allowlisted Warp Drive workflows, and running terminal commands through an explicit `input run` action. Accepted-command submission, agent-prompt submission, arbitrary workflow execution, and arbitrary internal dispatch remain excluded until separately reviewed. +A command that touches multiple categories must require the strongest applicable permission. For example, `file open` is an app-state mutation, while `file write` is an underlying data mutation; `input insert` is an app-state mutation, while `input run` is an underlying data mutation because it executes a command in the target session. ### Targeting flags All commands that address a running app target accept the same selector flags where meaningful. Generic `--window`, `--tab`, `--pane`, `--session`, and `--block` flags accept the selector grammar below; explicit typed aliases are provided so scripts can avoid string parsing ambiguity: - `--instance <instance_id>` selects a running Warp process from `warpctrl instance list`. @@ -255,8 +256,19 @@ Local file and project reads that expose only app/editor state, not arbitrary fi Authenticated read-only Warp Drive metadata and data reads, enabled only when the selected app has a logged-in Warp user and the grant allows authenticated reads. Listing is metadata; inspecting object content is an underlying data read: - `warpctrl drive list --type <workflow|notebook|env-var-collection|prompt|folder|ai-fact|mcp-server|space|trash> [selectors]` - `warpctrl drive inspect <id> [selectors]` +### Authenticated scripting command set +The full product requires two authenticated scripting modes before high-risk underlying-data mutations ship: +- **Verified Warp-terminal authenticated scripting:** `warpctrl` runs inside a Warp-managed terminal, presents the app-issued terminal proof described in `TECH.md`, and may receive authenticated-user grants only when the selected app is logged into Warp and Settings > Scripting allows authenticated actions from verified Warp terminals. +- **External API-key authenticated scripting:** `warpctrl` runs outside Warp or in a pure automation environment and presents a Warp-issued API key or derived short-lived exchange token to the selected app's local broker. The broker verifies the key, scopes, expiry, and user subject before issuing local authenticated-user grants. This path is separate from the local-control bearer credential and is required for unattended scripts that need file, Drive, or execution authority. +Recommended CLI surface for API-key setup and inspection: +- `warpctrl auth status [selectors]` reports local-control auth state, selected app login state, authenticated grant availability, and whether an external API-key identity is configured. +- `warpctrl auth login [selectors]` focuses the selected Warp app's sign-in UI for interactive app-login flows. +- `warpctrl auth api-key set --key-env <env_var>|--key-stdin [selectors]` stores or references an external scripting API key in platform secure storage without printing it. +- `warpctrl auth api-key status [selectors]` reports key subject/scope metadata without revealing the key. +- `warpctrl auth api-key revoke [selectors]` deletes the local stored reference and, where supported, revokes the server-side key. +The API-key path must support non-interactive scripts through an environment variable or secret manager reference, but raw keys must never be written to discovery records, logs, JSON output, shell completions, or repo config. ### Mutating command set -The mutating branches should build on the read-only stack and implement the following mutating commands: `zach/warp-cli-mutating-layout` owns app/window/tab/pane layout mutations, and `zach/warp-cli-mutating-input-settings-surfaces` owns the remaining input/session/settings/surface mutations. Mutating commands are split by what they mutate: app-state, metadata/configuration, or underlying data. Underlying data mutations require a separate and stronger permission than app-state or metadata/configuration mutations. +The mutating branches should build on the read-only and authenticated-scripting stack. `zach/warp-cli-mutating-layout` owns app/window/tab/pane layout mutations. `zach/warp-cli-mutating-input-settings-surfaces` owns input/session/settings/surface mutations. `zach/warp-cli-mutating-files-drive-data` owns file and Warp Drive underlying-data mutations. `zach/warp-cli-mutating-execution-underlying` owns terminal command execution and other approved execution-underlying actions. Mutating commands are split by what they mutate: app-state, metadata/configuration, or underlying data. Underlying data mutations require a separate and stronger permission plus an authenticated scripting identity. App-state mutations for app, window, and surfaces: - `warpctrl app focus [selectors]` - `warpctrl window create [--shell <name>] [selectors]` @@ -309,7 +321,7 @@ App-state mutations for sessions and input buffers: - `warpctrl input replace <text> [--session <selector>] [selectors]` - `warpctrl input clear [--session <selector>] [selectors]` - `warpctrl input mode set <terminal|agent> [--session <selector>] [selectors]` -These input-buffer commands only stage or edit text. The initial public implementation must not include a command that submits the buffer, executes a terminal command, accepts a suggested command, or sends an agent prompt. +These input-buffer commands only stage or edit text and must not submit the buffer. The separate `input run` command belongs only to the execution-underlying branch and requires authenticated scripting identity, underlying-data-mutation permission, explicit target resolution, and audit coverage. Accepted-command submission and agent-prompt submission remain excluded until separately reviewed. Metadata/configuration mutations for appearance and settings: - `warpctrl theme set <theme_name> [selectors]` - `warpctrl theme system set <true|false> [selectors]` @@ -330,19 +342,18 @@ App-state mutations for files, projects, and Warp Drive views: - `warpctrl drive notebook open <id> [selectors]` - `warpctrl drive env-var-collection open <id> [selectors]` Underlying data mutations for files and authenticated Warp Drive objects: -- `warpctrl file create <path> [--content <text>] [selectors]` -- `warpctrl file write <path> --content <text> [selectors]` -- `warpctrl file append <path> --content <text> [selectors]` +- `warpctrl file create <path> [--content <text>|--content-file <path>] [selectors]` +- `warpctrl file write <path> --content <text>|--content-file <path> [selectors]` +- `warpctrl file append <path> --content <text>|--content-file <path> [selectors]` - `warpctrl file delete <path> [selectors]` -- `warpctrl drive object create --type <workflow|notebook|env-var-collection|prompt|folder> [selectors]` -- `warpctrl drive object update <id> [selectors]` -- `warpctrl drive object trash <id> [selectors]` -- `warpctrl drive object restore <id> [selectors]` -Future execution actions explicitly excluded from the initial public implementation: -- `warpctrl input run <command> [--session <selector>] [selectors]` -- `warpctrl agent prompt submit <prompt> [--session <selector>] [selectors]` +- `warpctrl drive object create --type <workflow|notebook|env-var-collection|prompt|folder> [--content <text>|--content-file <path>] [selectors]` +- `warpctrl drive object update <id> [--content <text>|--content-file <path>] [selectors]` +- `warpctrl drive object delete <id> [selectors]` +- `warpctrl drive object insert <id> [--target <selector>] [selectors]` - `warpctrl drive workflow run <id> [--arg <name=value>...] [selectors]` -These are underlying-data mutations because they can execute code, trigger external side effects, or send user-authored prompts. They require a separate product/security review before being added to any public allowlist. +Execution-underlying actions: +- `warpctrl input run <command> [--session <selector>] [selectors]` +These are underlying-data mutations because they can modify user data, execute code, trigger external side effects, or run user-authored content. They require authenticated scripting identity, underlying-data-mutation permission, deterministic target resolution, audit records, and explicit tests proving lower-permission credentials cannot run them. Accepted-command submission and agent-prompt submission remain excluded until separately reviewed. ### Excluded from the public command surface The command surface must continue to exclude debug-only, crash-only, auth-token, heap-dump, and arbitrary internal dispatch actions even when those actions are available in command palette or keybinding registries. Examples that remain excluded are app crash/panic helpers, access-token copy helpers, heap profile dumps, debug reset actions, raw view-tree debugging, and broad internal action-by-string execution. ## Branch stacking and delivery model @@ -350,8 +361,11 @@ The Warp Control CLI work should ship as a raw-git branch stack so the combined - `zach/warp-cli-core-foundation` is the bottom review branch and targets `master`. It owns `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs alongside the first implementation slice: shared protocol, discovery/auth scaffolding, outside-Warp Settings > Scripting gates, local-control bridge/server, standalone `warpctrl` binary, packaging hooks, and the smallest safe end-to-end action. Verified inside-Warp invocation is documented for future implementation but is not supported by this branch. - `zach/warp-cli-readonly-metadata` stacks on `zach/warp-cli-core-foundation` and implements structural metadata reads, including instance/app health, active-chain, windows, tabs, panes, sessions, and action metadata. - `zach/warp-cli-readonly-data-settings` stacks on `zach/warp-cli-readonly-metadata` and fills in underlying-data reads plus read-only settings/appearance/docs, including terminal block output, input-buffer reads, history reads, and allowlisted settings metadata. -- `zach/warp-cli-mutating-layout` stacks on `zach/warp-cli-readonly-data-settings` and implements app/window/tab/pane layout mutations. -- `zach/warp-cli-mutating-input-settings-surfaces` stacks on `zach/warp-cli-mutating-layout` and fills in approved input/session/settings/surface mutating command families while preserving the initial prohibition on terminal command execution and agent-prompt submission. +- `zach/warp-cli-authenticated-scripting` stacks on `zach/warp-cli-readonly-data-settings` and implements authenticated-user grant plumbing for both verified Warp-terminal invocations and external API-key scripting identities. It does not broaden action support by itself; it makes later high-risk branches enforceable. +- `zach/warp-cli-mutating-layout` stacks on `zach/warp-cli-authenticated-scripting` and implements app/window/tab/pane layout mutations. +- `zach/warp-cli-mutating-input-settings-surfaces` stacks on `zach/warp-cli-mutating-layout` and fills in approved input/session/settings/surface mutating command families while preserving the prohibition on accepted-command submission and agent-prompt submission. +- `zach/warp-cli-mutating-files-drive-data` stacks on `zach/warp-cli-mutating-input-settings-surfaces` and implements authenticated file and Warp Drive underlying-data mutations from the approved allowlist. +- `zach/warp-cli-mutating-execution-underlying` stacks on `zach/warp-cli-mutating-files-drive-data` and implements authenticated execution-underlying actions such as `input run` and typed workflow execution where supported. The previous `zach/warp-cli-specs` branch is retained only as migration-source/history material. New spec changes originate on `zach/warp-cli-core-foundation` and are propagated upward through the stack with raw git so all higher implementation branches reflect the same product/security contract. Graphite is not part of this stack. If a lower branch merges first, higher branches should rebase onto the merged successor while preserving the approved spec content. ## Built-in Warp Agent skill Warp should include a built-in Agent skill for `warpctrl`, analogous to the bundled `oz-platform` skill. The skill should teach Warp Agent when to use `warpctrl`, how to discover and target running instances, how to prefer read-only commands before mutating commands, how to request explicit user approval for underlying data mutations, and how to interpret structured errors. The skill should document the stable command hierarchy above, include concise recipes for common automation tasks, and avoid instructing agents to bypass the CLI by calling local-control HTTP endpoints directly. @@ -378,6 +392,10 @@ Every action definition must include: - the required local-control permission category; - any target-scope restrictions. By default, new actions require an authenticated Warp user. An action may be marked logged-out-safe only after deliberate review confirms it does not touch Warp Drive, AI conversation traces, synced settings, team/account data, cloud-backed user state, terminal content, or other user-sensitive data. +### Authenticated scripting model +Authenticated scripting is required for any command that acts on a true Warp user identity or performs underlying-data mutation. Local-control credentials prove that a process may talk to the selected app; authenticated scripting credentials prove which Warp user or automation identity is allowed to request user-backed or high-risk actions. +Inside Warp, authenticated scripting uses the verified terminal proof flow: the selected app is already logged in, the terminal proof binds the CLI to a live Warp-managed session, and the broker may mint an authenticated-user grant for that app user when Settings > Scripting allows it. +Outside Warp, authenticated scripting uses a Warp-issued API key or exchanged short-lived token. The API key must be scoped for scripting/local control, optionally constrained to action categories or resource families, and tied to a Warp user subject. The selected app must either be logged in as the same subject or be able to validate that the API key's subject is authorized for the requested local action without exporting cloud auth tokens to the script. External API-key grants default off in Settings > Scripting and should be separable from ordinary outside-Warp logged-out-safe control. ### Permission categories Every action in the catalog belongs to exactly one of the following permission categories, from least to most sensitive: 1. **Read-only / metadata.** Actions that return local app structure, app state, or configuration metadata without exposing terminal content, file content, Warp Drive object content, AI conversation content, or other user data. @@ -396,12 +414,12 @@ Every action in the catalog belongs to exactly one of the following permission c - Tab and pane names, tab colors, themes, system-theme settings, font size, zoom, allowlisted app settings, and keybindings. Metadata/configuration writes need a stronger permission than app-state-only changes because they persist beyond the current UI interaction, but they are still distinct from data writes. 5. **Mutating / underlying data.** Actions that can change user data, execute code, submit prompts, or cause external side effects. - - Future terminal execution: `input run`, workflow execution in a terminal session, and any command execution path. These are explicitly excluded from the initial public implementation. - - Future agent execution: submitting an agent prompt, accepting an agent-proposed command, or otherwise causing an agent to act. These are explicitly excluded from the initial public implementation. + - Terminal execution through the explicit `input run` action and typed workflow execution where supported. - File writes: create, write, append, delete, rename, or otherwise modify local files. - - Warp Drive CRUD: create, update, trash, restore, permanently delete, or otherwise mutate workflows, notebooks, prompts, env-var collections, folders, or other Drive objects. + - Warp Drive CRUD: create, update, delete, insert, run, or otherwise mutate workflows, notebooks, prompts, env-var collections, folders, or other Drive objects. - AI conversation history mutation and any action that modifies cloud-backed user content. - This category must be explicitly separate from app-state mutation. A client allowed to open or focus Warp UI must not automatically be allowed to execute commands, write files, or mutate Warp Drive content. + - Future agent execution: submitting an agent prompt, accepting an agent-proposed command, or otherwise causing an agent to act; these remain excluded until separately reviewed. + This category must be explicitly separate from app-state mutation and requires authenticated scripting identity. A client allowed to open or focus Warp UI must not automatically be allowed to execute commands, write files, or mutate Warp Drive content. ### Authenticated-user requirement An authenticated user means a true logged-in Warp user in the selected Warp app, not merely the local OS user or a `warpctrl` process authenticated to localhost. The allowlist must clearly indicate `requires_authenticated_user` for every action: @@ -409,15 +427,15 @@ The allowlist must clearly indicate `requires_authenticated_user` for every acti - `true` for actions that read or mutate Warp Drive, AI conversation traces, synced settings, team/account data, user identity data, or any cloud-backed Warp state. - `true` for actions that execute user-authored Warp Drive content, even if the execution target is a local terminal session. If an authenticated-user action is invoked while the selected app has no logged-in user, the CLI reports a structured authenticated-user error. It must not silently return partial logged-out data as success. -### Warp Control login protocol -`warpctrl` must not maintain an independent cloud login that can drift from the Warp process it controls. For authenticated-user actions, the logged-in user is the user currently authenticated in the selected Warp app instance. -The CLI should expose a small auth/status flow for actions that require a logged-in Warp user: +### Warp Control authenticated scripting protocol +`warpctrl` has two authenticated scripting modes. Interactive inside-Warp use relies on the logged-in user in the selected Warp app and verified terminal proof. External or pure scripting use relies on a Warp-issued API key that is separate from local-control credentials and is exchanged for short-lived authenticated grants. +The CLI should expose auth/status flows for both modes: - `warpctrl auth status [selectors]` reports whether the selected Warp app is logged in and returns a stable, non-secret user subject/identity summary when the caller has the required local-control grant. - `warpctrl auth login [selectors]` does not collect credentials in the CLI or mint a separate CLI account session. It focuses or opens the selected Warp app's normal sign-in UI and waits, or exits with instructions, until the user completes sign-in in that app. -- After login completes, the app-side credential broker may mint an authenticated-user grant only for the same user subject that is currently logged in to the selected app. -- Authenticated-user credentials are bound to the selected app instance and user subject. If the app logs out, switches users, loses auth state, or the grant's subject no longer matches the selected app's logged-in subject, authenticated-user actions fail with a structured authenticated-user error rather than using stale authority. -- Raw Firebase, server, OAuth, or cloud API tokens are never exported to `warpctrl`, shell scripts, generated docs, logs, or JSON output. -This login protocol applies only to actions whose allowlist entry requires a true logged-in Warp user. Logged-out-safe local actions continue to use local-control credentials without requiring Warp account login. +- After app login completes, the app-side credential broker may mint an app-user grant only for the same user subject that is currently logged in to the selected app. For external API-key mode, the broker may mint an API-key-backed grant only after validating the key, scopes, subject, and local Scripting permissions. +- Authenticated credentials are bound to the selected app instance, subject, grant mode, scopes, expiry, and optional target/resource restrictions. If the app logs out, switches users, loses auth state, or the grant's subject no longer matches a grant that requires the selected app's logged-in subject, authenticated actions fail with a structured authenticated-user error rather than using stale authority. +- Raw Firebase, server, OAuth, cloud API tokens, and raw scripting API keys are never exported to `warpctrl` output, shell scripts, generated docs, logs, discovery records, or JSON responses. +This authenticated scripting protocol applies only to actions whose allowlist entry requires a true logged-in Warp user, external API-key identity, or underlying-data-mutation authority. Logged-out-safe local actions continue to use local-control credentials without requiring Warp account login or API-key setup. ### Execution context policy `warpctrl` should eventually distinguish verified invocations from inside Warp-managed terminal sessions from external invocations. The current foundation branch supports external invocation only and must reject verified Warp-terminal claims until the proof broker is implemented. - **Verified Warp-terminal invocation:** a `warpctrl` process started inside a Warp-managed terminal session and able to present an app-issued execution-context proof. The top-level setting for this context should default to on. When the selected app has a logged-in Warp user, this context can receive authenticated-user grants if the user's Scripting permissions allow that grant. @@ -451,7 +469,7 @@ Scoped credentials should include: - revocation/audit identity. The bridge, not the CLI frontend, enforces these grants. If a request exceeds its credential, the bridge returns `insufficient_permissions`, `authenticated_user_required`, `authenticated_user_unavailable`, or `execution_context_not_allowed` as appropriate. ### Future entity extensibility: files, blocks, and Warp Drive objects -The selector and action model should be designed to accommodate entity types beyond the current window/tab/pane/session hierarchy. Important entity families are **terminal blocks**, **local files**, **projects/workspaces**, and **Warp Drive objects**. Neither broad file/Drive mutation nor command/agent execution is in scope for the first implementation, but the protocol should not preclude future reviewed additions. +The selector and action model should accommodate entity types beyond the current window/tab/pane/session hierarchy. Important entity families are **terminal blocks**, **local files**, **projects/workspaces**, and **Warp Drive objects**. Broad file/Drive mutation and command execution are not in scope for the foundation branch, but they are in scope for later authenticated branches in the expanded stack. Agent-prompt submission remains excluded until separately reviewed. **Terminal blocks.** Blocks are first-class targetable terminal entities, not just data hanging off a session. Block selectors should support the same addressing primitives as terminal sessions where meaningful: active/current block, opaque block ID, and block index scoped to the resolved session. Block reads can expose command text, output, status, timing, exit code, and metadata, so block content reads are underlying-data reads while block listing that returns only IDs/status/timestamps may be metadata reads. Stale, missing, or ambiguous block selectors must fail rather than selecting a neighboring block. **Files.** Warp already supports file opening via deep links and the built-in editor. A future `file` namespace could support: - `warpctrl file open <path>` — app-state mutation that opens a file in a Warp editor tab, equivalent to clicking a file link. @@ -463,10 +481,10 @@ File selectors would use filesystem paths (absolute or relative to the working d **Warp Drive objects.** Warp Drive stores typed objects that users can reference, execute, edit, and share. The object taxonomy should include, at minimum, spaces, folders, notebooks, workflows, agent-mode workflows/prompts, environment variable collections, AI facts/rules, MCP servers, MCP server collections, and trash entries where trash operations are exposed. A future `drive` namespace could support: - `warpctrl drive list --type workflow` — authenticated metadata read that lists Warp Drive objects by type. - `warpctrl drive inspect <id>` — authenticated underlying data read when it returns object content. -- `warpctrl drive workflow run <workflow-id>` — future authenticated underlying data mutation that executes a workflow in a target session, excluded from the initial public implementation. +- `warpctrl drive workflow run <workflow-id>` — authenticated underlying data mutation that executes a typed workflow in a target session, implemented only in the execution-underlying branch with authenticated scripting identity and audit coverage. - `warpctrl drive object create|update|trash|restore <id>` — authenticated underlying data mutations that change cloud-backed user content. - `warpctrl drive notebook open <notebook-id>` — app-state mutation that opens a view of an existing notebook without modifying it. -Drive object selectors should support both opaque IDs (for automation stability) and human-friendly name/path lookups (for interactive use). The type field (`workflow`, `notebook`, `env_var_collection`, `prompt`, `folder`, `ai_fact`, `mcp_server`, etc.) acts as a namespace filter. Drive actions that execute content in a terminal session (e.g., running a workflow) inherit the underlying-data-mutation permission from the action classification model and remain unavailable until the execution prohibition is lifted by a later spec/review. +Drive object selectors should support both opaque IDs (for automation stability) and human-friendly name/path lookups (for interactive use). The type field (`workflow`, `notebook`, `env_var_collection`, `prompt`, `folder`, `ai_fact`, `mcp_server`, etc.) acts as a namespace filter. Drive actions that execute content in a terminal session (e.g., running a workflow) inherit the underlying-data-mutation permission from the action classification model and are implemented only in the execution-underlying branch after authenticated scripting identity and audit coverage are in place. **Design constraints for these future entity families:** - File and Drive selectors are orthogonal to the window/tab/pane hierarchy — a file open action targets an instance (which window to open in), not a specific pane. A Drive workflow execution targets a session (which pane to run in). - The `TargetSelector` type in the protocol should be extensible with optional fields for these new selector families without breaking existing requests that omit them. diff --git a/specs/warp-control-cli/SECURITY.md b/specs/warp-control-cli/SECURITY.md index 5cdadce55d..4383dd951e 100644 --- a/specs/warp-control-cli/SECURITY.md +++ b/specs/warp-control-cli/SECURITY.md @@ -1,8 +1,8 @@ # warpctrl security architecture -`warpctrl` is a local-control CLI for an already-running Warp app instance. Its security architecture is designed to support the control catalog: discovery, structural metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, underlying data mutations, input-buffer staging, file operations, and Warp Drive operations. Terminal command execution, workflow execution, accepted-command submission, and agent-prompt submission are future high-risk capabilities and must not be included in the initial public implementation; the initial version may stage text in an active input buffer only for the user to review and confirm manually. +`warpctrl` is a local-control CLI for an already-running Warp app instance. Its security architecture is designed to support the control catalog: discovery, structural metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, underlying data mutations, input-buffer staging, file operations, Warp Drive operations, and explicitly authenticated execution-underlying actions. Accepted-command submission and agent-prompt submission remain future high-risk capabilities that require separate product/security review, but typed terminal command execution (`input.run`) and typed Warp Drive workflow execution are in scope for later authenticated underlying-data mutation branches. The correct architecture is not a single shared localhost bearer token with client-side conventions. The CLI, app bridge, and protocol must treat security as a local app-enforced capability system: discovery finds compatible instances, secure storage protects raw credential material, broker-issued credentials identify the granted scopes, the running Warp app's local-control bridge enforces action categories before dispatch, and target resolution never silently retargets a request. The action-category model is primarily a safety and intent mechanism, not a hard security boundary against malicious same-user software. It lets a user, script, or agent intentionally request metadata-only, data-read, app-state mutation, metadata/configuration mutation, or underlying-data mutation access so it does not accidentally mutate state, expose sensitive content, or execute commands. It should not be described as strong access control against a process that can already run arbitrary commands as the user. -`warpctrl` has two distinct authorization dimensions: local-control authority and Warp user authority. Local-control authority proves the request is allowed to control the local app. Warp user authority proves the selected Warp app has a real logged-in Warp user and the request is allowed to act on user-authenticated data such as Warp Drive objects, AI conversation traces, synced settings, or cloud-backed user state. Logged-out users should retain a smaller local-only control surface, but authenticated-user actions require a true logged-in Warp user in the selected app. +`warpctrl` has two distinct authorization dimensions: local-control authority and authenticated scripting authority. Local-control authority proves the request is allowed to control the local app. Authenticated scripting authority proves the Warp user or automation identity that is allowed to act on user-authenticated data such as Warp Drive objects, AI conversation traces, synced settings, cloud-backed user state, file mutations, and execution-underlying actions. Logged-out users should retain a smaller local-only control surface, but authenticated-user and high-risk underlying-data mutation actions require either a verified Warp-terminal grant tied to the selected app's logged-in user or an external Warp-issued API-key grant. ## Current foundation status The current foundation implementation supports outside-Warp local-control requests only. Verified inside-Warp invocation is specified as future work because the app-issued terminal-session proof broker, proof injection path, and session registry do not exist yet. Until those pieces land, `InvocationContext::InsideWarp` requests must be rejected with `execution_context_not_allowed`, implemented action metadata must not advertise inside-Warp support, and Settings > Scripting must not expose inside-Warp enablement or permission toggles. ## Security goals @@ -22,7 +22,7 @@ The current foundation implementation supports outside-Warp local-control reques - Classify every action by state/data category and enforce the required permission category in the local app bridge, not in the CLI frontend. - Classify every action by whether it requires an authenticated Warp user. New actions should default to requiring an authenticated user unless they are deliberately reviewed as safe for logged-out or external use. - Prevent `warpctrl` from becoming an ambient full-power confused deputy that any same-user process can invoke for high-risk actions. -- Prohibit terminal command execution, workflow execution, accepted-command submission, and agent-prompt submission in the initial public implementation; input-buffer actions may stage text only and must not submit it. +- Require authenticated scripting identity, underlying-data-mutation permission, explicit target resolution, and audit records before terminal command execution or typed workflow execution can ship; accepted-command submission and agent-prompt submission remain prohibited until separately reviewed. - Preserve deterministic targeting so a request never silently mutates or reads the wrong window, tab, pane, session, file, or Warp Drive object. - Keep the action surface allowlisted and typed rather than exposing arbitrary internal app dispatch. - Make high-risk operations auditable and configurable without logging sensitive terminal contents or credentials. @@ -135,19 +135,26 @@ These scoped credentials are guardrails for well-behaved clients. They prevent a `warpctrl` should be able to receive special grants when it is invoked from within a Warp-managed terminal session, but the bridge must not trust a caller-supplied string such as `caller_class=warp_terminal`. The app should issue a session-bound execution-context proof to Warp-managed terminal sessions and have the broker verify that proof before minting in-Warp-only grants. Acceptable designs include a short-lived per-session capability, an app-owned broker handshake tied to the terminal session, or an equivalent proof that arbitrary external processes cannot mint by setting an environment variable. Plain environment variables may be used as handles or hints, but they must not be the sole authority for in-Warp privileges because external processes can spoof them. Verified in-Warp context can raise the maximum eligible grant set, especially for authenticated-user actions. It does not by itself bypass the user's granular local-control settings, action categories, target scopes, or logged-in-user requirements. -### Warp user authentication boundary -Actions that touch user-authenticated Warp data require a true logged-in Warp user in the selected app. This includes Warp Drive object contents or mutation, AI conversation traces, cloud-backed user settings, team/account data, and any other surface whose normal app access depends on the user's Warp account. Authenticated-user grants must be tied to the same logged-in Warp user that is active in the selected app instance; `warpctrl` must not maintain a separate cloud login that can drift from the controlled process. -The app bridge should execute these actions on behalf of the logged-in app user through existing app auth state. `warpctrl` should receive a local-control credential that carries an `authenticated_user` grant, the verified user identity or stable subject reference, and the allowed authenticated action families. It should not need to export raw Firebase, server, or cloud API tokens to shell scripts. -If the selected app has no logged-in user, authenticated-user actions must fail with a structured error rather than falling back to logged-out behavior. Logged-out users may still use the smaller local-only action set explicitly marked as not requiring an authenticated user. -### Authenticated-user login protocol -`warpctrl` should provide an auth/status flow for users and automation that need authenticated-user actions, but the CLI must not collect Warp credentials or own an independent Warp account session. +### Authenticated scripting boundary +Actions that touch user-authenticated Warp data or perform high-risk underlying-data mutations require authenticated scripting authority. This includes Warp Drive object contents or mutation, AI conversation traces, cloud-backed user settings, team/account data, file writes/deletes, typed workflow execution, terminal command execution, and any other surface whose normal app access depends on the user's Warp account or can cause external side effects. +There are two supported authenticated scripting modes: +- **Verified Warp-terminal mode:** `warpctrl` presents an app-issued terminal-session proof. If the selected app is logged into Warp and Settings > Scripting permits authenticated actions from verified Warp terminals, the broker may mint an authenticated-user grant tied to the selected app's current user subject. +- **External API-key mode:** `warpctrl` presents a Warp-issued scripting API key or a short-lived token exchanged from that key. If outside-Warp scripting and external authenticated grants are enabled, the broker verifies the key, scopes, expiry, revocation state, and user subject before minting a local authenticated-user grant. +For app-backed authenticated actions, the app bridge should execute on behalf of the selected app's logged-in user through existing app auth state. For explicitly API-key-backed actions, the API key subject and scopes must be recorded in the local grant and the handler must not export raw Firebase, server, OAuth, or cloud API tokens to shell scripts. If the selected app logs out, switches users, or no longer matches a grant that requires app-user identity, authenticated actions fail with structured errors rather than falling back to logged-out behavior. +Logged-out users may still use the smaller local-only action set explicitly marked as not requiring authenticated scripting authority. +### Authenticated scripting protocol +`warpctrl` should provide auth/status flows for both interactive app login and external API-key automation. The CLI must not collect Warp passwords and must not print or persist raw API keys outside approved secret storage. Requirements: -- `warpctrl auth status [selectors]` reports whether the selected app instance is logged in and may return a stable, non-secret user subject/identity summary when the caller has the required grant. +- `warpctrl auth status [selectors]` reports whether the selected app instance is logged in, whether verified Warp-terminal authenticated grants are available, and whether an external API-key identity is configured. It may return stable, non-secret subject/scope metadata when the caller has the required grant. - `warpctrl auth login [selectors]` focuses or opens the selected Warp app's normal sign-in UI and waits, or exits with actionable instructions, until the user signs in through Warp itself. -- The credential broker may mint an authenticated-user grant only after confirming the selected app has a true logged-in Warp user and the requested authenticated-user setting is enabled for the verified invocation context. -- Authenticated-user credentials are bound to the selected instance and logged-in user subject. If the app logs out, switches users, loses authenticated state, or the presented credential subject no longer matches the selected app's current user, authenticated-user actions fail with `authenticated_user_mismatch` or another structured authenticated-user error. -- The app bridge executes authenticated-user actions through the selected app's existing auth state. Raw Firebase, server, OAuth, or cloud API tokens must not be exported to `warpctrl`, shell scripts, JSON output, generated docs, or logs. -This protocol applies only to actions whose allowlist entry requires a logged-in Warp user. Logged-out-safe actions continue to use local-control credentials without requiring Warp account login. +- `warpctrl auth api-key set --key-env <env_var>|--key-stdin [selectors]` stores or references a Warp-issued scripting API key in platform secure storage. Non-interactive scripts may provide the key through a secret-manager-injected environment variable. +- `warpctrl auth api-key status [selectors]` reports non-secret subject, expiry, and scope metadata for the configured API key. +- `warpctrl auth api-key revoke [selectors]` removes the local key reference and revokes the server-side key where supported. +- The credential broker may mint an app-user authenticated grant only after confirming the selected app has a true logged-in Warp user and the requested authenticated-user setting is enabled for the verified invocation context. +- The credential broker may mint an external API-key grant only after validating the key or exchanging it for a short-lived assertion, confirming that external authenticated grants are enabled, and checking that the key scope covers the requested action family and permission category. +- Authenticated credentials are bound to the selected instance, subject, grant mode, scopes, expiry, and optional target/resource restrictions. If the app logs out, switches users, loses authenticated state, or the presented credential subject no longer matches a grant that requires app-user identity, authenticated actions fail with `authenticated_user_mismatch` or another structured authenticated-user error. +- Raw Firebase, server, OAuth, cloud API tokens, and raw API keys must not be exported to `warpctrl` output, shell completions, generated docs, logs, discovery records, or local-control JSON responses. +Logged-out-safe actions continue to use local-control credentials without requiring authenticated scripting identity. ### Application identity boundary On platforms with secure credential storage, especially macOS, the raw local-control credential should be readable only by Warp-owned, correctly signed code. On macOS this means storing raw credential material in Keychain with access constrained by Warp's signing identity, designated requirement, Keychain access group, or equivalent platform mechanism. This narrows token extraction from “any same-user process can read a file” to “only trusted Warp-signed code can unwrap the secret.” This boundary protects the credential from direct theft and prevents arbitrary apps from making authenticated raw HTTP requests to the local-control listener. It also lets the authoritative enablement state be stored somewhere harder to modify than ordinary user preferences. It does not prove that the user personally intended the specific action. Any same-user process may still be able to invoke the trusted `warpctrl` binary or automate the Warp UI. That confused-deputy risk is reduced by explicit in-app enablement, scoped credential issuance, action-category policy, and local app-side bridge enforcement, but it is not eliminated as a hard same-user security boundary. @@ -269,7 +276,7 @@ Recommended defaults: - Commands should start from least privilege and request only the grant needed for the requested action. - External same-user invocations should default to the smaller logged-out-safe local action set unless policy or explicit approval grants more. - Verified Warp-terminal invocations may receive broader local-control grants when the user's granular settings allow them. -- Authenticated-user grants are available only when the selected Warp app has a true logged-in Warp user and the requested execution context is allowed by local-control settings. +- App-user authenticated grants are available only when the selected Warp app has a true logged-in Warp user and the requested execution context is allowed by local-control settings. External API-key authenticated grants are available only after key validation/exchange and only when external authenticated scripting is enabled. - Metadata reads require an explicit `read_metadata` grant. - Underlying data reads require an explicit `read_underlying_data` grant. - App-state mutations require an explicit `mutate_app_state` grant. @@ -320,17 +327,18 @@ Transport requirements: Remote URL support is a separate future transport mode. It should not reuse the local same-user credential model without additional identity, encryption, replay protection, and remote approval/policy design. ## Logged-in user requirements Local-control validation always begins with local protocol state: discovery records, secure local credential references, scoped safety grants, execution-context proof, protocol version, request shape, allowlisted actions, typed parameters, and deterministic target selectors. -Some actions additionally require a true logged-in Warp user in the selected app. The action allowlist must declare this explicitly with a `requires_authenticated_user` field. +Some actions additionally require authenticated scripting authority: either a true logged-in Warp user in the selected app or an external API-key-backed subject with sufficient scopes. The action allowlist must declare this explicitly with a `requires_authenticated_user` or equivalent authenticated-scripting requirement field. Default rule for new actions: - New actions require an authenticated Warp user unless the implementer deliberately classifies them as logged-out-safe. - The logged-out-safe set should remain meaningfully smaller and limited to local app structure, local appearance metadata, and other surfaces that do not depend on the user's cloud-backed Warp identity. - Actions that read or mutate Warp Drive, AI conversation traces, synced settings, team/account data, or other user-authenticated state must require an authenticated user. -- Future actions that can execute user-authored cloud-backed content, such as running Warp Drive workflows or submitting notebook commands, require both the authenticated-user grant and the appropriate high-risk action category. These execution actions are excluded from the initial public implementation. -When an authenticated-user action is requested: -- the selected app must have an active logged-in Warp user; -- the presented local-control credential must include an `authenticated_user` grant for that user or stable subject; -- the user's granular settings must allow authenticated-user actions for the verified execution context; -- the app bridge should execute through the app's existing authenticated state rather than exporting raw cloud auth credentials to `warpctrl`. +- Actions that execute user-authored cloud-backed content, such as running typed Warp Drive workflows, require both authenticated scripting authority and the appropriate high-risk action category. Agent-prompt submission remains excluded until separately reviewed. +When an authenticated-user or authenticated-scripting action is requested: +- app-user mode requires the selected app to have an active logged-in Warp user; +- API-key mode requires a validated key or exchanged assertion with sufficient scopes, subject, expiry, and revocation state; +- the presented local-control credential must include an authenticated grant for that user or API-key-backed subject; +- the user's granular settings must allow authenticated actions for the verified execution context or external API-key mode; +- the app bridge should execute app-user actions through the app's existing authenticated state rather than exporting raw cloud auth credentials to `warpctrl`. If these conditions are not met, the app returns a structured error. It must not fall back to logged-out behavior or silently omit user-authenticated data from a result that claims success. ## Safety policy model Safety grants are enforced in the app bridge after transport authentication and before target resolution or handler dispatch. This provides consistent “do not accidentally do more than requested” behavior for honest clients, not a sandbox for hostile same-user code. @@ -342,7 +350,7 @@ The bridge must: 5. Map the requested action to a required permission category, action family, execution-context requirement, and authenticated-user requirement. 6. Check optional target-family restrictions. 7. Reject requests that exceed the credential's grants with `insufficient_permissions`. -8. Reject authenticated-user actions without a logged-in user or authenticated-user grant with a structured authenticated-user error. +8. Reject authenticated-user or API-key-backed actions without the required app-user login, API-key validation, scopes, or authenticated grant with a structured authenticated-user/API-key error. 9. Only then resolve selectors and invoke the allowlisted handler. The CLI frontend may provide helpful preflight errors, but those checks are advisory. Local app-side bridge enforcement is mandatory because other tools can bypass the official CLI and speak the protocol directly. ## Action permission categories @@ -380,13 +388,12 @@ This category should not authorize terminal command execution, file writes, or W ### Underlying data mutations Can change user data, execute code, submit prompts, or cause external side effects. Examples: -- future command execution in a session; -- future agent prompt submission or acceptance of an agent-proposed command; -- future execution of Warp Drive workflows or other user-authored runnable content; +- terminal command execution through the explicit `input.run` action; +- typed Warp Drive workflow execution or other approved user-authored runnable content; - file create/write/append/delete operations; -- Warp Drive object create/update/trash/restore/permanent-delete operations; +- Warp Drive object create/update/delete/insert operations; - AI conversation history mutation or other cloud-backed content mutation. -This category should require explicit user or policy approval for unattended automation and integrations. It must remain separate from app-state mutation so a client that can open or focus Warp UI cannot automatically execute commands, submit prompts, write files, or mutate Warp Drive content. Terminal command execution, workflow execution, accepted-command submission, and agent-prompt submission must remain unavailable in the initial public implementation even if the protocol has future reserved action names for them. +This category requires authenticated scripting identity plus explicit user or policy approval for unattended automation and integrations. It must remain separate from app-state mutation so a client that can open or focus Warp UI cannot automatically execute commands, submit prompts, write files, or mutate Warp Drive content. Accepted-command submission and agent-prompt submission remain unavailable until separately reviewed even if future protocol names are reserved for them. ## Target scoping and deterministic resolution Targeting is part of security. The protocol must not convert ambiguous or stale selectors into best-effort mutations. Rules: @@ -447,7 +454,8 @@ Important errors include: - `local_control_disabled` when the relevant inside-Warp or outside-Warp scripting context is disabled in Settings > Scripting or has been disabled after credentials were issued; - `unauthorized_local_client` for missing, malformed, expired, revoked, or invalid credentials; - `insufficient_permissions` for valid credentials that lack the requested permission category or target scope; -- `authenticated_user_required` when an action requires a logged-in Warp user but the credential lacks an authenticated-user grant; +- `authenticated_user_required` when an action requires authenticated scripting authority but the credential lacks an authenticated-user or API-key-backed grant; +- `api_key_required`, `api_key_invalid`, `api_key_expired`, `api_key_revoked`, and `api_key_insufficient_scope` for external API-key scripting failures, or equivalent structured variants if consolidated under existing authenticated-user errors; - `authenticated_user_unavailable` when the selected Warp app has no logged-in Warp user or cannot access the required authenticated user state; - `authenticated_user_mismatch` when an authenticated-user credential is bound to a different user subject than the user currently logged in to the selected Warp app; - `execution_context_not_allowed` when the action or requested grant is not allowed from the verified invocation context, such as an external client attempting an in-Warp-only authenticated-user action; diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index 77bb8d552d..869269b806 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -145,7 +145,30 @@ Minimum implementable design: - The broker then checks Settings > Scripting and permission-category policy for the requested action. A valid proof raises the maximum eligible grant set; it does not bypass user settings, action metadata, authenticated-user requirements, target scopes, or bridge enforcement. - The minted credential records `invocation_context: InsideWarp`, the granted permission category, expiry, instance, and any authenticated-user subject. The app bridge revalidates the grant and current app policy on every control request. Hardened follow-ups can strengthen this minimum design by storing secret material in platform secure storage, exposing only opaque handles through the shell, using Unix-domain-socket or named-pipe peer-credential checks, binding proofs to broker challenges, and invalidating proofs on shell/session teardown, app logout, user switch, or Settings > Scripting changes. These hardening layers improve direct-token theft resistance, but they do not create a perfect security boundary against malicious same-user software with process, filesystem, Accessibility, or screen-observation access. -### 5. App-side request bridge onto the UI/application context +### 5. Authenticated scripting identity and API-key grants +The full control catalog includes file writes, Warp Drive data mutation, and execution-underlying actions. Those actions require an authenticated scripting layer in addition to local-control credentials. Local-control credentials prove authority to call the local app; authenticated scripting credentials prove the Warp user or automation identity allowed to request user-backed or high-risk actions. +#### Inside-Warp authenticated scripting +For `warpctrl` launched inside a Warp-managed terminal, use the verified terminal proof broker from the previous section. When the proof is valid and the selected app is logged into Warp, the broker may mint an authenticated-user grant bound to the app's current user subject. The grant is available only if Settings > Scripting enables authenticated-user actions for verified Warp terminals and the requested action's permission category is enabled. +The CLI must not receive raw Firebase, OAuth, server, or session tokens. The app bridge executes authenticated actions through the selected app's existing auth state and rejects the grant if the app logs out, switches users, or the grant subject no longer matches the app user. +#### External API-key authenticated scripting +For `warpctrl` launched outside Warp, by cron, or by another pure scripting environment, introduce a separate API-key path. The user creates or supplies a Warp-issued scripting API key with explicit scopes such as local-control authenticated reads, file mutation, Drive mutation, or execution-underlying actions. The CLI may reference the key from a secret manager or environment variable such as `WARPCTRL_API_KEY`, or store it in platform secure storage through `warpctrl auth api-key set --key-stdin`; it must never print or write the raw key to discovery records, logs, JSON output, shell completions, or repo config. +The local broker exchanges or validates the API key with Warp services, obtains a short-lived signed identity assertion, and mints a local authenticated-user grant only when all of the following hold: +- outside-Warp scripting is enabled; +- external authenticated-user grants are enabled separately from logged-out outside-Warp control; +- the API key is valid, unexpired, unrevoked, and scoped for the requested permission category and action family; +- the selected app is logged into the same Warp user subject, or the action is explicitly designed to use API-key-backed identity without exporting app cloud tokens; +- the requested local-control permission category is enabled; +- any resource or target restrictions in the key and grant are satisfied. +The grant should record the API-key subject, scopes, credential ID, expiry, invocation context, permission category, and optional target/resource restrictions. The app bridge revalidates the grant before selector resolution and handler dispatch. +#### Auth command surface and storage +Add CLI and broker support for: +- `warpctrl auth status [selectors]` to report selected app login state, configured API-key subject metadata, and available authenticated grant modes without exposing secrets. +- `warpctrl auth login [selectors]` to focus the selected app's normal sign-in UI for interactive app login. +- `warpctrl auth api-key set --key-env <env_var>|--key-stdin [selectors]` to store or reference an external scripting key. +- `warpctrl auth api-key status [selectors]` to show key subject/scope metadata. +- `warpctrl auth api-key revoke [selectors]` to delete the local reference and revoke the server-side key where supported. +Store raw API keys only in platform secure storage where available. Environment-variable use is allowed for non-interactive automation, but commands and docs should prefer secret-manager injection over plaintext shell profiles. +### 6. App-side request bridge onto the UI/application context The HTTP handler runs on a Tokio runtime thread owned by the local-control server. It cannot directly access or mutate Warp's UI models, views, or app context because all WarpUI state is single-threaded and owned by the main app event loop. The bridge solves this by sending a closure from the Tokio handler thread to the main thread, executing it in the model's context, and returning the result to the waiting HTTP handler. #### Thread model - **Tokio runtime thread (HTTP handler):** Owns the Axum router, receives HTTP requests, authenticates, deserializes the `RequestEnvelope`. Cannot touch `AppContext`, views, or models. @@ -210,7 +233,7 @@ To add a new action to the bridge: 5. Inside the match arm, use `ctx` (which is a `&mut ModelContext<LocalControlBridge>` that derefs to `&mut AppContext`) to resolve selectors and dispatch the action onto existing app types. 6. Return a `ResponseEnvelope::ok(...)` or `ResponseEnvelope::error(...)` with the result. The bridge closure has access to the full `AppContext` API surface, including `ctx.windows()`, `ctx.window_ids()`, `ctx.views_of_type::<T>(window_id)`, `handle.update(ctx, ...)`, and `handle.read(ctx, ...)`. This makes it straightforward to wire new actions to existing UI behavior without introducing new concurrency concerns. -### 6. Target resolution model +### 7. Target resolution model Implement target resolution as a reusable component rather than scattering lookup logic across handlers. Recommended resolution order: 1. Select instance in the CLI/discovery layer. @@ -239,7 +262,7 @@ Implementation references: - Session selectors: `--session <active|id:<id>|index:<n>>`, `--session-id <id>`, and `--session-index <n>`, with one form allowed. - Block/file/Drive selectors only on commands that need them: `--block-id <id>`, path arguments or `--path <path>` plus `--line`/`--column`, and Drive object ID arguments or `--drive-id <id>`. The CLI converts these flags into the protocol `TargetSelector` before sending the request. It must not rely on positional entity IDs for commands like `window close 1`; target entities are selected through the shared selector flags so command arguments remain reserved for action parameters. -### 7. Allowlisted handler families +### 8. Allowlisted handler families Use one handler module per action family. The protocol layer owns parsing/validation; handler modules own target resolution and delegation to existing app logic. Recommended modules/families: - Discovery/state: @@ -270,7 +293,7 @@ Recommended shape: - Keep the mapping one-way from internal behavior to public catalog metadata. `WarpCtrlBehavior::Exposed(ControlActionKind::TabCreate)` means the action is represented by the public `tab.create` command; it does not mean raw `WorkspaceAction::AddTerminalTab` is serializable or dispatchable over the protocol. - Add tests that collect reviewed action kinds and verify every `ControlActionKind` has protocol metadata, permission metadata, CLI parser coverage, generated-doc coverage, and an app-side handler before it can be advertised as supported. The `warpui::Action` trait should not be extended for this purpose because it currently has a blanket implementation for any `Any + Debug + Send + Sync` type. The enforcement point is the concrete user-visible action enums and binding/action registration surfaces, where exhaustive review can be required without weakening the allowlisted protocol boundary. -### 8. First slice: prove discovery and `tab.create` +### 9. First slice: prove discovery and `tab.create` The first `warpctrl` implementation slice should land the minimum cross-cutting architecture plus a single representative tab mutation: - Shared protocol types and error envelopes. - `FeatureFlag::WarpControlCli` and Cargo feature `warp_control_cli`, with app-side runtime gating for settings, discovery, bridge registration, and local-control endpoints. @@ -293,7 +316,7 @@ Why `tab.create` first: - It exercises the protected enablement and scoped-grant model before higher-risk action families depend on it. - It gives operators a concise end-to-end smoke test: discover a running instance, create a tab, and confirm the live app changed. The PR should also introduce the shell-facing CLI command grammar that the remainder of the protocol will reuse and establish a lightweight CLI startup path distinct from GUI startup. -### 9. Follow-up slices: fill out the remaining protocol in parallel +### 10. Follow-up slices: fill out the remaining protocol in parallel After the first slice validates discovery, auth, selector resolution, CLI syntax, and server-to-app execution, follow-up slices can add the remaining allowlisted catalog in parallelized action-family groups. The baseline code should make new action additions mostly additive: - Extend `ControlAction`. - Update the relevant `WarpCtrlBehavior` mappings for the internal app actions that implement, overlap with, exclude, or defer the behavior. @@ -301,7 +324,7 @@ After the first slice validates discovery, auth, selector resolution, CLI syntax - Add a handler. - Add validation/tests. - Add CLI surface/tests. -### 10. CLI parsing and output libraries +### 11. CLI parsing and output libraries The `warpctrl` CLI must use the same argument parsing and output libraries as the existing Oz CLI so that conventions, derive patterns, and shell-completion generation remain consistent across both binaries. - **clap** (with the `derive` feature) for argument parsing, subcommand trees, and help generation. Both binaries share the `warp_cli` crate, so parser types defined there are reused directly. - **serde** / **serde_json** for JSON request/response serialization and for `--output-format json` output. @@ -309,7 +332,7 @@ The `warpctrl` CLI must use the same argument parsing and output libraries as th - The `OutputFormat` enum (`Pretty`, `Json`, `Ndjson`, `Text`) is shared from `warp_cli::agent::OutputFormat` so human-readable vs. machine-readable output follows the same conventions. - New subcommand types for `warpctrl` live in `warp_cli::local_control` and follow the same `#[derive(Parser)]` / `#[derive(Subcommand)]` / `#[derive(Args)]` patterns used by the Oz CLI's top-level `Args` and `CliCommand` types. Do not introduce alternative parsing libraries (e.g., `structopt`, `argh`) or alternative serialization approaches. Keeping one set of libraries across both CLIs reduces dependency weight, ensures consistent `--help` formatting, and lets contributors move between the two surfaces without learning a different stack. -### 11. CLI packaging and release shape +### 12. CLI packaging and release shape The shipped product shape should be a separate bundled `warpctrl` CLI binary that reuses shared CLI/protocol crates but does not depend on launching the GUI binary in command mode. Follow the Oz CLI release model as closely as practical: - macOS: - Add a standalone control CLI artifact path next to the existing Oz standalone CLI artifact flow. @@ -333,23 +356,29 @@ The durable review stack should optimize for reviewability rather than mirroring 1. `zach/warp-cli-core-foundation` — create this branch from `master`. It owns the specs in `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs, plus the shared implementation foundation: protocol crate shape, discovery/auth scaffolding, selector and error types, action metadata/catalog structure, Scripting settings surface, protected local-control settings, local-control server/bridge skeleton, standalone `warpctrl` binary skeleton, packaging hooks, module split, `WarpCtrlBehavior` scaffolding, and the minimum `tab.create` smoke path if needed to prove the end-to-end architecture. 2. `zach/warp-cli-readonly-metadata` — create this branch from `zach/warp-cli-core-foundation`. It implements structural metadata reads: instance/app health and active-chain commands, windows, tabs, panes, sessions, capability/action metadata, opaque IDs, metadata target shapes, and metadata-read permission enforcement. It must not expose terminal output, input buffers, history, file contents, Drive object contents, or other underlying user data. 3. `zach/warp-cli-readonly-data-settings` — create this branch from `zach/warp-cli-readonly-metadata`. It implements underlying-data reads and read-only settings/appearance/docs: block listing/inspection/output, input-buffer reads, history reads, theme/settings/keybinding/action reads, project/file app-state reads, authenticated Drive metadata/content reads where present, read-only docs, and the read-only `warpctrl` Agent skill. -4. `zach/warp-cli-mutating-layout` — create this branch from `zach/warp-cli-readonly-data-settings`. It implements layout and app-state mutations for app/window/tab/pane behavior: window create/focus/close, tab create/activate/move/close, tab metadata that belongs with layout review if appropriate, pane split/focus/navigate/resize/maximize/close, app-state mutation permission checks, and layout mutation tests. -5. `zach/warp-cli-mutating-input-settings-surfaces` — create this branch from `zach/warp-cli-mutating-layout`. It implements the remaining approved mutating command families: session activation/cycling/reopen, input insert/replace/clear, input mode switching, theme/system-theme/font/zoom changes, allowlisted setting set/toggle, settings/palette/search/panel/surface commands, file/project/Drive open commands that are app-state-only, mutating docs, and the mutating-command Agent skill updates. It must preserve the initial public prohibition on terminal command execution, workflow execution, accepted-command submission, and agent-prompt submission. +4. `zach/warp-cli-authenticated-scripting` — create this branch from `zach/warp-cli-readonly-data-settings`. It implements authenticated-user grant plumbing, the verified Warp-terminal proof broker, external API-key scripting identity, auth command surface, Settings > Scripting controls for authenticated grants, and tests proving high-risk actions cannot run without authenticated grants. It should not implement broad new action families by itself. +5. `zach/warp-cli-mutating-layout` — create this branch from `zach/warp-cli-authenticated-scripting`. It implements layout and app-state mutations for app/window/tab/pane behavior: window create/focus/close, tab create/activate/move/close, tab metadata that belongs with layout review if appropriate, pane split/focus/navigate/resize/maximize/close, app-state mutation permission checks, and layout mutation tests. +6. `zach/warp-cli-mutating-input-settings-surfaces` — create this branch from `zach/warp-cli-mutating-layout`. It implements session activation/cycling/reopen, input insert/replace/clear, input mode switching, theme/system-theme/font/zoom changes, allowlisted setting set/toggle, settings/palette/search/panel/surface commands, file/project/Drive open commands that are app-state-only, mutating docs, and the mutating-command Agent skill updates. It must preserve the prohibition on accepted-command submission and agent-prompt submission. +7. `zach/warp-cli-mutating-files-drive-data` — create this branch from `zach/warp-cli-mutating-input-settings-surfaces`. It implements authenticated underlying-data mutations for local files and Warp Drive objects: file create/write/append/delete, typed Drive object create/update/delete/insert, permission enforcement, authenticated-user/API-key enforcement, and tests using disposable resources. +8. `zach/warp-cli-mutating-execution-underlying` — create this branch from `zach/warp-cli-mutating-files-drive-data`. It implements authenticated execution-underlying actions such as `input.run` and typed workflow execution where supported. It must require underlying-data-mutation permission plus authenticated scripting identity, audit records, explicit target resolution, and tests proving accepted-command submission and agent-prompt submission remain unavailable. The previous `zach/warp-cli-specs` branch is retained only as migration-source/history material. It is no longer a separate review PR or an authoritative branch in the active stack. The goal is to keep durable review branches close to roughly 2,000 lines of incremental changes where practical while avoiding a one-branch-per-command maintenance burden. Product phases still matter, but they are not the primary PR boundary. The durable branches are the review spine; short-lived shard branches can feed into them during implementation. -Spec changes are an important part of the stacking strategy. All new spec changes must originate on `zach/warp-cli-core-foundation`, which is the authoritative source for `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs. After a spec change lands there, propagate it upward through every stacked branch with raw git so the spec files stay synchronized across `zach/warp-cli-readonly-metadata`, `zach/warp-cli-readonly-data-settings`, `zach/warp-cli-mutating-layout`, and `zach/warp-cli-mutating-input-settings-surfaces`. Do not make independent spec edits directly on higher implementation branches except as part of resolving a propagation conflict in a way that preserves the authoritative bottom-branch content. +Spec changes are an important part of the stacking strategy. All new spec changes must originate on `zach/warp-cli-core-foundation`, which is the authoritative source for `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs. After a spec change lands there, propagate it upward through every stacked branch with raw git so the spec files stay synchronized across `zach/warp-cli-readonly-metadata`, `zach/warp-cli-readonly-data-settings`, `zach/warp-cli-authenticated-scripting`, `zach/warp-cli-mutating-layout`, `zach/warp-cli-mutating-input-settings-surfaces`, `zach/warp-cli-mutating-files-drive-data`, and `zach/warp-cli-mutating-execution-underlying`. Do not make independent spec edits directly on higher implementation branches except as part of resolving a propagation conflict in a way that preserves the authoritative bottom-branch content. Recommended raw-git setup after `zach/warp-cli-core-foundation` is ready: ```bash git fetch origin git checkout -b zach/warp-cli-core-foundation origin/master git checkout -b zach/warp-cli-readonly-metadata git checkout -b zach/warp-cli-readonly-data-settings +git checkout -b zach/warp-cli-authenticated-scripting git checkout -b zach/warp-cli-mutating-layout git checkout -b zach/warp-cli-mutating-input-settings-surfaces +git checkout -b zach/warp-cli-mutating-files-drive-data +git checkout -b zach/warp-cli-mutating-execution-underlying ``` If a lower branch changes after higher branches exist, rebase each higher branch onto its immediate lower branch with raw git and resolve conflicts by preserving both the lower branch's stable API/permission model and the higher branch's owned behavior. ### Migrating from the earlier four-branch stack -The earlier four-branch stack (`zach/warp-cli-specs`, `zach/warp-cli`, `zach/warp-cli-readonly`, `zach/warp-cli-read-write`) should be treated as source material for the five-PR review stack, not as the final review structure. +The earlier four-branch stack (`zach/warp-cli-specs`, `zach/warp-cli`, `zach/warp-cli-readonly`, `zach/warp-cli-read-write`) should be treated as source material for the expanded eight-PR review stack, not as the final review structure. Recommended migration: 1. Create backup refs before rewriting or replacing anything: - `backup/warp-cli-specs` from `zach/warp-cli-specs`. @@ -359,9 +388,12 @@ Recommended migration: 2. Create `zach/warp-cli-core-foundation` from latest `origin/master` and bring over both the specs from `zach/warp-cli-specs` and only the foundation pieces from `zach/warp-cli`. Prefer path-level checkout followed by selective editing or `git add -p`; do not preserve every old commit if that makes review boundaries worse. 3. Create `zach/warp-cli-readonly-metadata` from `zach/warp-cli-core-foundation` and bring over only metadata-read pieces from `zach/warp-cli-readonly`. 4. Create `zach/warp-cli-readonly-data-settings` from `zach/warp-cli-readonly-metadata` and bring over the remaining read-only underlying-data, settings, docs, and skill pieces from `zach/warp-cli-readonly`. -5. Create `zach/warp-cli-mutating-layout` from `zach/warp-cli-readonly-data-settings` and bring over only layout/app-state mutations from `zach/warp-cli-read-write`. -6. Create `zach/warp-cli-mutating-input-settings-surfaces` from `zach/warp-cli-mutating-layout` and bring over the remaining approved mutating input/session/settings/surface pieces from `zach/warp-cli-read-write`. -7. Recompute incremental diff sizes, validate compilation/tests for each branch, push the new stack, and retire or close the old broad branches once the new review stack is accepted. +5. Create `zach/warp-cli-authenticated-scripting` from `zach/warp-cli-readonly-data-settings` and bring over or implement the verified terminal proof broker, external API-key scripting identity, authenticated-user grant plumbing, auth command surface, and related Settings > Scripting controls. +6. Create `zach/warp-cli-mutating-layout` from `zach/warp-cli-authenticated-scripting` and bring over only layout/app-state mutations from `zach/warp-cli-read-write` and its layout shards. +7. Create `zach/warp-cli-mutating-input-settings-surfaces` from `zach/warp-cli-mutating-layout` and bring over mutating input/session/settings/surface pieces from `zach/warp-cli-read-write`. +8. Create `zach/warp-cli-mutating-files-drive-data` from `zach/warp-cli-mutating-input-settings-surfaces` and bring over `zach/warp-cli-read-write-file-data` and `zach/warp-cli-read-write-drive-data` functionality. +9. Create `zach/warp-cli-mutating-execution-underlying` from `zach/warp-cli-mutating-files-drive-data` and bring over `zach/warp-cli-read-write-execution-underlying` functionality while keeping accepted-command and agent-prompt submission excluded. +10. Recompute incremental diff sizes, validate compilation/tests for each branch, push the new stack, and retire or close the old broad branches once the new review stack is accepted. Before redistributing feature work, prefer landing a mechanical module-split commit in `zach/warp-cli-core-foundation` so later branches do not all expand the same large files. The app-side target should be: - `app/src/local_control/mod.rs` for registration and top-level exports. - `app/src/local_control/bridge.rs` for the app request bridge. @@ -401,8 +433,11 @@ Keep PR boundaries aligned with the stack: - PR1: `zach/warp-cli-core-foundation` into `master` for the combined specs, shared protocol, CLI, settings, bridge, and module scaffolding. - PR2: `zach/warp-cli-readonly-metadata` into `zach/warp-cli-core-foundation` or its merged successor for metadata reads. - PR3: `zach/warp-cli-readonly-data-settings` into `zach/warp-cli-readonly-metadata` or its merged successor for underlying-data reads, settings reads, docs, and skill updates. -- PR4: `zach/warp-cli-mutating-layout` into `zach/warp-cli-readonly-data-settings` or its merged successor for app/window/tab/pane layout mutations. -- PR5: `zach/warp-cli-mutating-input-settings-surfaces` into `zach/warp-cli-mutating-layout` or its merged successor for input/session/settings/surface mutations. +- PR4: `zach/warp-cli-authenticated-scripting` into `zach/warp-cli-readonly-data-settings` or its merged successor for verified terminal proofs, external API-key scripting auth, and authenticated-user grants. +- PR5: `zach/warp-cli-mutating-layout` into `zach/warp-cli-authenticated-scripting` or its merged successor for app/window/tab/pane layout mutations. +- PR6: `zach/warp-cli-mutating-input-settings-surfaces` into `zach/warp-cli-mutating-layout` or its merged successor for input/session/settings/surface mutations. +- PR7: `zach/warp-cli-mutating-files-drive-data` into `zach/warp-cli-mutating-input-settings-surfaces` or its merged successor for authenticated file and Drive underlying-data mutations. +- PR8: `zach/warp-cli-mutating-execution-underlying` into `zach/warp-cli-mutating-files-drive-data` or its merged successor for authenticated execution-underlying actions. If a lower PR merges before higher branches are ready, rebase the next branch onto the merged successor of its base and continue upward through the stack. Use raw git for all rebases, conflict resolution, cherry-picks, and pushes. ## End-to-end flow ```mermaid @@ -446,6 +481,7 @@ Map tests directly to `PRODUCT.md` behavior. - Execution-context tests proving external clients cannot receive grants reserved for verified Warp-terminal invocations. - Permission-category enforcement tests proving insufficient grants fail with `insufficient_permissions` before selector resolution or handler dispatch, including separate denial cases for app-state mutation, metadata/configuration mutation, and underlying-data mutation. - Authenticated-user tests proving user-authenticated actions fail without a logged-in app user or authenticated-user grant. + - External API-key tests proving missing, invalid, expired, revoked, wrong-subject, and insufficient-scope keys fail before selector resolution or handler dispatch. - Settings > Scripting tests proving both top-level toggles and granular disabled categories invalidate credentials and prevent new grants. - Structured-error tests for disabled, unauthenticated, expired, revoked, insufficient-scope, execution-context-denied, authenticated-user-required, authenticated-user-unavailable, unsupported, malformed, ambiguous, missing-target, stale-target, and invalid-selector requests. - Behavior 1-6, 29-31: @@ -498,8 +534,10 @@ Wave 2: mutating fan-out: - Suggested shards: - `zach/warp-cli-shard/mutating-window-tab-pane` owns window/tab/pane layout mutations and feeds `zach/warp-cli-mutating-layout`. - `zach/warp-cli-shard/mutating-input-session` owns session activation/cycling/reopen, input insert/replace/clear, and input mode switching, then feeds `zach/warp-cli-mutating-input-settings-surfaces`. + - `zach/warp-cli-shard/authenticated-scripting` owns verified terminal proofs, external API-key auth, authenticated-user grants, and auth command tests, then feeds `zach/warp-cli-authenticated-scripting`. - `zach/warp-cli-shard/mutating-settings-surfaces` owns theme/font/zoom/setting mutations and settings/palette/panel/surface commands, then feeds `zach/warp-cli-mutating-input-settings-surfaces`. - - `zach/warp-cli-shard/mutating-files-drive` is optional and should be deferred unless the approved scope includes file/Drive app-state opens or future underlying-data mutations. + - `zach/warp-cli-shard/mutating-files-drive-data` owns file write/delete and Drive object data mutations, then feeds `zach/warp-cli-mutating-files-drive-data`. + - `zach/warp-cli-shard/mutating-execution-underlying` owns `input.run` and typed workflow execution, then feeds `zach/warp-cli-mutating-execution-underlying`. Each cloud shard prompt should include: - The exact base branch and shard branch name. - Owned command families. @@ -513,21 +551,30 @@ Default file ownership for shards: - Metadata shards own metadata handler/protocol/CLI modules and metadata tests. - Data shards own data handler/protocol/CLI modules and underlying-data permission tests. - Layout shards own layout handler/protocol/CLI modules and app-state mutation tests. -- Input/session shards own input/session handler/protocol/CLI modules and tests proving staging does not submit or execute. +- Authenticated-scripting shards own auth broker/protocol/CLI modules, Settings > Scripting authenticated grant controls, API-key storage/exchange tests, and authenticated-user denial tests. +- Input/session shards own input/session handler/protocol/CLI modules and tests proving staging does not submit or execute unless the branch explicitly owns `input.run`. - Settings/surface shards own settings/surface handler/protocol/CLI modules and metadata/configuration mutation tests. +- Files/Drive data shards own file and Drive underlying-data handler/protocol/CLI modules, authenticated-user/API-key enforcement tests, and disposable-resource tests. +- Execution-underlying shards own `input.run` and typed workflow execution handler/protocol/CLI modules, audit tests, and denial tests proving accepted-command and agent-prompt submission remain unavailable. The lead integrator merges or cherry-picks accepted shard work into the durable stack with raw git, in review order. Shard branches should not become independent long-lived PRs unless the lead intentionally splits review further; their default purpose is to feed the durable stack while preserving parallel implementation and focused context windows. ```mermaid flowchart LR Core["zach/warp-cli-core-foundation<br/>specs + contracts + bridge"] --> ROMeta["zach/warp-cli-readonly-metadata<br/>structural reads"] ROMeta --> ROData["zach/warp-cli-readonly-data-settings<br/>data/settings reads"] - ROData --> MutLayout["zach/warp-cli-mutating-layout<br/>layout mutations"] + ROData --> Auth["zach/warp-cli-authenticated-scripting<br/>terminal proof + API key auth"] + Auth --> MutLayout["zach/warp-cli-mutating-layout<br/>layout mutations"] MutLayout --> MutInput["zach/warp-cli-mutating-input-settings-surfaces<br/>input/settings/surfaces"] + MutInput --> MutData["zach/warp-cli-mutating-files-drive-data<br/>file + Drive data"] + MutData --> MutExec["zach/warp-cli-mutating-execution-underlying<br/>execution actions"] ROMetaShard["shard/readonly-metadata"] --> ROMeta RODataShard["shard/readonly-data"] --> ROData ROSettingsShard["shard/readonly-settings-docs"] --> ROData MutLayoutShard["shard/mutating-window-tab-pane"] --> MutLayout MutInputShard["shard/mutating-input-session"] --> MutInput MutSettingsShard["shard/mutating-settings-surfaces"] --> MutInput + AuthShard["shard/authenticated-scripting"] --> Auth + MutDataShard["shard/mutating-files-drive-data"] --> MutData + MutExecShard["shard/mutating-execution-underlying"] --> MutExec ``` ## Risks and mitigations - Fixed-port server assumptions: @@ -549,7 +596,7 @@ flowchart LR - Over-broad settings mutation: - Mitigation: allowlisted setting keys only, with private/debug/derived settings rejected. - Command execution risk: - - Mitigation: keep `input.run`/session execution in the catalog but require explicit follow-up product/review decision before broad rollout. + - Mitigation: keep `input.run`/typed workflow execution in the catalog but implement it only in `zach/warp-cli-mutating-execution-underlying` after authenticated scripting identity, underlying-data-mutation permission, deterministic target resolution, and audit coverage are in place. - Packaging churn due to provisional executable naming: - Mitigation: document `warpctrl` as provisional and settle final aliases before broad release workflow rollout. - Heavyweight CLI startup caused by sharing the GUI binary's launch path: From 97c82b294a3933d8fed1c2e284191aefbb89cb7f Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Mon, 25 May 2026 11:37:42 -0600 Subject: [PATCH 23/48] Refine warpctrl product scope Remove local file content operations from the warpctrl public catalog, narrow file/path support to app-state intents, and add the v0 Warp Drive sharing paths for opening the share dialog and sharing personal objects to the current team. Co-Authored-By: Oz <oz-agent@warp.dev> --- specs/warp-control-cli/PRODUCT.md | 77 +++++++++++++++++++----------- specs/warp-control-cli/SECURITY.md | 22 ++++----- specs/warp-control-cli/TECH.md | 52 ++++++++++---------- 3 files changed, 87 insertions(+), 64 deletions(-) diff --git a/specs/warp-control-cli/PRODUCT.md b/specs/warp-control-cli/PRODUCT.md index e3e7d2ca07..1052c7c38b 100644 --- a/specs/warp-control-cli/PRODUCT.md +++ b/specs/warp-control-cli/PRODUCT.md @@ -1,10 +1,11 @@ # Summary -Warp should ship an allowlisted standalone local control CLI binary, provisionally named `warpctrl`, that lets developers script the same classes of user-visible actions they can already perform inside the running app: manipulating windows, tabs, panes, sessions, terminal blocks, appearance, settings, Warp Drive views, and selected UI surfaces. The CLI should operate against one or more already-running local Warp app processes through a stable machine protocol, with deterministic target selection and clear errors when a process or target is ambiguous. +Warp should ship an allowlisted standalone local control CLI binary, provisionally named `warpctrl`, that acts as an agent control plane for operating Warp itself. `warpctrl` lets agents and developers script the same classes of user-visible actions they can already perform inside the running app: manipulating windows, tabs, panes, sessions, terminal blocks, appearance, settings, Warp Drive views, and selected UI surfaces. The CLI should operate against one or more already-running local Warp app processes through a stable machine protocol, with deterministic target selection and clear errors when a process or target is ambiguous. ## Problem -Warp already has rich interactive actions, but they are primarily reachable through UI, keybindings, menus, or deeplinks. Developers cannot reliably compose those same actions into shell scripts, demos, automation, or agent workflows, and there is no general local protocol for addressing a specific running Warp instance, window, pane, session, terminal block, Warp Drive object, or other uniquely named Warp entity. +Warp already has rich interactive actions, but they are primarily reachable through UI, keybindings, menus, or deeplinks. Agents can use native tools for files, code, shell commands, MCP calls, and many context reads, but they cannot reliably operate Warp's own product surfaces: arranging the user's workspace, focusing the correct pane, opening Warp Drive objects, presenting settings, or recovering from ambiguous UI state. Developers also cannot reliably compose those same actions into shell scripts, demos, automation, or agent workflows, and there is no general local protocol for addressing a specific running Warp instance, window, pane, session, terminal block, Warp Drive object, or other uniquely named Warp entity. ## Goals / Non-goals Goals: - Provide a first-class, scriptable standalone `warpctrl` binary for controlling running Warp app processes. +- Make Warp's own UI and app state available to agents through a typed, permissioned control plane instead of brittle screen automation or arbitrary internal dispatch. - Keep CLI startup lightweight by avoiding GUI-app startup or full terminal initialization for routine control commands. - Keep the surface allowlisted and finite instead of exposing arbitrary internal actions. - Make targeting explicit and deterministic across multiple Warp processes, windows, tabs, panes, terminal sessions, terminal blocks, Warp Drive objects, files, projects/workspaces, command surfaces, and other uniquely addressable Warp nouns. @@ -14,8 +15,18 @@ Non-goals: - Replacing the Oz CLI or mixing cloud-agent management into this CLI. - Exposing every internal app action, debug action, developer-only helper, or privileged state mutation. - Treating the CLI as a general RPC escape hatch into Warp internals. +- Replacing native agent tools for code editing, file operations, shell execution, web/MCP calls, or attached conversation/block context when those tools already solve the task better. - Requiring developers or automation to spawn the Warp GUI executable in CLI mode for ordinary control commands. - Requiring the first implementation slice to ship every action in the catalog. +## Primary user stories +These stories define the most compelling product uses for `warpctrl`. The command catalog below is intentionally broader, but the product should prioritize surfaces that agents cannot already operate well through native tools. +1. **Agent workspace orchestration.** When a user asks an agent to work on a task, the agent can inspect the current Warp state, create or reuse an appropriate window/tab layout, split panes, name and focus targets, open relevant Warp surfaces, and leave the workspace in a readable task-shaped state for the user. The agent should continue to use native tools for code edits, file reads/writes, shell execution, MCP calls, and other work that does not require operating Warp's UI or local-control permission model. +2. **Existing-session debugging and repair.** When a user asks for help with an existing Warp session, the agent can understand Warp-specific UI and session structure before acting: which instance/window/tab/pane/session is active, whether the relevant pane still exists, whether the correct surface is focused, which panels or settings pages are open, and which selector should be used for follow-up actions. The story should focus on UI/session structure, focus, panels, settings, and deterministic targeting; native agent context tools should remain the preferred way to read attached blocks, conversations, and other content when they are available. +3. **Warp Drive creation, navigation, and sharing.** When an agent notices reusable knowledge during normal work, it can help the user turn that knowledge into a Warp Drive object, open it for review, and guide sharing with the right scope. This includes workflows from repeated command sequences, notebooks from task writeups, prompts/rules/facts from user or project preferences, environment variable collections, MCP setup objects, folders, and spaces. Existing object navigation remains important, but creation and sharing are first-class because reusable team knowledge cannot be used until users are guided into creating it. +4. **Deterministic demos and walkthroughs.** When a user, teammate, or go-to-market workflow needs a reliable Warp demo, an agent or script can put Warp into a known presentation state: theme, zoom, windows, tabs, panes, focused targets, panels, command palette/search, and Warp Drive surfaces. The walkthrough can then advance using structured target IDs and recover from stale or missing targets instead of relying on screen coordinates, manual setup, or brittle UI automation. +5. **Personalization, onboarding, and preference migration.** When a user wants Warp to feel familiar, an agent can inspect user-approved settings from tools such as VS Code, iTerm, Ghostty, or shell configuration, propose Warp equivalents, apply allowlisted changes through `warpctrl`, and report unsupported mappings explicitly instead of guessing. The same flow can support team onboarding presets, presentation preferences, accessibility-related settings, themes, font and zoom, keybindings, notifications, and panels. +Human power-user scripting is a secondary beneficiary of the same design. Scripts get reliable JSON, target selectors, structured errors, and permission categories because the API is strong enough for agents, but the primary product narrative remains agent-led operation of Warp itself. +Persistent settings changes, Warp Drive creation or sharing, cross-app preference migration, terminal command execution, and other underlying-data mutations must be visibly reviewable or require stronger explicit permission than low-risk workspace organization. `warpctrl` should support full typed control over time, but each command must be progressively unlocked through action categories, target resolution, Agent Profile permissions, Scripting settings, and authenticated-user requirements rather than broad unchecked authority. ## Behavior 1. The Warp control CLI operates only on running local Warp app processes. If no compatible Warp process is available, the CLI exits non-zero with a clear “no running Warp instance found” error. 2. The CLI exposes only explicitly allowlisted actions. Unknown action names, unsupported parameter combinations, or requests for non-allowlisted capabilities fail with structured errors; they are never forwarded to arbitrary internal dispatch. @@ -64,14 +75,14 @@ Non-goals: - Pane selectors support `active`, opaque pane IDs, and pane indices scoped to the resolved tab or pane group. - Session selectors support `active`, opaque session IDs, and session indices scoped to the resolved pane when sessions are user-visible as an ordered list. - Block selectors support `active`, opaque block IDs, and block indices scoped to the resolved terminal session when blocks are user-visible as an ordered list. A block command may also support read-only filters such as command text, status, time range, or “last completed” for interactive lookup, but those filters must fail on ambiguity and resolve to concrete block IDs before reading output. - - File selectors use paths, plus optional line/column coordinates where the command supports opening or reading a location. + - File selectors use paths, plus optional line/column coordinates where the command supports opening a location. - Project/workspace selectors use paths, opaque project/workspace IDs when exposed by introspection, and exact names only as interactive convenience selectors. - Warp Drive selectors use opaque object IDs, with optional type-scoped exact name/path lookups for interactive use. Type scopes must include the user-facing object families Warp exposes today: spaces, folders, notebooks, workflows, agent-mode workflows/prompts, environment variable collections, AI facts/rules, MCP servers, MCP server collections, and trash entries when trash operations are supported. 11. “Active session” means the currently selected terminal session for the resolved pane/window context. If the selected target does not contain a terminal session, session-scoped actions fail rather than silently redirecting elsewhere. 12. When a command omits lower-level selectors, it resolves them from the chosen higher-level context using active defaults. Example: a pane split command with only `--instance` uses that instance’s active window, active tab, and active pane. 13. When an explicitly supplied target disappears between discovery and execution, the request fails with a stale-target error. The CLI must not silently choose a different tab, pane, or session. 14. The protocol is command-oriented, not open-ended state mutation. Each action has a named command, validated parameters, and defined target scope. -15. The complete allowlisted action catalog should be organized around stable public nouns rather than internal view/action names. The target taxonomy includes instances, windows, tabs, panes, terminal sessions, terminal blocks, input buffers, command history entries, files, projects/workspaces, Warp Drive spaces, folders, notebooks, workflows, agent-mode workflows/prompts, environment variable collections, AI facts/rules, MCP servers, MCP server collections, settings, themes, keybindings, command surfaces such as the command palette and command search, panels/surfaces such as Warp Drive, resource center, AI assistant, code review, left/right panels, and vertical tabs, plus action/capability metadata. The initial implementation may expose only a subset, but new command families should extend this noun taxonomy instead of inventing unrelated selector conventions. +15. The complete allowlisted action catalog should be organized around stable public nouns rather than internal view/action names. The target taxonomy includes instances, windows, tabs, panes, terminal sessions, terminal blocks, input buffers, command history entries, file/path intents, projects/workspaces, Warp Drive spaces, folders, notebooks, workflows, agent-mode workflows/prompts, environment variable collections, AI facts/rules, MCP servers, MCP server collections, settings, themes, keybindings, command surfaces such as the command palette and command search, panels/surfaces such as Warp Drive, resource center, AI assistant, code review, left/right panels, and vertical tabs, plus action/capability metadata. The initial implementation may expose only a subset, but new command families should extend this noun taxonomy instead of inventing unrelated selector conventions. 16. Discovery and read-only state actions: - List instances. - Get protocol/app version information for one instance. @@ -145,7 +156,7 @@ Non-goals: - Arbitrary internal view dispatch by string. - Arbitrary setting names outside the allowlist. - Accepted-command submission and agent-prompt submission until they receive a separate product/security review. - Terminal command execution, file writes/deletes, and typed Warp Drive object mutations are no longer excluded from the full product scope, but they belong to later authenticated underlying-data mutation branches and require stronger authenticated-user and permission gates than app-state or settings mutations. + Terminal command execution and typed Warp Drive object mutations are no longer excluded from the full product scope, but they belong to later authenticated underlying-data mutation branches and require stronger authenticated-user and permission gates than app-state or settings mutations. Local file content reads, writes, appends, deletes, and other filesystem-content mutations are excluded from the public `warpctrl` catalog; file/path support is limited to app-state intents such as opening a path in Warp and metadata reads of files already open in Warp. 27. CLI command names should be noun-oriented and discoverable. During the provisional standalone-binary phase, the control CLI should expose a `warpctrl ...` command surface: - `warpctrl instance list` - `warpctrl app active` @@ -191,11 +202,11 @@ The public `warpctrl` API is organized around nouns that map to stable user-faci ### State and data taxonomy The product surface must distinguish what kind of state a command touches. This distinction is part of the public API and the permission model, not just an implementation detail. - **Metadata reads** inspect app structure or configuration metadata without exposing user content: instances, windows, tabs, panes, sessions, capability metadata, action metadata, keybinding metadata, theme names, setting keys, current project identity, and other structural state. -- **Underlying data reads** expose user content or data-bearing state without changing it: terminal output, block contents, command history, input buffer contents, file contents, Warp Drive object contents, AI conversation content, and any other content that could contain user data or secrets. +- **Underlying data reads** expose user content or data-bearing state without changing it: terminal output, block contents, command history, input buffer contents, Warp Drive object contents, AI conversation content, and any other content that could contain user data or secrets. - **App-state mutations** change visible local Warp UI state without directly changing user data: opening or focusing windows, creating or closing tabs, splitting panes, focusing panes, opening panels, opening command surfaces, opening files in Warp, and editing the input buffer without submitting it. - **Metadata/configuration mutations** change persistent configuration or metadata, but not primary user content: changing themes, font size, zoom, allowlisted settings, keybindings, tab names, pane names, and tab colors. -- **Underlying data mutations** can change user data or cause external side effects: writing/creating/deleting files, typed CRUD operations on Warp Drive objects, inserting content into Warp Drive views, running allowlisted Warp Drive workflows, and running terminal commands through an explicit `input run` action. Accepted-command submission, agent-prompt submission, arbitrary workflow execution, and arbitrary internal dispatch remain excluded until separately reviewed. -A command that touches multiple categories must require the strongest applicable permission. For example, `file open` is an app-state mutation, while `file write` is an underlying data mutation; `input insert` is an app-state mutation, while `input run` is an underlying data mutation because it executes a command in the target session. +- **Underlying data mutations** can change user data or cause external side effects: typed CRUD operations on Warp Drive objects, sharing Warp Drive objects to the user's team through an explicit approved command, inserting content into Warp Drive views, running allowlisted Warp Drive workflows, and running terminal commands through an explicit `input run` action. Accepted-command submission, agent-prompt submission, local file content mutation, arbitrary workflow execution, and arbitrary internal dispatch remain excluded until separately reviewed. +A command that touches multiple categories must require the strongest applicable permission. For example, `file open` is an app-state mutation because it opens a visible Warp editor/view, while `input run` is an underlying data mutation because it executes a command in the target session. ### Targeting flags All commands that address a running app target accept the same selector flags where meaningful. Generic `--window`, `--tab`, `--pane`, `--session`, and `--block` flags accept the selector grammar below; explicit typed aliases are provided so scripts can avoid string parsing ambiguity: - `--instance <instance_id>` selects a running Warp process from `warpctrl instance list`. @@ -259,7 +270,7 @@ Authenticated read-only Warp Drive metadata and data reads, enabled only when th ### Authenticated scripting command set The full product requires two authenticated scripting modes before high-risk underlying-data mutations ship: - **Verified Warp-terminal authenticated scripting:** `warpctrl` runs inside a Warp-managed terminal, presents the app-issued terminal proof described in `TECH.md`, and may receive authenticated-user grants only when the selected app is logged into Warp and Settings > Scripting allows authenticated actions from verified Warp terminals. -- **External API-key authenticated scripting:** `warpctrl` runs outside Warp or in a pure automation environment and presents a Warp-issued API key or derived short-lived exchange token to the selected app's local broker. The broker verifies the key, scopes, expiry, and user subject before issuing local authenticated-user grants. This path is separate from the local-control bearer credential and is required for unattended scripts that need file, Drive, or execution authority. +- **External API-key authenticated scripting:** `warpctrl` runs outside Warp or in a pure automation environment and presents a Warp-issued API key or derived short-lived exchange token to the selected app's local broker. The broker verifies the key, scopes, expiry, and user subject before issuing local authenticated-user grants. This path is separate from the local-control bearer credential and is required for unattended scripts that need Drive or execution authority. Recommended CLI surface for API-key setup and inspection: - `warpctrl auth status [selectors]` reports local-control auth state, selected app login state, authenticated grant availability, and whether an external API-key identity is configured. - `warpctrl auth login [selectors]` focuses the selected Warp app's sign-in UI for interactive app-login flows. @@ -268,7 +279,7 @@ Recommended CLI surface for API-key setup and inspection: - `warpctrl auth api-key revoke [selectors]` deletes the local stored reference and, where supported, revokes the server-side key. The API-key path must support non-interactive scripts through an environment variable or secret manager reference, but raw keys must never be written to discovery records, logs, JSON output, shell completions, or repo config. ### Mutating command set -The mutating branches should build on the read-only and authenticated-scripting stack. `zach/warp-cli-mutating-layout` owns app/window/tab/pane layout mutations. `zach/warp-cli-mutating-input-settings-surfaces` owns input/session/settings/surface mutations. `zach/warp-cli-mutating-files-drive-data` owns file and Warp Drive underlying-data mutations. `zach/warp-cli-mutating-execution-underlying` owns terminal command execution and other approved execution-underlying actions. Mutating commands are split by what they mutate: app-state, metadata/configuration, or underlying data. Underlying data mutations require a separate and stronger permission plus an authenticated scripting identity. +The mutating branches should build on the read-only and authenticated-scripting stack. `zach/warp-cli-mutating-layout` owns app/window/tab/pane layout mutations. `zach/warp-cli-mutating-input-settings-surfaces` owns input/session/settings/surface mutations. `zach/warp-cli-mutating-drive-data` owns Warp Drive underlying-data mutations. `zach/warp-cli-mutating-execution-underlying` owns terminal command execution and other approved execution-underlying actions. Mutating commands are split by what they mutate: app-state, metadata/configuration, or underlying data. Underlying data mutations require a separate and stronger permission plus an authenticated scripting identity. App-state mutations for app, window, and surfaces: - `warpctrl app focus [selectors]` - `warpctrl window create [--shell <name>] [selectors]` @@ -341,19 +352,17 @@ App-state mutations for files, projects, and Warp Drive views: - `warpctrl drive open <id> [selectors]` - `warpctrl drive notebook open <id> [selectors]` - `warpctrl drive env-var-collection open <id> [selectors]` -Underlying data mutations for files and authenticated Warp Drive objects: -- `warpctrl file create <path> [--content <text>|--content-file <path>] [selectors]` -- `warpctrl file write <path> --content <text>|--content-file <path> [selectors]` -- `warpctrl file append <path> --content <text>|--content-file <path> [selectors]` -- `warpctrl file delete <path> [selectors]` +- `warpctrl drive object share open <id> [selectors]` +Underlying data mutations for authenticated Warp Drive objects: - `warpctrl drive object create --type <workflow|notebook|env-var-collection|prompt|folder> [--content <text>|--content-file <path>] [selectors]` - `warpctrl drive object update <id> [--content <text>|--content-file <path>] [selectors]` - `warpctrl drive object delete <id> [selectors]` - `warpctrl drive object insert <id> [--target <selector>] [selectors]` +- `warpctrl drive object share-to-team <id> [selectors]` - `warpctrl drive workflow run <id> [--arg <name=value>...] [selectors]` Execution-underlying actions: - `warpctrl input run <command> [--session <selector>] [selectors]` -These are underlying-data mutations because they can modify user data, execute code, trigger external side effects, or run user-authored content. They require authenticated scripting identity, underlying-data-mutation permission, deterministic target resolution, audit records, and explicit tests proving lower-permission credentials cannot run them. Accepted-command submission and agent-prompt submission remain excluded until separately reviewed. +These are underlying-data mutations because they can modify user data, execute code, trigger external side effects, share cloud-backed content, or run user-authored content. They require authenticated scripting identity, underlying-data-mutation permission, deterministic target resolution, audit records, and explicit tests proving lower-permission credentials cannot run them. `drive object share-to-team` is the only direct sharing mutation in the v0 product scope: it may make a personal Warp Drive object available to the user's current team using the app's standard team-sharing semantics. Arbitrary ACL editing, sharing with specific users, sharing with external guests, public-link creation, accepted-command submission, and agent-prompt submission remain excluded until separately reviewed. ### Excluded from the public command surface The command surface must continue to exclude debug-only, crash-only, auth-token, heap-dump, and arbitrary internal dispatch actions even when those actions are available in command palette or keybinding registries. Examples that remain excluded are app crash/panic helpers, access-token copy helpers, heap profile dumps, debug reset actions, raw view-tree debugging, and broad internal action-by-string execution. ## Branch stacking and delivery model @@ -364,8 +373,8 @@ The Warp Control CLI work should ship as a raw-git branch stack so the combined - `zach/warp-cli-authenticated-scripting` stacks on `zach/warp-cli-readonly-data-settings` and implements authenticated-user grant plumbing for both verified Warp-terminal invocations and external API-key scripting identities. It does not broaden action support by itself; it makes later high-risk branches enforceable. - `zach/warp-cli-mutating-layout` stacks on `zach/warp-cli-authenticated-scripting` and implements app/window/tab/pane layout mutations. - `zach/warp-cli-mutating-input-settings-surfaces` stacks on `zach/warp-cli-mutating-layout` and fills in approved input/session/settings/surface mutating command families while preserving the prohibition on accepted-command submission and agent-prompt submission. -- `zach/warp-cli-mutating-files-drive-data` stacks on `zach/warp-cli-mutating-input-settings-surfaces` and implements authenticated file and Warp Drive underlying-data mutations from the approved allowlist. -- `zach/warp-cli-mutating-execution-underlying` stacks on `zach/warp-cli-mutating-files-drive-data` and implements authenticated execution-underlying actions such as `input run` and typed workflow execution where supported. +- `zach/warp-cli-mutating-drive-data` stacks on `zach/warp-cli-mutating-input-settings-surfaces` and implements authenticated Warp Drive underlying-data mutations from the approved allowlist, including object creation/update/delete/insert and the v0 personal-to-team sharing path. +- `zach/warp-cli-mutating-execution-underlying` stacks on `zach/warp-cli-mutating-drive-data` and implements authenticated execution-underlying actions such as `input run` and typed workflow execution where supported. The previous `zach/warp-cli-specs` branch is retained only as migration-source/history material. New spec changes originate on `zach/warp-cli-core-foundation` and are propagated upward through the stack with raw git so all higher implementation branches reflect the same product/security contract. Graphite is not part of this stack. If a lower branch merges first, higher branches should rebase onto the merged successor while preserving the approved spec content. ## Built-in Warp Agent skill Warp should include a built-in Agent skill for `warpctrl`, analogous to the bundled `oz-platform` skill. The skill should teach Warp Agent when to use `warpctrl`, how to discover and target running instances, how to prefer read-only commands before mutating commands, how to request explicit user approval for underlying data mutations, and how to interpret structured errors. The skill should document the stable command hierarchy above, include concise recipes for common automation tasks, and avoid instructing agents to bypass the CLI by calling local-control HTTP endpoints directly. @@ -404,7 +413,7 @@ Every action in the catalog belongs to exactly one of the following permission c - Metadata reads: `theme list`, `setting list`, `keybinding list`, `action list`, `project active`, and Drive object listing that returns object IDs/names/types but not content. 2. **Read-only / underlying data.** Actions that return user content or data-bearing state without changing it. - Terminal reads: block output, scrollback, command history, input editor contents, session replay, or terminal-derived traces. - - File reads, Warp Drive object content reads, AI conversation reads, and any authenticated-user data read. + - Warp Drive object content reads, AI conversation reads, and any authenticated-user data read. This category is separate from metadata because read-only content can contain secrets, source code, customer data, command output, or other sensitive data. 3. **Mutating / app state.** Actions that change visible local Warp UI state without directly changing underlying user data. - Layout and focus: `window create`, `window focus`, `tab create`, `tab activate`, `tab move`, `window close`, `tab close`, `pane split`, `pane focus`, `pane navigate`, `pane maximize`, `pane resize`, and panel/surface toggles. @@ -415,11 +424,10 @@ Every action in the catalog belongs to exactly one of the following permission c Metadata/configuration writes need a stronger permission than app-state-only changes because they persist beyond the current UI interaction, but they are still distinct from data writes. 5. **Mutating / underlying data.** Actions that can change user data, execute code, submit prompts, or cause external side effects. - Terminal execution through the explicit `input run` action and typed workflow execution where supported. - - File writes: create, write, append, delete, rename, or otherwise modify local files. - - Warp Drive CRUD: create, update, delete, insert, run, or otherwise mutate workflows, notebooks, prompts, env-var collections, folders, or other Drive objects. + - Warp Drive CRUD and sharing: create, update, delete, insert, share to the user's current team, run, or otherwise mutate workflows, notebooks, prompts, env-var collections, folders, or other Drive objects. - AI conversation history mutation and any action that modifies cloud-backed user content. - Future agent execution: submitting an agent prompt, accepting an agent-proposed command, or otherwise causing an agent to act; these remain excluded until separately reviewed. - This category must be explicitly separate from app-state mutation and requires authenticated scripting identity. A client allowed to open or focus Warp UI must not automatically be allowed to execute commands, write files, or mutate Warp Drive content. + This category must be explicitly separate from app-state mutation and requires authenticated scripting identity. A client allowed to open or focus Warp UI must not automatically be allowed to execute commands, mutate Warp Drive content, or perform local file content operations. ### Authenticated-user requirement An authenticated user means a true logged-in Warp user in the selected Warp app, not merely the local OS user or a `warpctrl` process authenticated to localhost. The allowlist must clearly indicate `requires_authenticated_user` for every action: @@ -456,6 +464,16 @@ In the long-term model, the Scripting settings page should expose granular permi - Allow authenticated-user actions from verified Warp terminals. - Allow authenticated-user actions from external clients, default off and separate from the in-Warp permission. These settings define the maximum grants the broker may issue. The app bridge still enforces the action's risk posture, state/data category, authenticated-user requirement, execution-context requirement, and target scope for every request. Enabling app-state mutation must not imply permission to mutate underlying data. +### Agent Profile permissions +Agent Profiles should expose a dedicated **Warp control** permission group for agents that can invoke `warpctrl`. This permission group should mirror the local-control action categories so users can choose different `warpctrl` authority for different agent workflows: +- Metadata reads. +- Underlying data reads. +- App-state mutations. +- Metadata/configuration mutations. +- Underlying data mutations. +Each category should support the same autonomy vocabulary used by other Agent Profile permissions: the agent may be allowed to proceed, required to ask, allowed to decide based on confidence and risk, or denied. A cautious profile can therefore allow metadata reads and ask for app-state mutations, while a demo or onboarding profile can be explicitly configured to allow workspace organization or presentation setup. +Agent Profile permissions and global Scripting settings both apply. Settings > Scripting defines the maximum local-control authority available for an execution context, such as verified inside-Warp invocation or outside-Warp invocation. The selected Agent Profile defines what that specific agent is allowed to request within that maximum. If either layer denies an action category, authenticated-user requirement, or execution context, the request fails with a structured permission error instead of falling back to a weaker action or a raw `warpctrl` shell command. +The profile-level permission group should preserve the native-tools-first boundary. Agents should prefer native tools for code editing, file reads/writes, shell command execution, web/MCP calls, and attached conversation or block context when those tools are available. Agents should prefer `warpctrl` when the task requires operating Warp product surfaces, preserving visible UI context for the user, using Warp Drive as a first-class app surface, or applying the app's own permissioned control plane. ### Scoped credentials The local discovery record must not expose a reusable full-access credential. `warpctrl` should request scoped credentials from an app-owned broker or equivalent trusted path. Scoped credentials should include: @@ -469,31 +487,32 @@ Scoped credentials should include: - revocation/audit identity. The bridge, not the CLI frontend, enforces these grants. If a request exceeds its credential, the bridge returns `insufficient_permissions`, `authenticated_user_required`, `authenticated_user_unavailable`, or `execution_context_not_allowed` as appropriate. ### Future entity extensibility: files, blocks, and Warp Drive objects -The selector and action model should accommodate entity types beyond the current window/tab/pane/session hierarchy. Important entity families are **terminal blocks**, **local files**, **projects/workspaces**, and **Warp Drive objects**. Broad file/Drive mutation and command execution are not in scope for the foundation branch, but they are in scope for later authenticated branches in the expanded stack. Agent-prompt submission remains excluded until separately reviewed. +The selector and action model should accommodate entity types beyond the current window/tab/pane/session hierarchy. Important entity families are **terminal blocks**, **file/path intents**, **projects/workspaces**, and **Warp Drive objects**. Broad Drive mutation and command execution are not in scope for the foundation branch, but they are in scope for later authenticated branches in the expanded stack. Local file content reads, writes, appends, deletes, and other filesystem-content mutations are intentionally out of scope for the public `warpctrl` catalog because native agent file tools are the preferred surface for file content operations. Agent-prompt submission remains excluded until separately reviewed. **Terminal blocks.** Blocks are first-class targetable terminal entities, not just data hanging off a session. Block selectors should support the same addressing primitives as terminal sessions where meaningful: active/current block, opaque block ID, and block index scoped to the resolved session. Block reads can expose command text, output, status, timing, exit code, and metadata, so block content reads are underlying-data reads while block listing that returns only IDs/status/timestamps may be metadata reads. Stale, missing, or ambiguous block selectors must fail rather than selecting a neighboring block. -**Files.** Warp already supports file opening via deep links and the built-in editor. A future `file` namespace could support: +**Files.** Warp already supports file opening via deep links and the built-in editor. The `file` namespace is limited to app-state and metadata behaviors that operate Warp's visible UI: - `warpctrl file open <path>` — app-state mutation that opens a file in a Warp editor tab, equivalent to clicking a file link. - `warpctrl file open <path> --line <n>` — app-state mutation that opens at a specific line. - `warpctrl file list` — metadata read that lists files currently open in editor tabs across the instance. -- `warpctrl file read <path>` — underlying data read that returns file contents. -- `warpctrl file create|write|append|delete <path>` — underlying data mutations that modify the filesystem. -File selectors would use filesystem paths (absolute or relative to the working directory of the target pane/session). Unlike window/tab/pane selectors, file selectors are not opaque IDs — they are user-visible paths. The protocol should support a `file` field in the target selector that accepts a path string, distinct from the opaque ID selectors used for windows, tabs, and panes. +- `warpctrl project open <path>` — app-state mutation that opens or focuses a project/workspace in Warp where that matches existing user-visible behavior. +File selectors use filesystem paths (absolute or relative to the working directory of the target pane/session when the command defines that behavior). Unlike window/tab/pane selectors, file selectors are not opaque IDs — they are user-visible paths. The protocol should support a `file` field in the target selector that accepts a path string, distinct from the opaque ID selectors used for windows, tabs, and panes. `warpctrl` must not expose file content reads or filesystem-content mutations; agents and scripts should use native file tools for those operations. **Warp Drive objects.** Warp Drive stores typed objects that users can reference, execute, edit, and share. The object taxonomy should include, at minimum, spaces, folders, notebooks, workflows, agent-mode workflows/prompts, environment variable collections, AI facts/rules, MCP servers, MCP server collections, and trash entries where trash operations are exposed. A future `drive` namespace could support: - `warpctrl drive list --type workflow` — authenticated metadata read that lists Warp Drive objects by type. - `warpctrl drive inspect <id>` — authenticated underlying data read when it returns object content. - `warpctrl drive workflow run <workflow-id>` — authenticated underlying data mutation that executes a typed workflow in a target session, implemented only in the execution-underlying branch with authenticated scripting identity and audit coverage. - `warpctrl drive object create|update|trash|restore <id>` — authenticated underlying data mutations that change cloud-backed user content. +- `warpctrl drive object share open <id>` — app-state mutation that opens the sharing dialog for user review without changing sharing state. +- `warpctrl drive object share-to-team <id>` — authenticated underlying data mutation that makes a personal object available to the user's current team using the app's standard team-sharing behavior. This is the only direct sharing mutation in the v0 product scope. - `warpctrl drive notebook open <notebook-id>` — app-state mutation that opens a view of an existing notebook without modifying it. Drive object selectors should support both opaque IDs (for automation stability) and human-friendly name/path lookups (for interactive use). The type field (`workflow`, `notebook`, `env_var_collection`, `prompt`, `folder`, `ai_fact`, `mcp_server`, etc.) acts as a namespace filter. Drive actions that execute content in a terminal session (e.g., running a workflow) inherit the underlying-data-mutation permission from the action classification model and are implemented only in the execution-underlying branch after authenticated scripting identity and audit coverage are in place. **Design constraints for these future entity families:** - File and Drive selectors are orthogonal to the window/tab/pane hierarchy — a file open action targets an instance (which window to open in), not a specific pane. A Drive workflow execution targets a session (which pane to run in). - The `TargetSelector` type in the protocol should be extensible with optional fields for these new selector families without breaking existing requests that omit them. -- The action classification categories apply, and Drive actions require authenticated-user grants by default: listing Drive objects is metadata plus authenticated user, reading Drive object content is underlying-data-read plus authenticated user, opening an existing Drive object in the app is app-state mutation plus authenticated user, and executing or changing a Drive object is underlying-data-mutation plus authenticated user. +- The action classification categories apply, and Drive actions require authenticated-user grants by default: listing Drive objects is metadata plus authenticated user, reading Drive object content is underlying-data-read plus authenticated user, opening an existing Drive object or its sharing dialog in the app is app-state mutation plus authenticated user, and executing, sharing, or changing a Drive object is underlying-data-mutation plus authenticated user. ### Settings: protocol-first Settings reads and writes should go through the local-control protocol like other actions, not bypass it via direct file manipulation. - `warpctrl setting get <key>`, `warpctrl setting set <key> <value>`, and `warpctrl setting toggle <key>` send requests to the running Warp instance through the standard authenticated control endpoint. - The app bridge validates the key against the allowlist and the value against the expected type before applying the change. - This keeps authorization enforcement consistent: the same permission category, execution-context, and authenticated-user policies apply to settings mutations as to any other action, rather than creating a second unguarded path through the filesystem. -- The app owns the write to the settings file and any side effects (e.g., theme reload, layout reflow) as a single atomic operation, avoiding races between a CLI file write and the app's file watcher. +- The app owns the write to the settings file and any side effects (e.g., theme reload, layout reflow) as a single atomic operation, avoiding races between a direct settings-file edit and the app's file watcher. - If a future need arises for offline settings manipulation (no running Warp process), a separate file-based path can be added later with its own validation, but it should not be the default. - The action classification still applies: settings reads are metadata reads, and settings writes are metadata/configuration mutations. Settings writes must not be authorized by app-state mutation permission alone. diff --git a/specs/warp-control-cli/SECURITY.md b/specs/warp-control-cli/SECURITY.md index 4383dd951e..8922516064 100644 --- a/specs/warp-control-cli/SECURITY.md +++ b/specs/warp-control-cli/SECURITY.md @@ -1,8 +1,8 @@ # warpctrl security architecture -`warpctrl` is a local-control CLI for an already-running Warp app instance. Its security architecture is designed to support the control catalog: discovery, structural metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, underlying data mutations, input-buffer staging, file operations, Warp Drive operations, and explicitly authenticated execution-underlying actions. Accepted-command submission and agent-prompt submission remain future high-risk capabilities that require separate product/security review, but typed terminal command execution (`input.run`) and typed Warp Drive workflow execution are in scope for later authenticated underlying-data mutation branches. +`warpctrl` is a local-control CLI for an already-running Warp app instance. Its security architecture is designed to support the control catalog: discovery, structural metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, underlying data mutations, input-buffer staging, file/path app-state intents, Warp Drive operations, and explicitly authenticated execution-underlying actions. Accepted-command submission and agent-prompt submission remain future high-risk capabilities that require separate product/security review. Local file content operations are intentionally excluded from the public `warpctrl` catalog because native agent file tools are the preferred surface for file content reads and writes. The correct architecture is not a single shared localhost bearer token with client-side conventions. The CLI, app bridge, and protocol must treat security as a local app-enforced capability system: discovery finds compatible instances, secure storage protects raw credential material, broker-issued credentials identify the granted scopes, the running Warp app's local-control bridge enforces action categories before dispatch, and target resolution never silently retargets a request. The action-category model is primarily a safety and intent mechanism, not a hard security boundary against malicious same-user software. It lets a user, script, or agent intentionally request metadata-only, data-read, app-state mutation, metadata/configuration mutation, or underlying-data mutation access so it does not accidentally mutate state, expose sensitive content, or execute commands. It should not be described as strong access control against a process that can already run arbitrary commands as the user. -`warpctrl` has two distinct authorization dimensions: local-control authority and authenticated scripting authority. Local-control authority proves the request is allowed to control the local app. Authenticated scripting authority proves the Warp user or automation identity that is allowed to act on user-authenticated data such as Warp Drive objects, AI conversation traces, synced settings, cloud-backed user state, file mutations, and execution-underlying actions. Logged-out users should retain a smaller local-only control surface, but authenticated-user and high-risk underlying-data mutation actions require either a verified Warp-terminal grant tied to the selected app's logged-in user or an external Warp-issued API-key grant. +`warpctrl` has two distinct authorization dimensions: local-control authority and authenticated scripting authority. Local-control authority proves the request is allowed to control the local app. Authenticated scripting authority proves the Warp user or automation identity that is allowed to act on user-authenticated data such as Warp Drive objects, AI conversation traces, synced settings, cloud-backed user state, and execution-underlying actions. Logged-out users should retain a smaller local-only control surface, but authenticated-user and high-risk underlying-data mutation actions require either a verified Warp-terminal grant tied to the selected app's logged-in user or an external Warp-issued API-key grant. ## Current foundation status The current foundation implementation supports outside-Warp local-control requests only. Verified inside-Warp invocation is specified as future work because the app-issued terminal-session proof broker, proof injection path, and session registry do not exist yet. Until those pieces land, `InvocationContext::InsideWarp` requests must be rejected with `execution_context_not_allowed`, implemented action metadata must not advertise inside-Warp support, and Settings > Scripting must not expose inside-Warp enablement or permission toggles. ## Security goals @@ -23,7 +23,7 @@ The current foundation implementation supports outside-Warp local-control reques - Classify every action by whether it requires an authenticated Warp user. New actions should default to requiring an authenticated user unless they are deliberately reviewed as safe for logged-out or external use. - Prevent `warpctrl` from becoming an ambient full-power confused deputy that any same-user process can invoke for high-risk actions. - Require authenticated scripting identity, underlying-data-mutation permission, explicit target resolution, and audit records before terminal command execution or typed workflow execution can ship; accepted-command submission and agent-prompt submission remain prohibited until separately reviewed. -- Preserve deterministic targeting so a request never silently mutates or reads the wrong window, tab, pane, session, file, or Warp Drive object. +- Preserve deterministic targeting so a request never silently mutates or reads the wrong window, tab, pane, session, file/path intent, or Warp Drive object. - Keep the action surface allowlisted and typed rather than exposing arbitrary internal app dispatch. - Make high-risk operations auditable and configurable without logging sensitive terminal contents or credentials. ## Meaningful security boundaries @@ -117,10 +117,10 @@ These enablement gates do not create perfect same-user malicious-app isolation. ### Granular permission settings Once the relevant inside-Warp or outside-Warp enablement setting allows a request context, users should control which categories of `warpctrl` authority can be granted. These permissions should appear under Settings > Scripting. Recommended independent permissions: - **Metadata reads:** permit external and in-Warp clients to inspect non-sensitive local app structure and configuration metadata such as instances, windows, tabs, panes, app version, theme names, setting keys, action metadata, and Drive object IDs/names/types without content. -- **Underlying data reads:** permit reads of terminal output, scrollback, input buffers, command history, session traces, file contents, Warp Drive object contents, AI conversation content, and other content-bearing state. +- **Underlying data reads:** permit reads of terminal output, scrollback, input buffers, command history, session traces, Warp Drive object contents, AI conversation content, and other content-bearing state. - **App-state mutations:** permit local UI/layout/focus changes such as opening windows, creating tabs, closing tabs, focusing panes, splitting panes, opening panels, opening files/projects/views, and staging text in the input buffer without executing it. - **Metadata/configuration mutations:** permit persistent metadata or configuration changes such as tab/pane names, tab colors, themes, font size, zoom, allowlisted settings, and keybindings. -- **Underlying data mutations:** permit file create/write/append/delete, Warp Drive object CRUD, AI conversation mutations, and any other action that can change user data or cause external side effects. Terminal command execution, Warp Drive workflow execution, accepted-command submission, and agent-prompt submission belong in this category if they are added later, but they are not allowed in the initial public implementation. +- **Underlying data mutations:** permit Warp Drive object CRUD and personal-to-team sharing, AI conversation mutations, and any other allowlisted action that can change user data or cause external side effects. Terminal command execution and Warp Drive workflow execution belong in this category when their later authenticated branches implement them. Accepted-command submission and agent-prompt submission remain unavailable until separately reviewed. Local file content operations are intentionally excluded from the public `warpctrl` catalog and should use native file tools instead. - **Authenticated-user actions from Warp terminals:** permit `warpctrl` invocations that originate from a verified Warp-managed terminal session to receive grants backed by the currently logged-in Warp user, enabling actions that read or mutate Warp Drive, AI conversation traces, synced settings, or other user-authenticated state. - **Authenticated-user actions from external clients:** default off. If supported, this must be a separate explicit permission from in-Warp authenticated actions because external same-user processes are a weaker context than a Warp-managed terminal session. Granular permissions should be independently configurable for inside-Warp and outside-Warp contexts where the distinction matters. Disabling any category should invalidate active credentials that include that category. The broker and app bridge must enforce these settings locally for every credential request and every presented credential. App-state mutation permission must not imply metadata/configuration mutation or underlying data mutation permission. @@ -136,7 +136,7 @@ These scoped credentials are guardrails for well-behaved clients. They prevent a Acceptable designs include a short-lived per-session capability, an app-owned broker handshake tied to the terminal session, or an equivalent proof that arbitrary external processes cannot mint by setting an environment variable. Plain environment variables may be used as handles or hints, but they must not be the sole authority for in-Warp privileges because external processes can spoof them. Verified in-Warp context can raise the maximum eligible grant set, especially for authenticated-user actions. It does not by itself bypass the user's granular local-control settings, action categories, target scopes, or logged-in-user requirements. ### Authenticated scripting boundary -Actions that touch user-authenticated Warp data or perform high-risk underlying-data mutations require authenticated scripting authority. This includes Warp Drive object contents or mutation, AI conversation traces, cloud-backed user settings, team/account data, file writes/deletes, typed workflow execution, terminal command execution, and any other surface whose normal app access depends on the user's Warp account or can cause external side effects. +Actions that touch user-authenticated Warp data or perform high-risk underlying-data mutations require authenticated scripting authority. This includes Warp Drive object contents, object mutation, the v0 personal-to-team sharing path, AI conversation traces, cloud-backed user settings, team/account data, typed workflow execution, terminal command execution, and any other surface whose normal app access depends on the user's Warp account or can cause external side effects. There are two supported authenticated scripting modes: - **Verified Warp-terminal mode:** `warpctrl` presents an app-issued terminal-session proof. If the selected app is logged into Warp and Settings > Scripting permits authenticated actions from verified Warp terminals, the broker may mint an authenticated-user grant tied to the selected app's current user subject. - **External API-key mode:** `warpctrl` presents a Warp-issued scripting API key or a short-lived token exchanged from that key. If outside-Warp scripting and external authenticated grants are enabled, the broker verifies the key, scopes, expiry, revocation state, and user subject before minting a local authenticated-user grant. @@ -289,7 +289,7 @@ The broker must not issue broad authority merely because the request came from t The category system should be understood as a user-intent and accident-prevention mechanism: - A user can ask an agent or script to operate with metadata-read grants so it can inspect structure but cannot read terminal content or mutate state. - A workflow can request underlying-data reads separately from structural metadata reads because terminal output, files, Drive object content, and AI conversations can contain sensitive data. -- A script can request app-state mutation without also receiving permission to change persistent settings, execute commands, write files, or mutate Warp Drive objects. +- A script can request app-state mutation without also receiving permission to change persistent settings, execute commands, mutate Warp Drive objects, or perform local file content operations. - Metadata/configuration mutations can be allowed without granting underlying data mutation. - Underlying data mutations can require explicit approval or configured policy so surprising operations pause before they execute commands or change user data. This model does not make untrusted same-user software safe. A malicious local process may invoke `warpctrl`, simulate user workflows, or use other OS-level capabilities outside `warpctrl`. The category model is still valuable because it lets honest clients, agents, and scripts constrain themselves and gives Warp a structured point to prompt, deny, or audit risky actions. @@ -367,7 +367,6 @@ Default unattended credentials may include this category. Return user content or data-bearing state without mutating state. Examples: - pane output, scrollback, current input buffer, command history, session replay, or transcript reads; -- file content reads; - Warp Drive object content reads; - AI conversation content reads. This category is separate from metadata because content often contains secrets, source code, file paths, command output, customer data, and other sensitive information. @@ -384,16 +383,16 @@ Examples: - renaming tabs or panes; - changing tab colors; - theme, font, zoom, keybinding, and allowlisted settings writes. -This category should not authorize terminal command execution, file writes, or Warp Drive CRUD. +This category should not authorize terminal command execution, Warp Drive CRUD, Warp Drive sharing, or local file content operations. ### Underlying data mutations Can change user data, execute code, submit prompts, or cause external side effects. Examples: - terminal command execution through the explicit `input.run` action; - typed Warp Drive workflow execution or other approved user-authored runnable content; -- file create/write/append/delete operations; - Warp Drive object create/update/delete/insert operations; +- Warp Drive object sharing, limited in v0 to making a personal object available to the user's current team through an explicit `share-to-team` command; - AI conversation history mutation or other cloud-backed content mutation. -This category requires authenticated scripting identity plus explicit user or policy approval for unattended automation and integrations. It must remain separate from app-state mutation so a client that can open or focus Warp UI cannot automatically execute commands, submit prompts, write files, or mutate Warp Drive content. Accepted-command submission and agent-prompt submission remain unavailable until separately reviewed even if future protocol names are reserved for them. +This category requires authenticated scripting identity plus explicit user or policy approval for unattended automation and integrations. It must remain separate from app-state mutation so a client that can open or focus Warp UI cannot automatically execute commands, submit prompts, mutate Warp Drive content, share Drive objects, or perform local file content operations. Accepted-command submission and agent-prompt submission remain unavailable until separately reviewed even if future protocol names are reserved for them. ## Target scoping and deterministic resolution Targeting is part of security. The protocol must not convert ambiguous or stale selectors into best-effort mutations. Rules: @@ -486,6 +485,7 @@ Before shipping each action family, verify that these controls are implemented f - Logs and errors do not expose credentials, terminal contents, command text, or sensitive settings. - Operator docs distinguish available commands from planned catalog entries. - Initial public action-family docs and tests prove terminal command execution, workflow execution, accepted-command submission, and agent-prompt submission are not allowlisted; input-buffer staging never submits the buffer. +- Initial public action-family docs and tests prove local file content reads, writes, appends, deletes, and filesystem-content mutations are not allowlisted; file/path support is limited to opening visible Warp UI surfaces and listing files already open in Warp. ## Platform requirements ### macOS and Linux Discovery files must be stored in a per-user directory with owner-only permissions. diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index 869269b806..311ff9a29c 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -146,12 +146,12 @@ Minimum implementable design: - The minted credential records `invocation_context: InsideWarp`, the granted permission category, expiry, instance, and any authenticated-user subject. The app bridge revalidates the grant and current app policy on every control request. Hardened follow-ups can strengthen this minimum design by storing secret material in platform secure storage, exposing only opaque handles through the shell, using Unix-domain-socket or named-pipe peer-credential checks, binding proofs to broker challenges, and invalidating proofs on shell/session teardown, app logout, user switch, or Settings > Scripting changes. These hardening layers improve direct-token theft resistance, but they do not create a perfect security boundary against malicious same-user software with process, filesystem, Accessibility, or screen-observation access. ### 5. Authenticated scripting identity and API-key grants -The full control catalog includes file writes, Warp Drive data mutation, and execution-underlying actions. Those actions require an authenticated scripting layer in addition to local-control credentials. Local-control credentials prove authority to call the local app; authenticated scripting credentials prove the Warp user or automation identity allowed to request user-backed or high-risk actions. +The full control catalog includes Warp Drive data mutation and execution-underlying actions. Those actions require an authenticated scripting layer in addition to local-control credentials. Local-control credentials prove authority to call the local app; authenticated scripting credentials prove the Warp user or automation identity allowed to request user-backed or high-risk actions. #### Inside-Warp authenticated scripting For `warpctrl` launched inside a Warp-managed terminal, use the verified terminal proof broker from the previous section. When the proof is valid and the selected app is logged into Warp, the broker may mint an authenticated-user grant bound to the app's current user subject. The grant is available only if Settings > Scripting enables authenticated-user actions for verified Warp terminals and the requested action's permission category is enabled. The CLI must not receive raw Firebase, OAuth, server, or session tokens. The app bridge executes authenticated actions through the selected app's existing auth state and rejects the grant if the app logs out, switches users, or the grant subject no longer matches the app user. #### External API-key authenticated scripting -For `warpctrl` launched outside Warp, by cron, or by another pure scripting environment, introduce a separate API-key path. The user creates or supplies a Warp-issued scripting API key with explicit scopes such as local-control authenticated reads, file mutation, Drive mutation, or execution-underlying actions. The CLI may reference the key from a secret manager or environment variable such as `WARPCTRL_API_KEY`, or store it in platform secure storage through `warpctrl auth api-key set --key-stdin`; it must never print or write the raw key to discovery records, logs, JSON output, shell completions, or repo config. +For `warpctrl` launched outside Warp, by cron, or by another pure scripting environment, introduce a separate API-key path. The user creates or supplies a Warp-issued scripting API key with explicit scopes such as local-control authenticated reads, Drive mutation, or execution-underlying actions. The CLI may reference the key from a secret manager or environment variable such as `WARPCTRL_API_KEY`, or store it in platform secure storage through `warpctrl auth api-key set --key-stdin`; it must never print or write the raw key to discovery records, logs, JSON output, shell completions, or repo config. The local broker exchanges or validates the API key with Warp services, obtains a short-lived signed identity assertion, and mints a local authenticated-user grant only when all of the following hold: - outside-Warp scripting is enabled; - external authenticated-user grants are enabled separately from logged-out outside-Warp control; @@ -277,6 +277,10 @@ Recommended modules/families: - theme list/set, system-theme controls, font/zoom actions, allowlisted settings reads/writes/toggles. - Panels/surfaces: - settings/page/search, palettes, left/right panels, Drive, resource center, code review, vertical tabs, AI assistant. +- Files/projects: + - app-state-only path opening, project opening, and metadata reads for files already open in Warp. File content reads and filesystem-content mutations are intentionally excluded from the public `warpctrl` catalog. +- Warp Drive: + - object listing/inspection/opening, object creation/update/delete/insert, opening the share dialog, the v0 personal-to-team share mutation, and typed workflow execution where supported. Do not use a generic “dispatch action by string” endpoint. Every handler should be reachable only through an explicit `ControlAction` variant. #### WarpCtrlBehavior review gate The public `ControlAction` catalog remains the source of truth for the wire protocol, CLI parser, permission metadata, generated documentation, and app bridge handlers. Internal app actions such as `WorkspaceAction`, `TerminalAction`, `PaneGroupAction`, settings actions, and future user-visible action enums must not become the public protocol directly because they can contain transient view locators, indices, debug-only variants, implementation-specific payloads, and unstable internal semantics. @@ -354,16 +358,16 @@ Naming decision: Use raw git for the stack; do not use Graphite for these branches. The durable review stack should optimize for reviewability rather than mirroring only broad product phases. The bottom review branch now combines specs and the shared foundation so reviewers can see the product/security contract next to the protocol, settings, bridge, and CLI scaffolding that enforce it. The intended stack is: 1. `zach/warp-cli-core-foundation` — create this branch from `master`. It owns the specs in `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs, plus the shared implementation foundation: protocol crate shape, discovery/auth scaffolding, selector and error types, action metadata/catalog structure, Scripting settings surface, protected local-control settings, local-control server/bridge skeleton, standalone `warpctrl` binary skeleton, packaging hooks, module split, `WarpCtrlBehavior` scaffolding, and the minimum `tab.create` smoke path if needed to prove the end-to-end architecture. -2. `zach/warp-cli-readonly-metadata` — create this branch from `zach/warp-cli-core-foundation`. It implements structural metadata reads: instance/app health and active-chain commands, windows, tabs, panes, sessions, capability/action metadata, opaque IDs, metadata target shapes, and metadata-read permission enforcement. It must not expose terminal output, input buffers, history, file contents, Drive object contents, or other underlying user data. +2. `zach/warp-cli-readonly-metadata` — create this branch from `zach/warp-cli-core-foundation`. It implements structural metadata reads: instance/app health and active-chain commands, windows, tabs, panes, sessions, capability/action metadata, opaque IDs, metadata target shapes, and metadata-read permission enforcement. It must not expose terminal output, input buffers, history, Drive object contents, or other underlying user data. 3. `zach/warp-cli-readonly-data-settings` — create this branch from `zach/warp-cli-readonly-metadata`. It implements underlying-data reads and read-only settings/appearance/docs: block listing/inspection/output, input-buffer reads, history reads, theme/settings/keybinding/action reads, project/file app-state reads, authenticated Drive metadata/content reads where present, read-only docs, and the read-only `warpctrl` Agent skill. 4. `zach/warp-cli-authenticated-scripting` — create this branch from `zach/warp-cli-readonly-data-settings`. It implements authenticated-user grant plumbing, the verified Warp-terminal proof broker, external API-key scripting identity, auth command surface, Settings > Scripting controls for authenticated grants, and tests proving high-risk actions cannot run without authenticated grants. It should not implement broad new action families by itself. 5. `zach/warp-cli-mutating-layout` — create this branch from `zach/warp-cli-authenticated-scripting`. It implements layout and app-state mutations for app/window/tab/pane behavior: window create/focus/close, tab create/activate/move/close, tab metadata that belongs with layout review if appropriate, pane split/focus/navigate/resize/maximize/close, app-state mutation permission checks, and layout mutation tests. 6. `zach/warp-cli-mutating-input-settings-surfaces` — create this branch from `zach/warp-cli-mutating-layout`. It implements session activation/cycling/reopen, input insert/replace/clear, input mode switching, theme/system-theme/font/zoom changes, allowlisted setting set/toggle, settings/palette/search/panel/surface commands, file/project/Drive open commands that are app-state-only, mutating docs, and the mutating-command Agent skill updates. It must preserve the prohibition on accepted-command submission and agent-prompt submission. -7. `zach/warp-cli-mutating-files-drive-data` — create this branch from `zach/warp-cli-mutating-input-settings-surfaces`. It implements authenticated underlying-data mutations for local files and Warp Drive objects: file create/write/append/delete, typed Drive object create/update/delete/insert, permission enforcement, authenticated-user/API-key enforcement, and tests using disposable resources. -8. `zach/warp-cli-mutating-execution-underlying` — create this branch from `zach/warp-cli-mutating-files-drive-data`. It implements authenticated execution-underlying actions such as `input.run` and typed workflow execution where supported. It must require underlying-data-mutation permission plus authenticated scripting identity, audit records, explicit target resolution, and tests proving accepted-command submission and agent-prompt submission remain unavailable. +7. `zach/warp-cli-mutating-drive-data` — create this branch from `zach/warp-cli-mutating-input-settings-surfaces`. It implements authenticated underlying-data mutations for Warp Drive objects: typed Drive object create/update/delete/insert, the v0 personal-to-team sharing path, permission enforcement, authenticated-user/API-key enforcement, and tests using disposable resources. It must not implement local file content reads, writes, appends, deletes, or other filesystem-content mutations. +8. `zach/warp-cli-mutating-execution-underlying` — create this branch from `zach/warp-cli-mutating-drive-data`. It implements authenticated execution-underlying actions such as `input.run` and typed workflow execution where supported. It must require underlying-data-mutation permission plus authenticated scripting identity, audit records, explicit target resolution, and tests proving accepted-command submission and agent-prompt submission remain unavailable. The previous `zach/warp-cli-specs` branch is retained only as migration-source/history material. It is no longer a separate review PR or an authoritative branch in the active stack. The goal is to keep durable review branches close to roughly 2,000 lines of incremental changes where practical while avoiding a one-branch-per-command maintenance burden. Product phases still matter, but they are not the primary PR boundary. The durable branches are the review spine; short-lived shard branches can feed into them during implementation. -Spec changes are an important part of the stacking strategy. All new spec changes must originate on `zach/warp-cli-core-foundation`, which is the authoritative source for `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs. After a spec change lands there, propagate it upward through every stacked branch with raw git so the spec files stay synchronized across `zach/warp-cli-readonly-metadata`, `zach/warp-cli-readonly-data-settings`, `zach/warp-cli-authenticated-scripting`, `zach/warp-cli-mutating-layout`, `zach/warp-cli-mutating-input-settings-surfaces`, `zach/warp-cli-mutating-files-drive-data`, and `zach/warp-cli-mutating-execution-underlying`. Do not make independent spec edits directly on higher implementation branches except as part of resolving a propagation conflict in a way that preserves the authoritative bottom-branch content. +Spec changes are an important part of the stacking strategy. All new spec changes must originate on `zach/warp-cli-core-foundation`, which is the authoritative source for `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs. After a spec change lands there, propagate it upward through every stacked branch with raw git so the spec files stay synchronized across `zach/warp-cli-readonly-metadata`, `zach/warp-cli-readonly-data-settings`, `zach/warp-cli-authenticated-scripting`, `zach/warp-cli-mutating-layout`, `zach/warp-cli-mutating-input-settings-surfaces`, `zach/warp-cli-mutating-drive-data`, and `zach/warp-cli-mutating-execution-underlying`. Do not make independent spec edits directly on higher implementation branches except as part of resolving a propagation conflict in a way that preserves the authoritative bottom-branch content. Recommended raw-git setup after `zach/warp-cli-core-foundation` is ready: ```bash git fetch origin @@ -373,7 +377,7 @@ git checkout -b zach/warp-cli-readonly-data-settings git checkout -b zach/warp-cli-authenticated-scripting git checkout -b zach/warp-cli-mutating-layout git checkout -b zach/warp-cli-mutating-input-settings-surfaces -git checkout -b zach/warp-cli-mutating-files-drive-data +git checkout -b zach/warp-cli-mutating-drive-data git checkout -b zach/warp-cli-mutating-execution-underlying ``` If a lower branch changes after higher branches exist, rebase each higher branch onto its immediate lower branch with raw git and resolve conflicts by preserving both the lower branch's stable API/permission model and the higher branch's owned behavior. @@ -391,8 +395,8 @@ Recommended migration: 5. Create `zach/warp-cli-authenticated-scripting` from `zach/warp-cli-readonly-data-settings` and bring over or implement the verified terminal proof broker, external API-key scripting identity, authenticated-user grant plumbing, auth command surface, and related Settings > Scripting controls. 6. Create `zach/warp-cli-mutating-layout` from `zach/warp-cli-authenticated-scripting` and bring over only layout/app-state mutations from `zach/warp-cli-read-write` and its layout shards. 7. Create `zach/warp-cli-mutating-input-settings-surfaces` from `zach/warp-cli-mutating-layout` and bring over mutating input/session/settings/surface pieces from `zach/warp-cli-read-write`. -8. Create `zach/warp-cli-mutating-files-drive-data` from `zach/warp-cli-mutating-input-settings-surfaces` and bring over `zach/warp-cli-read-write-file-data` and `zach/warp-cli-read-write-drive-data` functionality. -9. Create `zach/warp-cli-mutating-execution-underlying` from `zach/warp-cli-mutating-files-drive-data` and bring over `zach/warp-cli-read-write-execution-underlying` functionality while keeping accepted-command and agent-prompt submission excluded. +8. Create `zach/warp-cli-mutating-drive-data` from `zach/warp-cli-mutating-input-settings-surfaces` and bring over the approved `zach/warp-cli-read-write-drive-data` functionality. Do not bring over local file content read/write/delete functionality because it is no longer part of the public catalog. +9. Create `zach/warp-cli-mutating-execution-underlying` from `zach/warp-cli-mutating-drive-data` and bring over `zach/warp-cli-read-write-execution-underlying` functionality while keeping accepted-command and agent-prompt submission excluded. 10. Recompute incremental diff sizes, validate compilation/tests for each branch, push the new stack, and retire or close the old broad branches once the new review stack is accepted. Before redistributing feature work, prefer landing a mechanical module-split commit in `zach/warp-cli-core-foundation` so later branches do not all expand the same large files. The app-side target should be: - `app/src/local_control/mod.rs` for registration and top-level exports. @@ -436,8 +440,8 @@ Keep PR boundaries aligned with the stack: - PR4: `zach/warp-cli-authenticated-scripting` into `zach/warp-cli-readonly-data-settings` or its merged successor for verified terminal proofs, external API-key scripting auth, and authenticated-user grants. - PR5: `zach/warp-cli-mutating-layout` into `zach/warp-cli-authenticated-scripting` or its merged successor for app/window/tab/pane layout mutations. - PR6: `zach/warp-cli-mutating-input-settings-surfaces` into `zach/warp-cli-mutating-layout` or its merged successor for input/session/settings/surface mutations. -- PR7: `zach/warp-cli-mutating-files-drive-data` into `zach/warp-cli-mutating-input-settings-surfaces` or its merged successor for authenticated file and Drive underlying-data mutations. -- PR8: `zach/warp-cli-mutating-execution-underlying` into `zach/warp-cli-mutating-files-drive-data` or its merged successor for authenticated execution-underlying actions. +- PR7: `zach/warp-cli-mutating-drive-data` into `zach/warp-cli-mutating-input-settings-surfaces` or its merged successor for authenticated Warp Drive underlying-data mutations. +- PR8: `zach/warp-cli-mutating-execution-underlying` into `zach/warp-cli-mutating-drive-data` or its merged successor for authenticated execution-underlying actions. If a lower PR merges before higher branches are ready, rebase the next branch onto the merged successor of its base and continue upward through the stack. Use raw git for all rebases, conflict resolution, cherry-picks, and pushes. ## End-to-end flow ```mermaid @@ -508,16 +512,16 @@ Map tests directly to `PRODUCT.md` behavior. - Shell completions/help output checks once final command naming is selected. ### Computer-use CLI verification Before any stacked PR is considered ready for review, run an end-to-end computer-use verification pass against a cloud built validation artifact from that branch. The validation artifact is a Warp app build and standalone `warpctrl` binary built by the validation agent from the exact commit under review, with `warp_control_cli` compiled in and `FeatureFlag::WarpControlCli` enabled at runtime. -The verifier must launch the built Warp app, enable the Scripting settings needed for the command category under test, and capture screenshots or screen recordings that prove the user-visible result of each basic command family. Screenshots should be saved as durable artifacts and named by branch, invocation context, command family, and command name. +The verifier must launch the built Warp app, enable the Scripting settings needed for the command category under test, and capture screenshots or screen recordings that prove both the terminal invocation and the user-visible result of each basic command family. Every `warpctrl` invocation executed during verification must have a durable screenshot captured immediately after the command runs, showing the exact command line and full terminal output in either pretty or JSON mode. Screenshots should be saved as durable artifacts and named by branch, invocation context, command family, command name, and whether the image shows terminal output or app UI state. The verifier must exercise every invocation context implemented by the branch under review. For the current foundation branch, inside-Warp verification means proving `InsideWarp` requests are rejected and no inside-Warp Settings > Scripting controls are exposed. Once verified Warp-terminal support is implemented, the verifier must also run `warpctrl` from a terminal session inside the feature-flag-enabled Warp app and prove the app-issued execution-context proof is accepted and inside-Warp settings gate command categories. The outside-Warp path must run the same `warpctrl` binary from an external terminal or automation shell outside the built Warp app and prove outside-Warp control is default-off, then works only after enabling outside-Warp scripting and the required granular permissions in Settings > Scripting. -The verification matrix should cover every implemented command in `PRODUCT.md` at least once in JSON output mode and, where there is a visible UI effect, with a screenshot after the command runs. At minimum: -- read-only metadata commands show successful CLI output and, for active/focus/list commands, a visible target that matches the output; -- underlying data read commands show successful CLI output only when the underlying-data-read permission is enabled and a denial screenshot/output when it is disabled; -- app-state mutation commands show before/after screenshots proving the visible Warp UI changed; -- metadata/configuration mutation commands show before/after screenshots proving the persisted setting or label changed; -- underlying data mutation commands run only in a disposable test workspace/session with test files and test Warp Drive objects, show denial without the underlying-data-mutation permission, then show success with the permission enabled; -- authenticated-user commands show both the logged-out or missing-grant denial path and the enabled authenticated path when a test account/environment is available. -The verifier should produce a verification manifest checked into or attached to the PR artifacts. The manifest should list each command, branch under test, invocation context, required permission category, expected result, actual result, screenshot artifact path, and any skipped case with a reason. Missing screenshots for visible commands block review readiness. +The verification matrix should cover every implemented command in `PRODUCT.md` at least once in JSON output mode. The verifier must capture a terminal-output screenshot for every command invocation, including successful calls, expected denials, unsupported stubs, and fail-closed policy gates. Where there is a visible UI effect, the verifier must also capture app UI screenshots after the command runs, and before the command when that is needed to prove a before/after state transition. At minimum: +- read-only metadata commands show successful CLI output in a terminal screenshot and, for active/focus/list commands, a visible target that matches the output; +- underlying data read commands show successful CLI output only when the underlying-data-read permission is enabled, plus terminal screenshots for disabled-permission denials; +- app-state mutation commands show terminal-output screenshots plus before/after app UI screenshots proving the visible Warp UI changed; +- metadata/configuration mutation commands show terminal-output screenshots plus before/after app UI screenshots proving the persisted setting or label changed; +- underlying data mutation commands run only in a disposable test workspace/session with test Warp Drive objects, show terminal screenshots for denial without the underlying-data-mutation permission, then show terminal screenshots and any relevant app/file/Drive state evidence for success with the permission enabled; +- authenticated-user commands show terminal screenshots for both the logged-out or missing-grant denial path and the enabled authenticated path when a test account/environment is available. +The verifier should produce a verification manifest checked into or attached to the PR artifacts. The manifest should list each command, branch under test, invocation context, required permission category, expected result, actual result, terminal screenshot artifact path, UI screenshot artifact path when applicable, and any skipped case with a reason. Missing terminal screenshots for any executed `warpctrl` invocation block review readiness. Missing UI screenshots for visible commands also block review readiness. ## Parallelization The durable review stack should remain linear, but implementation can still happen in parallel with Oz cloud agents. The pattern is contract-first fan-out: land the shared contracts and module boundaries in `zach/warp-cli-core-foundation`, then let cloud agents work on short-lived shard branches that feed the durable review branches. Wave 0: foundation: @@ -536,7 +540,7 @@ Wave 2: mutating fan-out: - `zach/warp-cli-shard/mutating-input-session` owns session activation/cycling/reopen, input insert/replace/clear, and input mode switching, then feeds `zach/warp-cli-mutating-input-settings-surfaces`. - `zach/warp-cli-shard/authenticated-scripting` owns verified terminal proofs, external API-key auth, authenticated-user grants, and auth command tests, then feeds `zach/warp-cli-authenticated-scripting`. - `zach/warp-cli-shard/mutating-settings-surfaces` owns theme/font/zoom/setting mutations and settings/palette/panel/surface commands, then feeds `zach/warp-cli-mutating-input-settings-surfaces`. - - `zach/warp-cli-shard/mutating-files-drive-data` owns file write/delete and Drive object data mutations, then feeds `zach/warp-cli-mutating-files-drive-data`. + - `zach/warp-cli-shard/mutating-drive-data` owns Drive object data mutations, including the v0 personal-to-team sharing path, then feeds `zach/warp-cli-mutating-drive-data`. - `zach/warp-cli-shard/mutating-execution-underlying` owns `input.run` and typed workflow execution, then feeds `zach/warp-cli-mutating-execution-underlying`. Each cloud shard prompt should include: - The exact base branch and shard branch name. @@ -554,7 +558,7 @@ Default file ownership for shards: - Authenticated-scripting shards own auth broker/protocol/CLI modules, Settings > Scripting authenticated grant controls, API-key storage/exchange tests, and authenticated-user denial tests. - Input/session shards own input/session handler/protocol/CLI modules and tests proving staging does not submit or execute unless the branch explicitly owns `input.run`. - Settings/surface shards own settings/surface handler/protocol/CLI modules and metadata/configuration mutation tests. -- Files/Drive data shards own file and Drive underlying-data handler/protocol/CLI modules, authenticated-user/API-key enforcement tests, and disposable-resource tests. +- Drive data shards own Drive underlying-data handler/protocol/CLI modules, authenticated-user/API-key enforcement tests, personal-to-team sharing tests, and disposable-resource tests. - Execution-underlying shards own `input.run` and typed workflow execution handler/protocol/CLI modules, audit tests, and denial tests proving accepted-command and agent-prompt submission remain unavailable. The lead integrator merges or cherry-picks accepted shard work into the durable stack with raw git, in review order. Shard branches should not become independent long-lived PRs unless the lead intentionally splits review further; their default purpose is to feed the durable stack while preserving parallel implementation and focused context windows. ```mermaid @@ -564,7 +568,7 @@ flowchart LR ROData --> Auth["zach/warp-cli-authenticated-scripting<br/>terminal proof + API key auth"] Auth --> MutLayout["zach/warp-cli-mutating-layout<br/>layout mutations"] MutLayout --> MutInput["zach/warp-cli-mutating-input-settings-surfaces<br/>input/settings/surfaces"] - MutInput --> MutData["zach/warp-cli-mutating-files-drive-data<br/>file + Drive data"] + MutInput --> MutData["zach/warp-cli-mutating-drive-data<br/>Drive data"] MutData --> MutExec["zach/warp-cli-mutating-execution-underlying<br/>execution actions"] ROMetaShard["shard/readonly-metadata"] --> ROMeta RODataShard["shard/readonly-data"] --> ROData @@ -573,7 +577,7 @@ flowchart LR MutInputShard["shard/mutating-input-session"] --> MutInput MutSettingsShard["shard/mutating-settings-surfaces"] --> MutInput AuthShard["shard/authenticated-scripting"] --> Auth - MutDataShard["shard/mutating-files-drive-data"] --> MutData + MutDataShard["shard/mutating-drive-data"] --> MutData MutExecShard["shard/mutating-execution-underlying"] --> MutExec ``` ## Risks and mitigations From f5e8795cfe5f025037257f435374f83b5d7d6f15 Mon Sep 17 00:00:00 2001 From: zachlloyd <zachlloyd@gmail.com> Date: Tue, 26 May 2026 03:33:18 +0000 Subject: [PATCH 24/48] Sync warpctrl contract stance Co-Authored-By: Oz <oz-agent@warp.dev> --- crates/local_control/src/catalog.rs | 1178 ++++++++++++++++---- crates/local_control/src/protocol.rs | 238 +++- crates/local_control/src/protocol_tests.rs | 48 +- specs/warp-control-cli/PRODUCT.md | 699 +++--------- specs/warp-control-cli/README.md | 122 +- specs/warp-control-cli/SECURITY.md | 627 ++--------- specs/warp-control-cli/TECH.md | 696 ++---------- 7 files changed, 1674 insertions(+), 1934 deletions(-) diff --git a/crates/local_control/src/catalog.rs b/crates/local_control/src/catalog.rs index 6f92fca348..7a01262d20 100644 --- a/crates/local_control/src/catalog.rs +++ b/crates/local_control/src/catalog.rs @@ -3,6 +3,22 @@ use serde::{Deserialize, Serialize}; pub const PROTOCOL_VERSION: u32 = 1; +pub const EXCLUDED_LOCAL_FILE_MUTATION_ACTION_NAMES: &[&str] = &[ + "file.read", + "file.write", + "file.append", + "file.delete", + "file.copy", + "file.move", + "file.mkdir", +]; + +pub const EXCLUDED_STANDALONE_SECRET_AUTH_ACTION_NAMES: &[&str] = &[ + "auth.api_key.set", + "auth.api_key.status", + "auth.api_key.revoke", +]; + /// Runtime context from which a control request was initiated. #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -61,7 +77,7 @@ pub struct AuthenticatedUserRequirement { pub required: bool, } -/// Level of Warp hierarchy an action targets. +/// Level of Warp hierarchy or orthogonal product noun an action targets. #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TargetScope { @@ -70,9 +86,19 @@ pub enum TargetScope { Tab, Pane, Session, + Block, + Input, + History, Settings, Appearance, Surface, + File, + Project, + DriveObject, + Auth, + Keybinding, + Action, + Capability, } /// Whether an action has an app-side implementation in this stack layer. @@ -83,6 +109,67 @@ pub enum ActionImplementationStatus { Stub, } +/// Typed parameter contract for a catalog action. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ActionParameterSpec { + None, + ActionName, + BindingName, + BooleanValue, + ColorValue, + Direction, + DriveObjectCreate, + DriveObjectId, + DriveObjectInsert, + DriveObjectList, + DriveObjectUpdate, + FileOpen, + InputMode, + Key, + KeyValue, + Limit, + Namespace, + PageQuery, + Path, + Query, + Rename, + Resize, + TabActivate, + TabClose, + TabCreate, + Text, + ThemeName, + WorkflowRun, +} + +/// Typed result contract for a catalog action. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ActionResultSpec { + Acknowledgement, + ActiveTarget, + AppearanceState, + AuthStatus, + CapabilityList, + CapabilityMetadata, + Content, + DriveObjectList, + DriveObjectMetadata, + FileList, + InstanceList, + InstanceMetadata, + KeybindingList, + KeybindingMetadata, + ProjectList, + SettingList, + SettingValue, + TargetList, + TargetMetadata, + ThemeList, + ThemeState, +} + /// Discoverable metadata describing one local-control action. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ActionMetadata { @@ -96,43 +183,37 @@ pub struct ActionMetadata { pub allowed_invocation_contexts: Vec<InvocationContext>, pub permission_category: PermissionCategory, pub target_scope: TargetScope, + pub parameter_spec: ActionParameterSpec, + pub result_spec: ActionResultSpec, } -/// Stable protocol name for every planned `warpctrl` action. +/// Stable protocol name for every approved `warpctrl` action. #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum ActionKind { #[serde(rename = "instance.list")] InstanceList, + #[serde(rename = "instance.inspect")] + InstanceInspect, #[serde(rename = "app.ping")] AppPing, - #[serde(rename = "app.inspect")] - AppInspect, #[serde(rename = "app.version")] AppVersion, #[serde(rename = "app.active")] AppActive, #[serde(rename = "app.focus")] AppFocus, - #[serde(rename = "app.settings.open")] - AppSettingsOpen, - #[serde(rename = "app.command_palette.open")] - AppCommandPaletteOpen, - #[serde(rename = "app.command_search.open")] - AppCommandSearchOpen, - #[serde(rename = "app.warp_drive.open")] - AppWarpDriveOpen, - #[serde(rename = "app.warp_drive.toggle")] - AppWarpDriveToggle, - #[serde(rename = "app.resource_center.toggle")] - AppResourceCenterToggle, - #[serde(rename = "app.ai_assistant.toggle")] - AppAiAssistantToggle, - #[serde(rename = "app.code_review.toggle")] - AppCodeReviewToggle, - #[serde(rename = "app.vertical_tabs.toggle")] - AppVerticalTabsToggle, + #[serde(rename = "auth.status")] + AuthStatus, + #[serde(rename = "auth.login")] + AuthLogin, + #[serde(rename = "capability.list")] + CapabilityList, + #[serde(rename = "capability.inspect")] + CapabilityInspect, #[serde(rename = "window.list")] WindowList, + #[serde(rename = "window.inspect")] + WindowInspect, #[serde(rename = "window.create")] WindowCreate, #[serde(rename = "window.focus")] @@ -141,36 +222,66 @@ pub enum ActionKind { WindowClose, #[serde(rename = "tab.list")] TabList, + #[serde(rename = "tab.inspect")] + TabInspect, #[serde(rename = "tab.create")] TabCreate, #[serde(rename = "tab.activate")] TabActivate, #[serde(rename = "tab.move")] TabMove, - #[serde(rename = "tab.rename")] - TabRename, #[serde(rename = "tab.close")] TabClose, + #[serde(rename = "tab.rename")] + TabRename, + #[serde(rename = "tab.reset_name")] + TabResetName, + #[serde(rename = "tab.color.set")] + TabColorSet, + #[serde(rename = "tab.color.clear")] + TabColorClear, #[serde(rename = "pane.list")] PaneList, + #[serde(rename = "pane.inspect")] + PaneInspect, #[serde(rename = "pane.split")] PaneSplit, #[serde(rename = "pane.focus")] PaneFocus, #[serde(rename = "pane.navigate")] PaneNavigate, - #[serde(rename = "pane.close")] - PaneClose, - #[serde(rename = "pane.maximize")] - PaneMaximize, #[serde(rename = "pane.resize")] PaneResize, - #[serde(rename = "pane.session.previous")] - PaneSessionPrevious, - #[serde(rename = "pane.session.next")] - PaneSessionNext, + #[serde(rename = "pane.maximize")] + PaneMaximize, + #[serde(rename = "pane.unmaximize")] + PaneUnmaximize, + #[serde(rename = "pane.close")] + PaneClose, + #[serde(rename = "pane.rename")] + PaneRename, + #[serde(rename = "pane.reset_name")] + PaneResetName, #[serde(rename = "session.list")] SessionList, + #[serde(rename = "session.inspect")] + SessionInspect, + #[serde(rename = "session.activate")] + SessionActivate, + #[serde(rename = "session.previous")] + SessionPrevious, + #[serde(rename = "session.next")] + SessionNext, + #[serde(rename = "session.reopen_closed")] + SessionReopenClosed, + #[serde(rename = "block.list")] + BlockList, + #[serde(rename = "block.inspect")] + BlockInspect, + #[serde(rename = "block.output")] + BlockOutput, + #[serde(rename = "input.get")] + InputGet, #[serde(rename = "input.insert")] InputInsert, #[serde(rename = "input.replace")] @@ -179,144 +290,222 @@ pub enum ActionKind { InputClear, #[serde(rename = "input.mode.set")] InputModeSet, + #[serde(rename = "input.run")] + InputRun, + #[serde(rename = "history.list")] + HistoryList, #[serde(rename = "theme.list")] ThemeList, + #[serde(rename = "theme.get")] + ThemeGet, #[serde(rename = "theme.set")] ThemeSet, + #[serde(rename = "theme.system.set")] + ThemeSystemSet, + #[serde(rename = "theme.light.set")] + ThemeLightSet, + #[serde(rename = "theme.dark.set")] + ThemeDarkSet, #[serde(rename = "appearance.get")] AppearanceGet, - #[serde(rename = "appearance.set")] - AppearanceSet, - #[serde(rename = "appearance.font_size")] - AppearanceFontSize, - #[serde(rename = "appearance.zoom")] - AppearanceZoom, - #[serde(rename = "setting.get")] - SettingGet, + #[serde(rename = "appearance.font_size.increase")] + AppearanceFontSizeIncrease, + #[serde(rename = "appearance.font_size.decrease")] + AppearanceFontSizeDecrease, + #[serde(rename = "appearance.font_size.reset")] + AppearanceFontSizeReset, + #[serde(rename = "appearance.zoom.increase")] + AppearanceZoomIncrease, + #[serde(rename = "appearance.zoom.decrease")] + AppearanceZoomDecrease, + #[serde(rename = "appearance.zoom.reset")] + AppearanceZoomReset, #[serde(rename = "setting.list")] SettingList, + #[serde(rename = "setting.get")] + SettingGet, #[serde(rename = "setting.set")] SettingSet, #[serde(rename = "setting.toggle")] SettingToggle, + #[serde(rename = "keybinding.list")] + KeybindingList, + #[serde(rename = "keybinding.get")] + KeybindingGet, + #[serde(rename = "action.list")] + ActionList, + #[serde(rename = "action.inspect")] + ActionInspect, + #[serde(rename = "surface.settings.open")] + SurfaceSettingsOpen, + #[serde(rename = "surface.command_palette.open")] + SurfaceCommandPaletteOpen, + #[serde(rename = "surface.command_search.open")] + SurfaceCommandSearchOpen, + #[serde(rename = "surface.warp_drive.open")] + SurfaceWarpDriveOpen, + #[serde(rename = "surface.warp_drive.toggle")] + SurfaceWarpDriveToggle, + #[serde(rename = "surface.resource_center.toggle")] + SurfaceResourceCenterToggle, + #[serde(rename = "surface.ai_assistant.toggle")] + SurfaceAiAssistantToggle, + #[serde(rename = "surface.code_review.toggle")] + SurfaceCodeReviewToggle, + #[serde(rename = "surface.left_panel.toggle")] + SurfaceLeftPanelToggle, + #[serde(rename = "surface.right_panel.toggle")] + SurfaceRightPanelToggle, + #[serde(rename = "surface.vertical_tabs.toggle")] + SurfaceVerticalTabsToggle, + #[serde(rename = "file.list")] + FileList, + #[serde(rename = "file.open")] + FileOpen, + #[serde(rename = "project.active")] + ProjectActive, + #[serde(rename = "project.list")] + ProjectList, + #[serde(rename = "project.open")] + ProjectOpen, + #[serde(rename = "drive.list")] + DriveList, + #[serde(rename = "drive.inspect")] + DriveInspect, + #[serde(rename = "drive.open")] + DriveOpen, + #[serde(rename = "drive.notebook.open")] + DriveNotebookOpen, + #[serde(rename = "drive.env_var_collection.open")] + DriveEnvVarCollectionOpen, + #[serde(rename = "drive.object.share.open")] + DriveObjectShareOpen, + #[serde(rename = "drive.object.create")] + DriveObjectCreate, + #[serde(rename = "drive.object.update")] + DriveObjectUpdate, + #[serde(rename = "drive.object.delete")] + DriveObjectDelete, + #[serde(rename = "drive.object.insert")] + DriveObjectInsert, + #[serde(rename = "drive.object.share_to_team")] + DriveObjectShareToTeam, + #[serde(rename = "drive.workflow.run")] + DriveWorkflowRun, } impl ActionKind { pub const ALL: &[Self] = &[ Self::InstanceList, + Self::InstanceInspect, Self::AppPing, - Self::AppInspect, Self::AppVersion, Self::AppActive, Self::AppFocus, - Self::AppSettingsOpen, - Self::AppCommandPaletteOpen, - Self::AppCommandSearchOpen, - Self::AppWarpDriveOpen, - Self::AppWarpDriveToggle, - Self::AppResourceCenterToggle, - Self::AppAiAssistantToggle, - Self::AppCodeReviewToggle, - Self::AppVerticalTabsToggle, + Self::AuthStatus, + Self::AuthLogin, + Self::CapabilityList, + Self::CapabilityInspect, Self::WindowList, + Self::WindowInspect, Self::WindowCreate, Self::WindowFocus, Self::WindowClose, Self::TabList, + Self::TabInspect, Self::TabCreate, Self::TabActivate, Self::TabMove, - Self::TabRename, Self::TabClose, + Self::TabRename, + Self::TabResetName, + Self::TabColorSet, + Self::TabColorClear, Self::PaneList, + Self::PaneInspect, Self::PaneSplit, Self::PaneFocus, Self::PaneNavigate, - Self::PaneClose, - Self::PaneMaximize, Self::PaneResize, - Self::PaneSessionPrevious, - Self::PaneSessionNext, + Self::PaneMaximize, + Self::PaneUnmaximize, + Self::PaneClose, + Self::PaneRename, + Self::PaneResetName, Self::SessionList, + Self::SessionInspect, + Self::SessionActivate, + Self::SessionPrevious, + Self::SessionNext, + Self::SessionReopenClosed, + Self::BlockList, + Self::BlockInspect, + Self::BlockOutput, + Self::InputGet, Self::InputInsert, Self::InputReplace, Self::InputClear, Self::InputModeSet, + Self::InputRun, + Self::HistoryList, Self::ThemeList, + Self::ThemeGet, Self::ThemeSet, + Self::ThemeSystemSet, + Self::ThemeLightSet, + Self::ThemeDarkSet, Self::AppearanceGet, - Self::AppearanceSet, - Self::AppearanceFontSize, - Self::AppearanceZoom, - Self::SettingGet, + Self::AppearanceFontSizeIncrease, + Self::AppearanceFontSizeDecrease, + Self::AppearanceFontSizeReset, + Self::AppearanceZoomIncrease, + Self::AppearanceZoomDecrease, + Self::AppearanceZoomReset, Self::SettingList, + Self::SettingGet, Self::SettingSet, Self::SettingToggle, + Self::KeybindingList, + Self::KeybindingGet, + Self::ActionList, + Self::ActionInspect, + Self::SurfaceSettingsOpen, + Self::SurfaceCommandPaletteOpen, + Self::SurfaceCommandSearchOpen, + Self::SurfaceWarpDriveOpen, + Self::SurfaceWarpDriveToggle, + Self::SurfaceResourceCenterToggle, + Self::SurfaceAiAssistantToggle, + Self::SurfaceCodeReviewToggle, + Self::SurfaceLeftPanelToggle, + Self::SurfaceRightPanelToggle, + Self::SurfaceVerticalTabsToggle, + Self::FileList, + Self::FileOpen, + Self::ProjectActive, + Self::ProjectList, + Self::ProjectOpen, + Self::DriveList, + Self::DriveInspect, + Self::DriveOpen, + Self::DriveNotebookOpen, + Self::DriveEnvVarCollectionOpen, + Self::DriveObjectShareOpen, + Self::DriveObjectCreate, + Self::DriveObjectUpdate, + Self::DriveObjectDelete, + Self::DriveObjectInsert, + Self::DriveObjectShareToTeam, + Self::DriveWorkflowRun, ]; + pub fn as_str(self) -> &'static str { - match self { - Self::InstanceList => "instance.list", - Self::AppPing => "app.ping", - Self::AppInspect => "app.inspect", - Self::AppVersion => "app.version", - Self::AppActive => "app.active", - Self::AppFocus => "app.focus", - Self::AppSettingsOpen => "app.settings.open", - Self::AppCommandPaletteOpen => "app.command_palette.open", - Self::AppCommandSearchOpen => "app.command_search.open", - Self::AppWarpDriveOpen => "app.warp_drive.open", - Self::AppWarpDriveToggle => "app.warp_drive.toggle", - Self::AppResourceCenterToggle => "app.resource_center.toggle", - Self::AppAiAssistantToggle => "app.ai_assistant.toggle", - Self::AppCodeReviewToggle => "app.code_review.toggle", - Self::AppVerticalTabsToggle => "app.vertical_tabs.toggle", - Self::WindowList => "window.list", - Self::WindowCreate => "window.create", - Self::WindowFocus => "window.focus", - Self::WindowClose => "window.close", - Self::TabList => "tab.list", - Self::TabCreate => "tab.create", - Self::TabActivate => "tab.activate", - Self::TabMove => "tab.move", - Self::TabRename => "tab.rename", - Self::TabClose => "tab.close", - Self::PaneList => "pane.list", - Self::PaneSplit => "pane.split", - Self::PaneFocus => "pane.focus", - Self::PaneNavigate => "pane.navigate", - Self::PaneClose => "pane.close", - Self::PaneMaximize => "pane.maximize", - Self::PaneResize => "pane.resize", - Self::PaneSessionPrevious => "pane.session.previous", - Self::PaneSessionNext => "pane.session.next", - Self::SessionList => "session.list", - Self::InputInsert => "input.insert", - Self::InputReplace => "input.replace", - Self::InputClear => "input.clear", - Self::InputModeSet => "input.mode.set", - Self::ThemeList => "theme.list", - Self::ThemeSet => "theme.set", - Self::AppearanceGet => "appearance.get", - Self::AppearanceSet => "appearance.set", - Self::AppearanceFontSize => "appearance.font_size", - Self::AppearanceZoom => "appearance.zoom", - Self::SettingGet => "setting.get", - Self::SettingList => "setting.list", - Self::SettingSet => "setting.set", - Self::SettingToggle => "setting.toggle", - } + serde_names::action_name(self) } pub fn metadata(self) -> ActionMetadata { - let (implementation_status, requires_authenticated_user, allowed_invocation_contexts) = - match self { - Self::InstanceList | Self::AppPing | Self::AppVersion | Self::TabCreate => ( - ActionImplementationStatus::Implemented, - false, - vec![InvocationContext::OutsideWarp], - ), - _ => (ActionImplementationStatus::Stub, true, Vec::new()), - }; + let implementation_status = self.implementation_status(); + let requires_authenticated_user = self.requires_authenticated_user(); ActionMetadata { kind: self, name: self.as_str().to_owned(), @@ -327,9 +516,11 @@ impl ActionKind { authenticated_user: AuthenticatedUserRequirement { required: requires_authenticated_user, }, - allowed_invocation_contexts, + allowed_invocation_contexts: self.allowed_invocation_contexts(), permission_category: self.default_permission_category(), target_scope: self.default_target_scope(), + parameter_spec: self.parameter_spec(), + result_spec: self.result_spec(), } } @@ -345,113 +536,346 @@ impl ActionKind { } pub fn is_implemented(self) -> bool { - self.metadata().implementation_status == ActionImplementationStatus::Implemented + self.implementation_status() == ActionImplementationStatus::Implemented } - fn default_risk_tier(self) -> RiskTier { + fn implementation_status(self) -> ActionImplementationStatus { match self { - Self::InstanceList - | Self::AppPing - | Self::AppInspect - | Self::AppVersion + Self::InstanceList | Self::AppPing | Self::AppVersion | Self::TabCreate => { + ActionImplementationStatus::Implemented + } + Self::InstanceInspect | Self::AppActive + | Self::AppFocus + | Self::AuthStatus + | Self::AuthLogin + | Self::CapabilityList + | Self::CapabilityInspect | Self::WindowList + | Self::WindowInspect + | Self::WindowCreate + | Self::WindowFocus + | Self::WindowClose | Self::TabList + | Self::TabInspect + | Self::TabActivate + | Self::TabMove + | Self::TabClose + | Self::TabRename + | Self::TabResetName + | Self::TabColorSet + | Self::TabColorClear | Self::PaneList + | Self::PaneInspect + | Self::PaneSplit + | Self::PaneFocus + | Self::PaneNavigate + | Self::PaneResize + | Self::PaneMaximize + | Self::PaneUnmaximize + | Self::PaneClose + | Self::PaneRename + | Self::PaneResetName | Self::SessionList - | Self::ThemeList - | Self::AppearanceGet - | Self::SettingGet - | Self::SettingList => RiskTier::ReadOnlyMetadata, - Self::InputInsert + | Self::SessionInspect + | Self::SessionActivate + | Self::SessionPrevious + | Self::SessionNext + | Self::SessionReopenClosed + | Self::BlockList + | Self::BlockInspect + | Self::BlockOutput + | Self::InputGet + | Self::InputInsert | Self::InputReplace | Self::InputClear | Self::InputModeSet - | Self::WindowClose - | Self::TabClose - | Self::PaneClose => RiskTier::MutatingDestructiveOrExecution, - Self::AppFocus - | Self::AppSettingsOpen - | Self::AppCommandPaletteOpen - | Self::AppCommandSearchOpen - | Self::AppWarpDriveOpen - | Self::AppWarpDriveToggle - | Self::AppResourceCenterToggle - | Self::AppAiAssistantToggle - | Self::AppCodeReviewToggle - | Self::AppVerticalTabsToggle + | Self::InputRun + | Self::HistoryList + | Self::ThemeList + | Self::ThemeGet + | Self::ThemeSet + | Self::ThemeSystemSet + | Self::ThemeLightSet + | Self::ThemeDarkSet + | Self::AppearanceGet + | Self::AppearanceFontSizeIncrease + | Self::AppearanceFontSizeDecrease + | Self::AppearanceFontSizeReset + | Self::AppearanceZoomIncrease + | Self::AppearanceZoomDecrease + | Self::AppearanceZoomReset + | Self::SettingList + | Self::SettingGet + | Self::SettingSet + | Self::SettingToggle + | Self::KeybindingList + | Self::KeybindingGet + | Self::ActionList + | Self::ActionInspect + | Self::SurfaceSettingsOpen + | Self::SurfaceCommandPaletteOpen + | Self::SurfaceCommandSearchOpen + | Self::SurfaceWarpDriveOpen + | Self::SurfaceWarpDriveToggle + | Self::SurfaceResourceCenterToggle + | Self::SurfaceAiAssistantToggle + | Self::SurfaceCodeReviewToggle + | Self::SurfaceLeftPanelToggle + | Self::SurfaceRightPanelToggle + | Self::SurfaceVerticalTabsToggle + | Self::FileList + | Self::FileOpen + | Self::ProjectActive + | Self::ProjectList + | Self::ProjectOpen + | Self::DriveList + | Self::DriveInspect + | Self::DriveOpen + | Self::DriveNotebookOpen + | Self::DriveEnvVarCollectionOpen + | Self::DriveObjectShareOpen + | Self::DriveObjectCreate + | Self::DriveObjectUpdate + | Self::DriveObjectDelete + | Self::DriveObjectInsert + | Self::DriveObjectShareToTeam + | Self::DriveWorkflowRun => ActionImplementationStatus::Stub, + } + } + + fn allowed_invocation_contexts(self) -> Vec<InvocationContext> { + match self { + Self::InstanceList | Self::AppPing | Self::AppVersion | Self::TabCreate => { + vec![InvocationContext::OutsideWarp] + } + _ if self.requires_authenticated_user() => vec![InvocationContext::InsideWarp], + _ => vec![ + InvocationContext::InsideWarp, + InvocationContext::OutsideWarp, + ], + } + } + + fn requires_authenticated_user(self) -> bool { + match self { + Self::DriveList + | Self::DriveInspect + | Self::DriveOpen + | Self::DriveNotebookOpen + | Self::DriveEnvVarCollectionOpen + | Self::DriveObjectShareOpen + | Self::DriveObjectCreate + | Self::DriveObjectUpdate + | Self::DriveObjectDelete + | Self::DriveObjectInsert + | Self::DriveObjectShareToTeam + | Self::DriveWorkflowRun + | Self::InputRun => true, + Self::InstanceList + | Self::InstanceInspect + | Self::AppPing + | Self::AppVersion + | Self::AppActive + | Self::AppFocus + | Self::AuthStatus + | Self::AuthLogin + | Self::CapabilityList + | Self::CapabilityInspect + | Self::WindowList + | Self::WindowInspect | Self::WindowCreate | Self::WindowFocus + | Self::WindowClose + | Self::TabList + | Self::TabInspect | Self::TabCreate | Self::TabActivate | Self::TabMove + | Self::TabClose | Self::TabRename + | Self::TabResetName + | Self::TabColorSet + | Self::TabColorClear + | Self::PaneList + | Self::PaneInspect | Self::PaneSplit | Self::PaneFocus | Self::PaneNavigate - | Self::PaneMaximize | Self::PaneResize - | Self::PaneSessionPrevious - | Self::PaneSessionNext + | Self::PaneMaximize + | Self::PaneUnmaximize + | Self::PaneClose + | Self::PaneRename + | Self::PaneResetName + | Self::SessionList + | Self::SessionInspect + | Self::SessionActivate + | Self::SessionPrevious + | Self::SessionNext + | Self::SessionReopenClosed + | Self::BlockList + | Self::BlockInspect + | Self::BlockOutput + | Self::InputGet + | Self::InputInsert + | Self::InputReplace + | Self::InputClear + | Self::InputModeSet + | Self::HistoryList + | Self::ThemeList + | Self::ThemeGet | Self::ThemeSet - | Self::AppearanceSet - | Self::AppearanceFontSize - | Self::AppearanceZoom + | Self::ThemeSystemSet + | Self::ThemeLightSet + | Self::ThemeDarkSet + | Self::AppearanceGet + | Self::AppearanceFontSizeIncrease + | Self::AppearanceFontSizeDecrease + | Self::AppearanceFontSizeReset + | Self::AppearanceZoomIncrease + | Self::AppearanceZoomDecrease + | Self::AppearanceZoomReset + | Self::SettingList + | Self::SettingGet | Self::SettingSet - | Self::SettingToggle => RiskTier::MutatingNonDestructive, + | Self::SettingToggle + | Self::KeybindingList + | Self::KeybindingGet + | Self::ActionList + | Self::ActionInspect + | Self::SurfaceSettingsOpen + | Self::SurfaceCommandPaletteOpen + | Self::SurfaceCommandSearchOpen + | Self::SurfaceWarpDriveOpen + | Self::SurfaceWarpDriveToggle + | Self::SurfaceResourceCenterToggle + | Self::SurfaceAiAssistantToggle + | Self::SurfaceCodeReviewToggle + | Self::SurfaceLeftPanelToggle + | Self::SurfaceRightPanelToggle + | Self::SurfaceVerticalTabsToggle + | Self::FileList + | Self::FileOpen + | Self::ProjectActive + | Self::ProjectList + | Self::ProjectOpen => false, + } + } + + fn default_risk_tier(self) -> RiskTier { + match self.default_state_data_category() { + StateDataCategory::MetadataRead => RiskTier::ReadOnlyMetadata, + StateDataCategory::UnderlyingDataRead => RiskTier::ReadOnlyTerminalData, + StateDataCategory::UnderlyingDataMutation => RiskTier::MutatingDestructiveOrExecution, + StateDataCategory::AppStateMutation + | StateDataCategory::MetadataConfigurationMutation => RiskTier::MutatingNonDestructive, } } fn default_state_data_category(self) -> StateDataCategory { match self { Self::InstanceList + | Self::InstanceInspect | Self::AppPing - | Self::AppInspect | Self::AppVersion | Self::AppActive + | Self::AuthStatus + | Self::CapabilityList + | Self::CapabilityInspect | Self::WindowList + | Self::WindowInspect | Self::TabList + | Self::TabInspect | Self::PaneList + | Self::PaneInspect | Self::SessionList + | Self::SessionInspect + | Self::BlockList | Self::ThemeList + | Self::ThemeGet | Self::AppearanceGet + | Self::SettingList | Self::SettingGet - | Self::SettingList => StateDataCategory::MetadataRead, - Self::SettingSet - | Self::SettingToggle + | Self::KeybindingList + | Self::KeybindingGet + | Self::ActionList + | Self::ActionInspect + | Self::FileList + | Self::ProjectActive + | Self::ProjectList + | Self::DriveList => StateDataCategory::MetadataRead, + Self::BlockInspect + | Self::BlockOutput + | Self::InputGet + | Self::HistoryList + | Self::DriveInspect => StateDataCategory::UnderlyingDataRead, + Self::TabRename + | Self::TabResetName + | Self::TabColorSet + | Self::TabColorClear + | Self::PaneRename + | Self::PaneResetName | Self::ThemeSet - | Self::AppearanceSet - | Self::AppearanceFontSize - | Self::AppearanceZoom => StateDataCategory::MetadataConfigurationMutation, - Self::InputInsert | Self::InputReplace | Self::InputClear | Self::InputModeSet => { - StateDataCategory::UnderlyingDataMutation - } + | Self::ThemeSystemSet + | Self::ThemeLightSet + | Self::ThemeDarkSet + | Self::AppearanceFontSizeIncrease + | Self::AppearanceFontSizeDecrease + | Self::AppearanceFontSizeReset + | Self::AppearanceZoomIncrease + | Self::AppearanceZoomDecrease + | Self::AppearanceZoomReset + | Self::SettingSet + | Self::SettingToggle => StateDataCategory::MetadataConfigurationMutation, + Self::DriveObjectCreate + | Self::DriveObjectUpdate + | Self::DriveObjectDelete + | Self::DriveObjectInsert + | Self::DriveObjectShareToTeam + | Self::DriveWorkflowRun + | Self::InputRun => StateDataCategory::UnderlyingDataMutation, Self::AppFocus - | Self::AppSettingsOpen - | Self::AppCommandPaletteOpen - | Self::AppCommandSearchOpen - | Self::AppWarpDriveOpen - | Self::AppWarpDriveToggle - | Self::AppResourceCenterToggle - | Self::AppAiAssistantToggle - | Self::AppCodeReviewToggle - | Self::AppVerticalTabsToggle + | Self::AuthLogin | Self::WindowCreate | Self::WindowFocus | Self::WindowClose | Self::TabCreate | Self::TabActivate | Self::TabMove - | Self::TabRename | Self::TabClose | Self::PaneSplit | Self::PaneFocus | Self::PaneNavigate - | Self::PaneClose - | Self::PaneMaximize | Self::PaneResize - | Self::PaneSessionPrevious - | Self::PaneSessionNext => StateDataCategory::AppStateMutation, + | Self::PaneMaximize + | Self::PaneUnmaximize + | Self::PaneClose + | Self::SessionActivate + | Self::SessionPrevious + | Self::SessionNext + | Self::SessionReopenClosed + | Self::InputInsert + | Self::InputReplace + | Self::InputClear + | Self::InputModeSet + | Self::SurfaceSettingsOpen + | Self::SurfaceCommandPaletteOpen + | Self::SurfaceCommandSearchOpen + | Self::SurfaceWarpDriveOpen + | Self::SurfaceWarpDriveToggle + | Self::SurfaceResourceCenterToggle + | Self::SurfaceAiAssistantToggle + | Self::SurfaceCodeReviewToggle + | Self::SurfaceLeftPanelToggle + | Self::SurfaceRightPanelToggle + | Self::SurfaceVerticalTabsToggle + | Self::FileOpen + | Self::ProjectOpen + | Self::DriveOpen + | Self::DriveNotebookOpen + | Self::DriveEnvVarCollectionOpen + | Self::DriveObjectShareOpen => StateDataCategory::AppStateMutation, } } @@ -466,55 +890,411 @@ impl ActionKind { StateDataCategory::UnderlyingDataMutation => PermissionCategory::MutateUnderlyingData, } } + fn default_target_scope(self) -> TargetScope { match self { - Self::WindowList | Self::WindowCreate | Self::WindowFocus | Self::WindowClose => { - TargetScope::Window - } + Self::InstanceList + | Self::InstanceInspect + | Self::AppPing + | Self::AppVersion + | Self::AppActive + | Self::AppFocus => TargetScope::Instance, + Self::AuthStatus | Self::AuthLogin => TargetScope::Auth, + Self::CapabilityList | Self::CapabilityInspect => TargetScope::Capability, + Self::WindowList + | Self::WindowInspect + | Self::WindowCreate + | Self::WindowFocus + | Self::WindowClose => TargetScope::Window, Self::TabList + | Self::TabInspect | Self::TabCreate | Self::TabActivate | Self::TabMove + | Self::TabClose | Self::TabRename - | Self::TabClose => TargetScope::Tab, + | Self::TabResetName + | Self::TabColorSet + | Self::TabColorClear => TargetScope::Tab, Self::PaneList + | Self::PaneInspect | Self::PaneSplit | Self::PaneFocus | Self::PaneNavigate - | Self::PaneClose - | Self::PaneMaximize | Self::PaneResize - | Self::PaneSessionPrevious - | Self::PaneSessionNext => TargetScope::Pane, + | Self::PaneMaximize + | Self::PaneUnmaximize + | Self::PaneClose + | Self::PaneRename + | Self::PaneResetName => TargetScope::Pane, Self::SessionList + | Self::SessionInspect + | Self::SessionActivate + | Self::SessionPrevious + | Self::SessionNext + | Self::SessionReopenClosed => TargetScope::Session, + Self::BlockList | Self::BlockInspect | Self::BlockOutput => TargetScope::Block, + Self::InputGet | Self::InputInsert | Self::InputReplace | Self::InputClear - | Self::InputModeSet => TargetScope::Session, + | Self::InputModeSet + | Self::InputRun => TargetScope::Input, + Self::HistoryList => TargetScope::History, Self::ThemeList + | Self::ThemeGet | Self::ThemeSet + | Self::ThemeSystemSet + | Self::ThemeLightSet + | Self::ThemeDarkSet | Self::AppearanceGet - | Self::AppearanceSet - | Self::AppearanceFontSize - | Self::AppearanceZoom => TargetScope::Appearance, - Self::SettingGet | Self::SettingList | Self::SettingSet | Self::SettingToggle => { + | Self::AppearanceFontSizeIncrease + | Self::AppearanceFontSizeDecrease + | Self::AppearanceFontSizeReset + | Self::AppearanceZoomIncrease + | Self::AppearanceZoomDecrease + | Self::AppearanceZoomReset => TargetScope::Appearance, + Self::SettingList | Self::SettingGet | Self::SettingSet | Self::SettingToggle => { TargetScope::Settings } - Self::AppSettingsOpen - | Self::AppCommandPaletteOpen - | Self::AppCommandSearchOpen - | Self::AppWarpDriveOpen - | Self::AppWarpDriveToggle - | Self::AppResourceCenterToggle - | Self::AppAiAssistantToggle - | Self::AppCodeReviewToggle - | Self::AppVerticalTabsToggle => TargetScope::Surface, + Self::KeybindingList | Self::KeybindingGet => TargetScope::Keybinding, + Self::ActionList | Self::ActionInspect => TargetScope::Action, + Self::SurfaceSettingsOpen + | Self::SurfaceCommandPaletteOpen + | Self::SurfaceCommandSearchOpen + | Self::SurfaceWarpDriveOpen + | Self::SurfaceWarpDriveToggle + | Self::SurfaceResourceCenterToggle + | Self::SurfaceAiAssistantToggle + | Self::SurfaceCodeReviewToggle + | Self::SurfaceLeftPanelToggle + | Self::SurfaceRightPanelToggle + | Self::SurfaceVerticalTabsToggle => TargetScope::Surface, + Self::FileList | Self::FileOpen => TargetScope::File, + Self::ProjectActive | Self::ProjectList | Self::ProjectOpen => TargetScope::Project, + Self::DriveList + | Self::DriveInspect + | Self::DriveOpen + | Self::DriveNotebookOpen + | Self::DriveEnvVarCollectionOpen + | Self::DriveObjectShareOpen + | Self::DriveObjectCreate + | Self::DriveObjectUpdate + | Self::DriveObjectDelete + | Self::DriveObjectInsert + | Self::DriveObjectShareToTeam + | Self::DriveWorkflowRun => TargetScope::DriveObject, + } + } + + fn parameter_spec(self) -> ActionParameterSpec { + match self { Self::InstanceList + | Self::InstanceInspect | Self::AppPing - | Self::AppInspect | Self::AppVersion | Self::AppActive - | Self::AppFocus => TargetScope::Instance, + | Self::AppFocus + | Self::AuthStatus + | Self::AuthLogin + | Self::CapabilityList + | Self::WindowList + | Self::WindowInspect + | Self::TabList + | Self::TabInspect + | Self::PaneList + | Self::PaneInspect + | Self::SessionList + | Self::SessionInspect + | Self::ThemeList + | Self::ThemeGet + | Self::AppearanceGet + | Self::KeybindingList + | Self::ActionList + | Self::FileList + | Self::ProjectActive + | Self::ProjectList => ActionParameterSpec::None, + Self::CapabilityInspect | Self::ActionInspect => ActionParameterSpec::ActionName, + Self::WindowCreate | Self::TabCreate => ActionParameterSpec::TabCreate, + Self::WindowFocus + | Self::WindowClose + | Self::TabResetName + | Self::TabColorClear + | Self::PaneFocus + | Self::PaneMaximize + | Self::PaneUnmaximize + | Self::PaneClose + | Self::PaneResetName + | Self::SessionActivate => ActionParameterSpec::None, + Self::TabActivate => ActionParameterSpec::TabActivate, + Self::TabMove | Self::PaneSplit | Self::PaneNavigate => ActionParameterSpec::Direction, + Self::TabClose => ActionParameterSpec::TabClose, + Self::TabRename | Self::PaneRename => ActionParameterSpec::Rename, + Self::TabColorSet => ActionParameterSpec::ColorValue, + Self::PaneResize => ActionParameterSpec::Resize, + Self::SessionPrevious | Self::SessionNext | Self::SessionReopenClosed => { + ActionParameterSpec::None + } + Self::BlockList | Self::HistoryList => ActionParameterSpec::Limit, + Self::BlockInspect | Self::BlockOutput => ActionParameterSpec::None, + Self::InputGet => ActionParameterSpec::None, + Self::InputInsert | Self::InputReplace | Self::InputRun => ActionParameterSpec::Text, + Self::InputClear => ActionParameterSpec::None, + Self::InputModeSet => ActionParameterSpec::InputMode, + Self::ThemeSet | Self::ThemeLightSet | Self::ThemeDarkSet => { + ActionParameterSpec::ThemeName + } + Self::ThemeSystemSet => ActionParameterSpec::BooleanValue, + Self::AppearanceFontSizeIncrease + | Self::AppearanceFontSizeDecrease + | Self::AppearanceFontSizeReset + | Self::AppearanceZoomIncrease + | Self::AppearanceZoomDecrease + | Self::AppearanceZoomReset => ActionParameterSpec::None, + Self::SettingList => ActionParameterSpec::Namespace, + Self::SettingGet => ActionParameterSpec::Key, + Self::SettingSet => ActionParameterSpec::KeyValue, + Self::SettingToggle => ActionParameterSpec::Key, + Self::KeybindingGet => ActionParameterSpec::BindingName, + Self::SurfaceSettingsOpen => ActionParameterSpec::PageQuery, + Self::SurfaceCommandPaletteOpen | Self::SurfaceCommandSearchOpen => { + ActionParameterSpec::Query + } + Self::SurfaceWarpDriveOpen + | Self::SurfaceWarpDriveToggle + | Self::SurfaceResourceCenterToggle + | Self::SurfaceAiAssistantToggle + | Self::SurfaceCodeReviewToggle + | Self::SurfaceLeftPanelToggle + | Self::SurfaceRightPanelToggle + | Self::SurfaceVerticalTabsToggle => ActionParameterSpec::None, + Self::FileOpen => ActionParameterSpec::FileOpen, + Self::ProjectOpen => ActionParameterSpec::Path, + Self::DriveList => ActionParameterSpec::DriveObjectList, + Self::DriveInspect + | Self::DriveOpen + | Self::DriveNotebookOpen + | Self::DriveEnvVarCollectionOpen + | Self::DriveObjectShareOpen + | Self::DriveObjectDelete + | Self::DriveObjectShareToTeam => ActionParameterSpec::DriveObjectId, + Self::DriveObjectCreate => ActionParameterSpec::DriveObjectCreate, + Self::DriveObjectUpdate => ActionParameterSpec::DriveObjectUpdate, + Self::DriveObjectInsert => ActionParameterSpec::DriveObjectInsert, + Self::DriveWorkflowRun => ActionParameterSpec::WorkflowRun, + } + } + + fn result_spec(self) -> ActionResultSpec { + match self { + Self::InstanceList => ActionResultSpec::InstanceList, + Self::InstanceInspect | Self::AppPing | Self::AppVersion => { + ActionResultSpec::InstanceMetadata + } + Self::AppActive => ActionResultSpec::ActiveTarget, + Self::AuthStatus => ActionResultSpec::AuthStatus, + Self::CapabilityList => ActionResultSpec::CapabilityList, + Self::CapabilityInspect => ActionResultSpec::CapabilityMetadata, + Self::WindowList + | Self::TabList + | Self::PaneList + | Self::SessionList + | Self::BlockList => ActionResultSpec::TargetList, + Self::WindowInspect | Self::TabInspect | Self::PaneInspect | Self::SessionInspect => { + ActionResultSpec::TargetMetadata + } + Self::BlockInspect | Self::BlockOutput | Self::InputGet | Self::HistoryList => { + ActionResultSpec::Content + } + Self::ThemeList => ActionResultSpec::ThemeList, + Self::ThemeGet => ActionResultSpec::ThemeState, + Self::AppearanceGet => ActionResultSpec::AppearanceState, + Self::SettingList => ActionResultSpec::SettingList, + Self::SettingGet => ActionResultSpec::SettingValue, + Self::KeybindingList => ActionResultSpec::KeybindingList, + Self::KeybindingGet => ActionResultSpec::KeybindingMetadata, + Self::ActionList => ActionResultSpec::CapabilityList, + Self::ActionInspect => ActionResultSpec::CapabilityMetadata, + Self::FileList => ActionResultSpec::FileList, + Self::ProjectActive | Self::ProjectList => ActionResultSpec::ProjectList, + Self::DriveList => ActionResultSpec::DriveObjectList, + Self::DriveInspect => ActionResultSpec::DriveObjectMetadata, + Self::AppFocus + | Self::AuthLogin + | Self::WindowCreate + | Self::WindowFocus + | Self::WindowClose + | Self::TabCreate + | Self::TabActivate + | Self::TabMove + | Self::TabClose + | Self::TabRename + | Self::TabResetName + | Self::TabColorSet + | Self::TabColorClear + | Self::PaneSplit + | Self::PaneFocus + | Self::PaneNavigate + | Self::PaneResize + | Self::PaneMaximize + | Self::PaneUnmaximize + | Self::PaneClose + | Self::PaneRename + | Self::PaneResetName + | Self::SessionActivate + | Self::SessionPrevious + | Self::SessionNext + | Self::SessionReopenClosed + | Self::InputInsert + | Self::InputReplace + | Self::InputClear + | Self::InputModeSet + | Self::InputRun + | Self::ThemeSet + | Self::ThemeSystemSet + | Self::ThemeLightSet + | Self::ThemeDarkSet + | Self::AppearanceFontSizeIncrease + | Self::AppearanceFontSizeDecrease + | Self::AppearanceFontSizeReset + | Self::AppearanceZoomIncrease + | Self::AppearanceZoomDecrease + | Self::AppearanceZoomReset + | Self::SettingSet + | Self::SettingToggle + | Self::SurfaceSettingsOpen + | Self::SurfaceCommandPaletteOpen + | Self::SurfaceCommandSearchOpen + | Self::SurfaceWarpDriveOpen + | Self::SurfaceWarpDriveToggle + | Self::SurfaceResourceCenterToggle + | Self::SurfaceAiAssistantToggle + | Self::SurfaceCodeReviewToggle + | Self::SurfaceLeftPanelToggle + | Self::SurfaceRightPanelToggle + | Self::SurfaceVerticalTabsToggle + | Self::FileOpen + | Self::ProjectOpen + | Self::DriveOpen + | Self::DriveNotebookOpen + | Self::DriveEnvVarCollectionOpen + | Self::DriveObjectShareOpen + | Self::DriveObjectCreate + | Self::DriveObjectUpdate + | Self::DriveObjectDelete + | Self::DriveObjectInsert + | Self::DriveObjectShareToTeam + | Self::DriveWorkflowRun => ActionResultSpec::Acknowledgement, + } + } +} + +mod serde_names { + use super::ActionKind; + + pub(super) fn action_name(action: ActionKind) -> &'static str { + match action { + ActionKind::InstanceList => "instance.list", + ActionKind::InstanceInspect => "instance.inspect", + ActionKind::AppPing => "app.ping", + ActionKind::AppVersion => "app.version", + ActionKind::AppActive => "app.active", + ActionKind::AppFocus => "app.focus", + ActionKind::AuthStatus => "auth.status", + ActionKind::AuthLogin => "auth.login", + ActionKind::CapabilityList => "capability.list", + ActionKind::CapabilityInspect => "capability.inspect", + ActionKind::WindowList => "window.list", + ActionKind::WindowInspect => "window.inspect", + ActionKind::WindowCreate => "window.create", + ActionKind::WindowFocus => "window.focus", + ActionKind::WindowClose => "window.close", + ActionKind::TabList => "tab.list", + ActionKind::TabInspect => "tab.inspect", + ActionKind::TabCreate => "tab.create", + ActionKind::TabActivate => "tab.activate", + ActionKind::TabMove => "tab.move", + ActionKind::TabClose => "tab.close", + ActionKind::TabRename => "tab.rename", + ActionKind::TabResetName => "tab.reset_name", + ActionKind::TabColorSet => "tab.color.set", + ActionKind::TabColorClear => "tab.color.clear", + ActionKind::PaneList => "pane.list", + ActionKind::PaneInspect => "pane.inspect", + ActionKind::PaneSplit => "pane.split", + ActionKind::PaneFocus => "pane.focus", + ActionKind::PaneNavigate => "pane.navigate", + ActionKind::PaneResize => "pane.resize", + ActionKind::PaneMaximize => "pane.maximize", + ActionKind::PaneUnmaximize => "pane.unmaximize", + ActionKind::PaneClose => "pane.close", + ActionKind::PaneRename => "pane.rename", + ActionKind::PaneResetName => "pane.reset_name", + ActionKind::SessionList => "session.list", + ActionKind::SessionInspect => "session.inspect", + ActionKind::SessionActivate => "session.activate", + ActionKind::SessionPrevious => "session.previous", + ActionKind::SessionNext => "session.next", + ActionKind::SessionReopenClosed => "session.reopen_closed", + ActionKind::BlockList => "block.list", + ActionKind::BlockInspect => "block.inspect", + ActionKind::BlockOutput => "block.output", + ActionKind::InputGet => "input.get", + ActionKind::InputInsert => "input.insert", + ActionKind::InputReplace => "input.replace", + ActionKind::InputClear => "input.clear", + ActionKind::InputModeSet => "input.mode.set", + ActionKind::InputRun => "input.run", + ActionKind::HistoryList => "history.list", + ActionKind::ThemeList => "theme.list", + ActionKind::ThemeGet => "theme.get", + ActionKind::ThemeSet => "theme.set", + ActionKind::ThemeSystemSet => "theme.system.set", + ActionKind::ThemeLightSet => "theme.light.set", + ActionKind::ThemeDarkSet => "theme.dark.set", + ActionKind::AppearanceGet => "appearance.get", + ActionKind::AppearanceFontSizeIncrease => "appearance.font_size.increase", + ActionKind::AppearanceFontSizeDecrease => "appearance.font_size.decrease", + ActionKind::AppearanceFontSizeReset => "appearance.font_size.reset", + ActionKind::AppearanceZoomIncrease => "appearance.zoom.increase", + ActionKind::AppearanceZoomDecrease => "appearance.zoom.decrease", + ActionKind::AppearanceZoomReset => "appearance.zoom.reset", + ActionKind::SettingList => "setting.list", + ActionKind::SettingGet => "setting.get", + ActionKind::SettingSet => "setting.set", + ActionKind::SettingToggle => "setting.toggle", + ActionKind::KeybindingList => "keybinding.list", + ActionKind::KeybindingGet => "keybinding.get", + ActionKind::ActionList => "action.list", + ActionKind::ActionInspect => "action.inspect", + ActionKind::SurfaceSettingsOpen => "surface.settings.open", + ActionKind::SurfaceCommandPaletteOpen => "surface.command_palette.open", + ActionKind::SurfaceCommandSearchOpen => "surface.command_search.open", + ActionKind::SurfaceWarpDriveOpen => "surface.warp_drive.open", + ActionKind::SurfaceWarpDriveToggle => "surface.warp_drive.toggle", + ActionKind::SurfaceResourceCenterToggle => "surface.resource_center.toggle", + ActionKind::SurfaceAiAssistantToggle => "surface.ai_assistant.toggle", + ActionKind::SurfaceCodeReviewToggle => "surface.code_review.toggle", + ActionKind::SurfaceLeftPanelToggle => "surface.left_panel.toggle", + ActionKind::SurfaceRightPanelToggle => "surface.right_panel.toggle", + ActionKind::SurfaceVerticalTabsToggle => "surface.vertical_tabs.toggle", + ActionKind::FileList => "file.list", + ActionKind::FileOpen => "file.open", + ActionKind::ProjectActive => "project.active", + ActionKind::ProjectList => "project.list", + ActionKind::ProjectOpen => "project.open", + ActionKind::DriveList => "drive.list", + ActionKind::DriveInspect => "drive.inspect", + ActionKind::DriveOpen => "drive.open", + ActionKind::DriveNotebookOpen => "drive.notebook.open", + ActionKind::DriveEnvVarCollectionOpen => "drive.env_var_collection.open", + ActionKind::DriveObjectShareOpen => "drive.object.share.open", + ActionKind::DriveObjectCreate => "drive.object.create", + ActionKind::DriveObjectUpdate => "drive.object.update", + ActionKind::DriveObjectDelete => "drive.object.delete", + ActionKind::DriveObjectInsert => "drive.object.insert", + ActionKind::DriveObjectShareToTeam => "drive.object.share_to_team", + ActionKind::DriveWorkflowRun => "drive.workflow.run", } } } diff --git a/crates/local_control/src/protocol.rs b/crates/local_control/src/protocol.rs index a9afc71c16..e7e0f1bad3 100644 --- a/crates/local_control/src/protocol.rs +++ b/crates/local_control/src/protocol.rs @@ -3,14 +3,246 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; pub use crate::catalog::{ - ActionImplementationStatus, ActionKind, ActionMetadata, AuthenticatedUserRequirement, - ExecutionContextProof, InvocationContext, PROTOCOL_VERSION, PermissionCategory, RiskTier, - StateDataCategory, TargetScope, + ActionImplementationStatus, ActionKind, ActionMetadata, ActionParameterSpec, ActionResultSpec, + AuthenticatedUserRequirement, EXCLUDED_LOCAL_FILE_MUTATION_ACTION_NAMES, + EXCLUDED_STANDALONE_SECRET_AUTH_ACTION_NAMES, ExecutionContextProof, InvocationContext, + PROTOCOL_VERSION, PermissionCategory, RiskTier, StateDataCategory, TargetScope, }; pub use crate::selectors::{ PaneSelector, PaneTarget, TabSelector, TabTarget, TargetSelector, WindowSelector, WindowTarget, }; +/// Opaque Drive object identifier supplied by Warp metadata. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct DriveObjectId(pub String); + +/// Public Warp Drive object families addressed by the control protocol. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DriveObjectType { + Workflow, + Notebook, + EnvVarCollection, + Prompt, + Folder, + AiFact, + McpServer, + Space, + Trash, +} + +/// Common layout direction values accepted by pane and tab mutations. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Direction { + Left, + Right, + Up, + Down, + Previous, + Next, +} + +/// Input mode values accepted by `input.mode.set`. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InputMode { + Terminal, + Agent, +} + +/// Output flavor for block output reads. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BlockOutputFormat { + Plain, + Ansi, + Json, +} + +/// Tab type accepted by `tab.create`. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TabType { + Terminal, + Agent, + CloudAgent, + Default, +} + +/// Typed parameter payloads for public catalog actions. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ActionParams { + None, + ActionName { + action: String, + }, + BindingName { + binding_name: String, + }, + BooleanValue { + value: bool, + }, + ColorValue { + color: String, + }, + Direction { + direction: Direction, + }, + DriveObjectCreate(DriveObjectCreateParams), + DriveObjectId { + id: DriveObjectId, + }, + DriveObjectInsert(DriveObjectInsertParams), + DriveObjectList { + object_type: DriveObjectType, + }, + DriveObjectUpdate(DriveObjectUpdateParams), + FileOpen(FileOpenParams), + InputMode { + mode: InputMode, + }, + Key { + key: String, + }, + KeyValue { + key: String, + value: serde_json::Value, + }, + Limit { + limit: Option<u32>, + }, + Namespace { + namespace: Option<String>, + }, + PageQuery { + page: Option<String>, + query: Option<String>, + }, + Path { + path: String, + }, + Query { + query: Option<String>, + }, + Rename { + title: String, + }, + Resize { + direction: Direction, + amount: Option<u32>, + }, + TabActivate { + mode: TabActivationMode, + }, + TabClose { + mode: TabCloseMode, + }, + TabCreate(TabCreateParams), + Text { + text: String, + }, + ThemeName { + theme_name: String, + }, + WorkflowRun(WorkflowRunParams), +} + +/// Parameters for `tab.create` and `window.create` shell/profile options. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct TabCreateParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_type: Option<TabType>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shell: Option<String>, +} + +/// Parameters for opening a file in Warp's app/editor state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileOpenParams { + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub line: Option<u32>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub column: Option<u32>, + #[serde(default)] + pub new_tab: bool, +} + +/// Parameters for Drive object creation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DriveObjectCreateParams { + pub object_type: DriveObjectType, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_file: Option<String>, +} + +/// Parameters for Drive object updates. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DriveObjectUpdateParams { + pub id: DriveObjectId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_file: Option<String>, +} + +/// Parameters for inserting an existing Drive object into a target surface. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DriveObjectInsertParams { + pub id: DriveObjectId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option<TargetSelector>, +} + +/// Parameters for running an approved Warp Drive workflow. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkflowRunParams { + pub id: DriveObjectId, + #[serde(default)] + pub args: Vec<WorkflowArgument>, +} + +/// Name/value argument passed to an approved workflow run. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkflowArgument { + pub name: String, + pub value: String, +} + +/// Mode accepted by `tab.activate`. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TabActivationMode { + Target, + Previous, + Next, + Last, +} + +/// Mode accepted by `tab.close`. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TabCloseMode { + Target, + Active, + Others, + RightOf, +} + +/// Typed success payloads for catalog actions that need stable structured data. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ControlResult { + Acknowledgement { action: ActionKind }, + Metadata { data: serde_json::Value }, + Content { data: serde_json::Value }, +} + /// Top-level request sent by a local-control client to a Warp instance. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RequestEnvelope { diff --git a/crates/local_control/src/protocol_tests.rs b/crates/local_control/src/protocol_tests.rs index af03f60868..01fb94e1c9 100644 --- a/crates/local_control/src/protocol_tests.rs +++ b/crates/local_control/src/protocol_tests.rs @@ -28,17 +28,25 @@ fn ambiguous_target_error_code_is_stable() { assert_eq!(value, serde_json::json!("ambiguous_target")); } -#[test] -fn input_run_is_not_in_the_allowlisted_catalog() { - let action = serde_json::from_value::<ActionKind>(serde_json::json!("input.run")); - assert!(action.is_err()); -} #[test] fn malformed_action_name_is_not_deserialized() { let action = serde_json::from_value::<ActionKind>(serde_json::json!("tab.create.extra")); assert!(action.is_err()); } +#[test] +fn excluded_action_names_are_not_deserialized() { + for action in [ + "file.write", + "file.delete", + "auth.api_key.set", + "auth.api_key.status", + "auth.api_key.revoke", + ] { + assert!(serde_json::from_value::<ActionKind>(serde_json::json!(action)).is_err()); + } +} + #[test] fn tab_create_metadata_is_first_slice_logged_out_safe_mutation() { let metadata = ActionKind::TabCreate.metadata(); @@ -110,7 +118,7 @@ fn default_permissions_preserve_security_categories() { ); assert_eq!( ActionKind::InputInsert.metadata().permission_category, - PermissionCategory::MutateUnderlyingData + PermissionCategory::MutateAppState ); assert_eq!( ActionKind::SettingSet.metadata().permission_category, @@ -122,15 +130,31 @@ fn default_permissions_preserve_security_categories() { ); } #[test] -fn non_first_slice_actions_are_catalog_stubs() { +fn logged_out_safe_stub_actions_can_advertise_external_context() { let metadata = ActionKind::WindowCreate.metadata(); assert_eq!( metadata.implementation_status, ActionImplementationStatus::Stub ); - assert!( - !metadata - .allowed_invocation_contexts - .contains(&InvocationContext::OutsideWarp) - ); + assert!(!metadata.authenticated_user.required); + assert!(metadata + .allowed_invocation_contexts + .contains(&InvocationContext::OutsideWarp)); +} + +#[test] +fn authenticated_actions_are_warp_terminal_only_in_the_contract() { + for action in [ + ActionKind::DriveInspect, + ActionKind::DriveObjectCreate, + ActionKind::DriveWorkflowRun, + ActionKind::InputRun, + ] { + let metadata = action.metadata(); + assert!(metadata.authenticated_user.required); + assert_eq!( + metadata.allowed_invocation_contexts, + vec![InvocationContext::InsideWarp] + ); + } } diff --git a/specs/warp-control-cli/PRODUCT.md b/specs/warp-control-cli/PRODUCT.md index 1052c7c38b..793fe0960f 100644 --- a/specs/warp-control-cli/PRODUCT.md +++ b/specs/warp-control-cli/PRODUCT.md @@ -1,518 +1,185 @@ # Summary -Warp should ship an allowlisted standalone local control CLI binary, provisionally named `warpctrl`, that acts as an agent control plane for operating Warp itself. `warpctrl` lets agents and developers script the same classes of user-visible actions they can already perform inside the running app: manipulating windows, tabs, panes, sessions, terminal blocks, appearance, settings, Warp Drive views, and selected UI surfaces. The CLI should operate against one or more already-running local Warp app processes through a stable machine protocol, with deterministic target selection and clear errors when a process or target is ambiguous. -## Problem -Warp already has rich interactive actions, but they are primarily reachable through UI, keybindings, menus, or deeplinks. Agents can use native tools for files, code, shell commands, MCP calls, and many context reads, but they cannot reliably operate Warp's own product surfaces: arranging the user's workspace, focusing the correct pane, opening Warp Drive objects, presenting settings, or recovering from ambiguous UI state. Developers also cannot reliably compose those same actions into shell scripts, demos, automation, or agent workflows, and there is no general local protocol for addressing a specific running Warp instance, window, pane, session, terminal block, Warp Drive object, or other uniquely named Warp entity. -## Goals / Non-goals -Goals: -- Provide a first-class, scriptable standalone `warpctrl` binary for controlling running Warp app processes. -- Make Warp's own UI and app state available to agents through a typed, permissioned control plane instead of brittle screen automation or arbitrary internal dispatch. -- Keep CLI startup lightweight by avoiding GUI-app startup or full terminal initialization for routine control commands. -- Keep the surface allowlisted and finite instead of exposing arbitrary internal actions. -- Make targeting explicit and deterministic across multiple Warp processes, windows, tabs, panes, terminal sessions, terminal blocks, Warp Drive objects, files, projects/workspaces, command surfaces, and other uniquely addressable Warp nouns. -- Support both ergonomic active-target defaults and precise selectors for automation. -- Define a complete protocol/catalog up front, while shipping the implementation incrementally. -Non-goals: -- Replacing the Oz CLI or mixing cloud-agent management into this CLI. -- Exposing every internal app action, debug action, developer-only helper, or privileged state mutation. -- Treating the CLI as a general RPC escape hatch into Warp internals. -- Replacing native agent tools for code editing, file operations, shell execution, web/MCP calls, or attached conversation/block context when those tools already solve the task better. -- Requiring developers or automation to spawn the Warp GUI executable in CLI mode for ordinary control commands. -- Requiring the first implementation slice to ship every action in the catalog. -## Primary user stories -These stories define the most compelling product uses for `warpctrl`. The command catalog below is intentionally broader, but the product should prioritize surfaces that agents cannot already operate well through native tools. -1. **Agent workspace orchestration.** When a user asks an agent to work on a task, the agent can inspect the current Warp state, create or reuse an appropriate window/tab layout, split panes, name and focus targets, open relevant Warp surfaces, and leave the workspace in a readable task-shaped state for the user. The agent should continue to use native tools for code edits, file reads/writes, shell execution, MCP calls, and other work that does not require operating Warp's UI or local-control permission model. -2. **Existing-session debugging and repair.** When a user asks for help with an existing Warp session, the agent can understand Warp-specific UI and session structure before acting: which instance/window/tab/pane/session is active, whether the relevant pane still exists, whether the correct surface is focused, which panels or settings pages are open, and which selector should be used for follow-up actions. The story should focus on UI/session structure, focus, panels, settings, and deterministic targeting; native agent context tools should remain the preferred way to read attached blocks, conversations, and other content when they are available. -3. **Warp Drive creation, navigation, and sharing.** When an agent notices reusable knowledge during normal work, it can help the user turn that knowledge into a Warp Drive object, open it for review, and guide sharing with the right scope. This includes workflows from repeated command sequences, notebooks from task writeups, prompts/rules/facts from user or project preferences, environment variable collections, MCP setup objects, folders, and spaces. Existing object navigation remains important, but creation and sharing are first-class because reusable team knowledge cannot be used until users are guided into creating it. -4. **Deterministic demos and walkthroughs.** When a user, teammate, or go-to-market workflow needs a reliable Warp demo, an agent or script can put Warp into a known presentation state: theme, zoom, windows, tabs, panes, focused targets, panels, command palette/search, and Warp Drive surfaces. The walkthrough can then advance using structured target IDs and recover from stale or missing targets instead of relying on screen coordinates, manual setup, or brittle UI automation. -5. **Personalization, onboarding, and preference migration.** When a user wants Warp to feel familiar, an agent can inspect user-approved settings from tools such as VS Code, iTerm, Ghostty, or shell configuration, propose Warp equivalents, apply allowlisted changes through `warpctrl`, and report unsupported mappings explicitly instead of guessing. The same flow can support team onboarding presets, presentation preferences, accessibility-related settings, themes, font and zoom, keybindings, notifications, and panels. -Human power-user scripting is a secondary beneficiary of the same design. Scripts get reliable JSON, target selectors, structured errors, and permission categories because the API is strong enough for agents, but the primary product narrative remains agent-led operation of Warp itself. -Persistent settings changes, Warp Drive creation or sharing, cross-app preference migration, terminal command execution, and other underlying-data mutations must be visibly reviewable or require stronger explicit permission than low-risk workspace organization. `warpctrl` should support full typed control over time, but each command must be progressively unlocked through action categories, target resolution, Agent Profile permissions, Scripting settings, and authenticated-user requirements rather than broad unchecked authority. -## Behavior -1. The Warp control CLI operates only on running local Warp app processes. If no compatible Warp process is available, the CLI exits non-zero with a clear “no running Warp instance found” error. -2. The CLI exposes only explicitly allowlisted actions. Unknown action names, unsupported parameter combinations, or requests for non-allowlisted capabilities fail with structured errors; they are never forwarded to arbitrary internal dispatch. -3. Every successful mutating request identifies: - - The Warp process instance that executed it. - - The resolved target, when the action addresses a window, tab, pane, terminal session, terminal block, file, project/workspace, Warp Drive object, surface, or other targetable noun. - - A success payload suitable for JSON output. -4. Every failure identifies: - - A stable machine-readable error code. - - A human-readable explanation. - - Any selector that was ambiguous, missing, stale, unsupported, or invalid. -5. The CLI supports human-readable output by default and JSON output for scripts. JSON output has stable field names and is available for discovery commands, read commands, successful mutations, and failures. -6. The CLI supports process discovery and instance selection: - - `warpctrl instance list` returns all reachable local Warp app processes that support the protocol. - - Each process has an opaque `instance_id`, a channel/build identity, and enough display metadata for a developer to choose it. - - If exactly one compatible process is available, commands may target it implicitly. - - If multiple compatible processes are available, the CLI may select a single clearly active/frontmost instance when that state is unambiguous; otherwise it fails and asks the developer to pass an explicit instance selector. - - Developers can explicitly choose an instance by opaque instance ID. Channel or PID filters may be offered as convenience filters, but opaque instance ID is the canonical selector. -7. The CLI supports introspection for target discovery: - - `warpctrl window list` - - `warpctrl tab list` - - `warpctrl pane list` - - `warpctrl session list` - - `warpctrl block list` - - `warpctrl drive list` - - `warpctrl app active` - These commands return opaque protocol-facing IDs and enough metadata for subsequent commands without requiring knowledge of internal Warp identifiers. -8. The target selector model is hierarchical: - - Instance selector resolves a running Warp process. - - Window selector resolves within the instance. - - Tab selector resolves within the window. - - Pane selector resolves within the tab or active pane group context. - - Session selector resolves within the pane when the pane hosts terminal session state. - - Block selector resolves within the terminal session when the command is block-scoped. - Non-hierarchical selectors such as file paths, projects/workspaces, Warp Drive objects, and global app surfaces still resolve inside the selected instance and must not silently borrow lower-level pane/session defaults unless the action definition explicitly requires that scope. -9. Every selector family supports an ergonomic `active` form when that concept exists: - - Active instance, if unambiguous. - - Active window in the selected instance. - - Active tab in the selected window. - - Active pane in the selected tab. - - Active session in the selected pane. - - Active or selected terminal block in the selected session when a current block is unambiguous. -10. Every selector family supports explicit opaque IDs returned by introspection. Selector families may also support scoped indices, titles/names, or paths where those concepts are already user-visible, but IDs remain the preferred automation surface. - - Window selectors support `active`, opaque window IDs, window indices from `window list`, and exact window titles for interactive use. - - Tab selectors support `active`, opaque tab IDs, tab indices scoped to the resolved window, and exact tab titles for interactive use. - - Pane selectors support `active`, opaque pane IDs, and pane indices scoped to the resolved tab or pane group. - - Session selectors support `active`, opaque session IDs, and session indices scoped to the resolved pane when sessions are user-visible as an ordered list. - - Block selectors support `active`, opaque block IDs, and block indices scoped to the resolved terminal session when blocks are user-visible as an ordered list. A block command may also support read-only filters such as command text, status, time range, or “last completed” for interactive lookup, but those filters must fail on ambiguity and resolve to concrete block IDs before reading output. - - File selectors use paths, plus optional line/column coordinates where the command supports opening a location. - - Project/workspace selectors use paths, opaque project/workspace IDs when exposed by introspection, and exact names only as interactive convenience selectors. - - Warp Drive selectors use opaque object IDs, with optional type-scoped exact name/path lookups for interactive use. Type scopes must include the user-facing object families Warp exposes today: spaces, folders, notebooks, workflows, agent-mode workflows/prompts, environment variable collections, AI facts/rules, MCP servers, MCP server collections, and trash entries when trash operations are supported. -11. “Active session” means the currently selected terminal session for the resolved pane/window context. If the selected target does not contain a terminal session, session-scoped actions fail rather than silently redirecting elsewhere. -12. When a command omits lower-level selectors, it resolves them from the chosen higher-level context using active defaults. Example: a pane split command with only `--instance` uses that instance’s active window, active tab, and active pane. -13. When an explicitly supplied target disappears between discovery and execution, the request fails with a stale-target error. The CLI must not silently choose a different tab, pane, or session. -14. The protocol is command-oriented, not open-ended state mutation. Each action has a named command, validated parameters, and defined target scope. -15. The complete allowlisted action catalog should be organized around stable public nouns rather than internal view/action names. The target taxonomy includes instances, windows, tabs, panes, terminal sessions, terminal blocks, input buffers, command history entries, file/path intents, projects/workspaces, Warp Drive spaces, folders, notebooks, workflows, agent-mode workflows/prompts, environment variable collections, AI facts/rules, MCP servers, MCP server collections, settings, themes, keybindings, command surfaces such as the command palette and command search, panels/surfaces such as Warp Drive, resource center, AI assistant, code review, left/right panels, and vertical tabs, plus action/capability metadata. The initial implementation may expose only a subset, but new command families should extend this noun taxonomy instead of inventing unrelated selector conventions. -16. Discovery and read-only state actions: - - List instances. - - Get protocol/app version information for one instance. - - List windows, tabs, panes, and sessions. - - Get the currently active instance/window/tab/pane/session chain when available. - - Inspect enough target metadata to let a script decide what to address next. -17. Window actions: - - Create a new window. - - Focus a target window. - - Close a target window. -18. Tab actions: - - Create a new terminal tab. - - Create a new agent tab where that is already a user-visible app capability. - - Activate a target tab. - - Activate previous, next, or last tab. - - Move a target tab left or right. - - Rename or reset a tab title. - - Set or clear active-tab color where that is already supported in the UI. - - Close the active tab, a target tab, other tabs, or tabs to the right of a target tab. -19. Pane actions: - - Split a target pane left, right, up, or down. - - Optionally choose the shell/session profile for split operations when that already maps to user-facing behavior. - - Focus a target pane. - - Navigate focus left, right, up, or down among panes. - - Close a target pane. - - Toggle maximize for a target pane. - - Resize pane dividers left, right, up, or down when that is supported by the app. -20. Session and terminal-input actions: - - Cycle to the previous or next session where the app exposes session cycling. - - Insert text into the active input without executing it. - - Replace the active input buffer. - - Clear the active input buffer where that matches existing user behavior. - - Switch input mode between terminal and agent modes only where that mode switch is already user-visible and valid for the selected target. - Input staging commands must not submit terminal input or press Enter. The separate `input run` execution action may submit a command only in the later execution-underlying branch, after authenticated scripting identity, underlying-data-mutation permission, audit coverage, and explicit target resolution are implemented. Accepted-command submission and agent-prompt submission remain future protocol concepts that require separate product/security review. -21. Appearance actions: - - List available themes. - - Set the current fixed theme. - - Toggle or set “follow system theme.” - - Set the light and dark themes used when following the system theme. - - Increase, decrease, or reset font size. - - Increase, decrease, or reset UI zoom. - - Set other allowlisted appearance controls only when they correspond to stable user-facing controls. -22. Settings actions: - - Read allowlisted user-facing settings. - - Set allowlisted settings to validated values. - - Toggle allowlisted boolean settings. - - Reject attempts to mutate private, debug-only, unsafe, derived, or unsupported settings. - - Return a stable error when a named setting exists internally but is not part of the public local-control allowlist. -23. The settings allowlist should initially cover settings families that are already plainly user-facing and valuable for scripting: - - Theme/system-theme configuration. - - Font/zoom-related controls. - - Notifications. - - Syntax highlighting and error-underlining toggles. - - Accessibility verbosity where exposed to users. - - Selected panel/layout toggles when the user-facing behavior is already stable. - Additional settings families can be added only by extending the allowlist. -24. Panel and surface actions: - - Open the general settings surface. - - Open a specific settings page or settings search result. - - Open or toggle the command palette with an optional initial query where the app already supports query seeding. - - Open or toggle command search where that is already user-visible. - - Toggle or open the left panel, Warp Drive surface, right panel, resource center, AI assistant panel, code review panel, and vertical tabs panel where valid. -25. File/path intent actions may be included when they already mirror existing user-visible deep-link behavior: - - Open a path in a new tab or window. - - Open a repository picker or repo path flow where the current app already supports it. - These should remain allowlisted intent actions rather than arbitrary filesystem RPCs. -26. The following categories are explicitly excluded from the public allowlist even when internal actions exist for them: - - Crash, panic, heap-dump, token-copying, debug-reset, and similar developer/debug helpers. - - Arbitrary auth manipulation outside the explicit authenticated-scripting flows. - - Arbitrary cloud object mutation or broad Warp Drive CRUD outside the typed Drive actions in this spec. - - Arbitrary internal view dispatch by string. - - Arbitrary setting names outside the allowlist. - - Accepted-command submission and agent-prompt submission until they receive a separate product/security review. - Terminal command execution and typed Warp Drive object mutations are no longer excluded from the full product scope, but they belong to later authenticated underlying-data mutation branches and require stronger authenticated-user and permission gates than app-state or settings mutations. Local file content reads, writes, appends, deletes, and other filesystem-content mutations are excluded from the public `warpctrl` catalog; file/path support is limited to app-state intents such as opening a path in Warp and metadata reads of files already open in Warp. -27. CLI command names should be noun-oriented and discoverable. During the provisional standalone-binary phase, the control CLI should expose a `warpctrl ...` command surface: - - `warpctrl instance list` - - `warpctrl app active` - - `warpctrl tab create` - - `warpctrl tab rename --window-id <window_id> --tab-id <tab_id> "Build logs"` - - `warpctrl tab rename --window active --tab-index 0 "Build logs"` - - `warpctrl window close --window-title "Scratch"` - - `warpctrl pane split --direction right` - - `warpctrl pane split --instance <id> --window active --pane active --direction right` - - `warpctrl input replace --session-id <session_id> "cargo check"` - - `warpctrl block output --pane-id <pane_id> --block-id <block_id> --plain` - - `warpctrl theme set "Warp Dark"` - - `warpctrl setting set appearance.themes.system_theme true` - - `warpctrl input insert "cargo check" --replace` - Channelized install names or aliases may vary during packaging. If the product later converges on `warp ...`, update packaging, shell completions, and operator docs together. -28. The wire protocol mirrors the CLI model. A mutating request contains: - - An action name from the allowlist. - - A structured target selector. - - Validated parameters. - A response contains: - - Success/failure status. - - Resolved instance and target metadata. - - Result data or structured error data. -29. The protocol is versioned. Clients must be able to determine whether a running Warp process supports the protocol version and action they intend to call. -30. Multiple running Warp processes are a supported normal case, not an error state. A local stable build and local dev build, or multiple supported local app instances, can coexist; the CLI provides deterministic discovery and addressing rather than assuming one global server. -31. Requests should be scoped to local-user control of the running app, with separate enforcement for actions that require a true logged-in Warp user. A command that fails local authentication, local authorization, execution-context checks, or authenticated-user checks reports that condition explicitly and does not degrade into a less-specific request. -32. If a selected action is valid in general but impossible in the current UI state, the CLI reports a state-specific failure. Examples include: - - Splitting a pane that no longer exists. - - Issuing a session-scoped action against a non-terminal pane. - - Focusing a window that has closed. - - Setting a theme that is not available in that instance. -33. The first `warpctrl` implementation slice should ship the smallest end-to-end vertical slice that proves: - - The current implementation supports outside-Warp local-control requests only; verified inside-Warp requests are specified for future work and rejected until the app-issued terminal proof broker exists. - - Process discovery and target resolution work. - - A standalone CLI binary can reach a running local Warp process without launching or initializing the GUI app. - - `warpctrl tab create` creates a new terminal tab in the selected running instance. - - The command returns a structured success or failure payload suitable for human-readable and JSON output. - The first slice should include the minimum health/introspection commands needed to discover a running instance and exercise `tab.create`. -34. Follow-up PRs should fill out the remaining catalog in parallelizable groups once the protocol, discovery model, target resolution, error model, `tab.create` action path, and standalone `warpctrl` packaging shape have been validated by the first slice. -35. The protocol transport should be designed so that the default target is localhost but the CLI can be extended in the future to target remote URLs (e.g., a Warp instance on another machine or a hosted control endpoint). This is not in scope for the first implementation but should not be precluded by the architecture. -## API command surface -The public `warpctrl` API is organized around nouns that map to stable user-facing entities. Command names are intentionally not a dump of every internal `WorkspaceAction`, `TerminalAction`, keybinding, or command-palette binding. Internal actions inform the catalog, but a command is added only when it has a stable user-facing behavior, typed parameters, deterministic target resolution, and an explicit risk classification. -### State and data taxonomy -The product surface must distinguish what kind of state a command touches. This distinction is part of the public API and the permission model, not just an implementation detail. -- **Metadata reads** inspect app structure or configuration metadata without exposing user content: instances, windows, tabs, panes, sessions, capability metadata, action metadata, keybinding metadata, theme names, setting keys, current project identity, and other structural state. -- **Underlying data reads** expose user content or data-bearing state without changing it: terminal output, block contents, command history, input buffer contents, Warp Drive object contents, AI conversation content, and any other content that could contain user data or secrets. -- **App-state mutations** change visible local Warp UI state without directly changing user data: opening or focusing windows, creating or closing tabs, splitting panes, focusing panes, opening panels, opening command surfaces, opening files in Warp, and editing the input buffer without submitting it. -- **Metadata/configuration mutations** change persistent configuration or metadata, but not primary user content: changing themes, font size, zoom, allowlisted settings, keybindings, tab names, pane names, and tab colors. -- **Underlying data mutations** can change user data or cause external side effects: typed CRUD operations on Warp Drive objects, sharing Warp Drive objects to the user's team through an explicit approved command, inserting content into Warp Drive views, running allowlisted Warp Drive workflows, and running terminal commands through an explicit `input run` action. Accepted-command submission, agent-prompt submission, local file content mutation, arbitrary workflow execution, and arbitrary internal dispatch remain excluded until separately reviewed. -A command that touches multiple categories must require the strongest applicable permission. For example, `file open` is an app-state mutation because it opens a visible Warp editor/view, while `input run` is an underlying data mutation because it executes a command in the target session. -### Targeting flags -All commands that address a running app target accept the same selector flags where meaningful. Generic `--window`, `--tab`, `--pane`, `--session`, and `--block` flags accept the selector grammar below; explicit typed aliases are provided so scripts can avoid string parsing ambiguity: -- `--instance <instance_id>` selects a running Warp process from `warpctrl instance list`. -- `--pid <pid>` is a convenience instance selector and conflicts with `--instance`. -- `--window <active|id:<id>|index:<n>|title:<title>>` selects a window inside the instance. -- `--window-id <id>`, `--window-index <n>`, and `--window-title <title>` are exact aliases for the corresponding `--window ...` forms. -- `--tab <active|id:<id>|index:<n>|title:<title>>` selects a tab inside the resolved window. -- `--tab-id <id>`, `--tab-index <n>`, and `--tab-title <title>` are exact aliases for the corresponding `--tab ...` forms. -- `--pane <active|id:<id>|index:<n>>` selects a pane inside the resolved tab or pane-group context. -- `--pane-id <id>` and `--pane-index <n>` are exact aliases for the corresponding `--pane ...` forms. -- `--session <active|id:<id>|index:<n>>` selects a terminal or agent session inside the resolved pane when the command is session-scoped. -- `--session-id <id>` and `--session-index <n>` are exact aliases for the corresponding `--session ...` forms. -- `--block <active|id:<id>|index:<n>>` selects a terminal block inside the resolved terminal session when the command is block-scoped. -- `--block-id <id>` and `--block-index <n>` are exact aliases for the corresponding `--block ...` forms. -- File commands use path arguments or `--path <path>` where the path is the selected file entity; `--line <n>` and `--column <n>` refine the location when supported. -- Drive commands use object ID arguments or `--drive-id <id>` where the ID is the selected Warp Drive entity; name/path lookup must be type-scoped when supported. -- `--output-format <pretty|json|ndjson|text>` controls output shape and remains globally available. -Within a selector family, specifying more than one form is invalid. For example, `--tab-id` conflicts with `--tab-index`, `--tab-title`, and `--tab`. Omitted lower-level selectors use active defaults only when that active target is unambiguous. Explicit IDs must resolve exactly or fail with `stale_target`; index/title/name/path selectors that match zero targets fail with `missing_target`, and selectors that match multiple targets fail with `ambiguous_target`. -### Read-only command set -The read-only branches should implement the following commands before mutating catalog expansion begins: `zach/warp-cli-readonly-metadata` owns structural metadata reads, and `zach/warp-cli-readonly-data-settings` owns underlying-data reads plus read-only settings/appearance/docs. Read-only does not mean one permission: metadata reads and underlying data reads are separate grant categories. -Metadata and capability reads: +Warp ships `warpctrl` as the standalone local control CLI for operating already-running local Warp app instances. `warpctrl` is an allowlisted, typed control plane for agent and developer workflows that need to inspect or change Warp product surfaces such as windows, tabs, panes, terminal sessions, blocks, settings, appearance, command surfaces, files opened in Warp, projects, and Warp Drive objects. +The public contract is catalog-first: command names, selectors, permissions, result types, and errors are defined up front so implementation shards can add handlers without changing the security stance. +# Product stance +`warpctrl` is the decided binary name. +The initial target is the full allowlisted public catalog, with implementation status advertised per action by the running app. +Authenticated actions require a verified Warp-managed terminal invocation and a logged-in Warp user in the selected app. +External invocations are limited to logged-out-safe local-control actions. +There is no standalone secret-based authenticated external scripting path. +The transport is local same-machine control of running Warp app instances only. Network or hosted control transports are out of scope. +# Goals +- Provide a stable scriptable CLI for operating running Warp app instances without launching the GUI executable in CLI mode. +- Give agents a typed, permissioned way to preserve and organize visible Warp UI context instead of relying on brittle screen automation or arbitrary internal dispatch. +- Support deterministic targeting across instances, windows, tabs, panes, sessions, terminal blocks, visible file/path intents, projects, surfaces, and Warp Drive objects. +- Keep every action allowlisted, named by public product nouns, classified by state/data category, permission category, authenticated-user requirement, allowed invocation context, and implementation status. +- Preserve native-tools-first boundaries: agents should still use native file, shell, web, MCP, and conversation tools when those tools are the better surface. +# Non-goals and excluded surfaces +- Replacing the Oz CLI or mixing cloud-agent management into `warpctrl`. +- Exposing arbitrary internal action dispatch, raw view dispatch, debug helpers, crash/panic helpers, heap dumps, token-copying helpers, or broad developer-only commands. +- Mutating local filesystem data through `warpctrl`; file/path support is limited to visible Warp app intents such as opening a path and listing files already open in Warp. +- Submitting accepted commands, submitting agent prompts, or causing agent execution through this catalog. +- Arbitrary ACL editing, public sharing, guest sharing, or broad sharing policy mutation. The catalog may include the typed personal-to-team Drive sharing action only because it maps to a constrained Warp Drive product flow. +- Standalone secret-based authenticated external scripting. +- Network control endpoints or hosted control URLs. +# User stories +## Agent workspace orchestration +An agent can inspect visible Warp structure, choose or create an appropriate workspace layout, focus or name targets, open relevant surfaces, and leave Warp in a readable task-shaped state for the user. +## Existing-session debugging and repair +An agent can inspect which instance, window, tab, pane, session, block, and surface are active or targetable before applying focus, layout, panel, or settings changes. +## Warp Drive navigation and typed operations +An agent can list, inspect, open, create, update, insert, delete, share to the current team, or run approved Warp Drive objects only through typed catalog actions with authenticated Warp-terminal grants where required. +## Demos and walkthroughs +A script or agent can put Warp into a known presentation state: theme, zoom, window/tab/pane layout, focused targets, panels, command surfaces, and Warp Drive views. +## Personalization and onboarding +An agent can inspect user-approved preferences, propose Warp equivalents, and apply allowlisted settings, appearance, keybinding, layout, and surface changes with explicit permission categories. +# Targeting model +Selectors are deterministic and hierarchical where the UI hierarchy is hierarchical: +- Instance: active, opaque instance ID, or PID convenience filter. +- Window: active, opaque ID, scoped index, or exact title. +- Tab: active, opaque ID, scoped index, or exact title. +- Pane: active, opaque ID, or scoped index. +- Session: active, opaque ID, or scoped index. +- Block: active/current where unambiguous, opaque ID, or scoped index. +- File/path: path plus optional line and column for visible app intents. +- Project/workspace: path, opaque project/workspace ID, or exact name where exposed. +- Drive object: opaque ID, with type-scoped exact lookup for interactive use. +Active defaults are allowed only when unambiguous. Explicit IDs must resolve exactly or fail with `stale_target`. Missing active targets fail with `missing_target`. Ambiguous selectors fail with `ambiguous_target`. Commands must not silently retarget to a nearby instance, tab, pane, session, file, or Drive object. +# State/data categories and permissions +Every action belongs to exactly one category: +- Metadata reads: structure and non-content metadata such as instances, windows, tabs, panes, sessions, capabilities, actions, settings keys, themes, keybindings, project identity, and Drive object IDs/names/types. +- Underlying data reads: terminal block output, input buffer contents, history, Drive object contents, AI conversation content, or other user data. +- App-state mutations: visible UI state such as focus, layout, panels, surface opens, file/project opens, and input-buffer staging without execution. +- Metadata/configuration mutations: persistent metadata or configuration such as titles, tab colors, themes, zoom, font size, keybindings, and allowlisted settings. +- Underlying data mutations: terminal execution through explicit `input.run`, typed Warp Drive CRUD/insert/share-to-team/workflow-run operations, and other actions that can change user data or cause external side effects. +A command that touches multiple categories requires the strongest applicable permission. +# Public action catalog +## Instance, app, capability, and action metadata +- `instance.list` +- `instance.inspect` +- `app.ping` +- `app.version` +- `app.active` +- `app.focus` +- `capability.list` +- `capability.inspect` +- `action.list` +- `action.inspect` +## Auth status and app login routing +- `auth.status` +- `auth.login` +These actions report local/authenticated grant availability or open the selected app's normal sign-in UI. They do not create a standalone external secret identity. +## Windows, tabs, panes, and sessions +- `window.list` +- `window.inspect` +- `window.create` +- `window.focus` +- `window.close` +- `tab.list` +- `tab.inspect` +- `tab.create` +- `tab.activate` +- `tab.move` +- `tab.close` +- `tab.rename` +- `tab.reset_name` +- `tab.color.set` +- `tab.color.clear` +- `pane.list` +- `pane.inspect` +- `pane.split` +- `pane.focus` +- `pane.navigate` +- `pane.resize` +- `pane.maximize` +- `pane.unmaximize` +- `pane.close` +- `pane.rename` +- `pane.reset_name` +- `session.list` +- `session.inspect` +- `session.activate` +- `session.previous` +- `session.next` +- `session.reopen_closed` +## Blocks, input, and history +- `block.list` +- `block.inspect` +- `block.output` +- `input.get` +- `input.insert` +- `input.replace` +- `input.clear` +- `input.mode.set` +- `input.run` +- `history.list` +Input insert/replace/clear/mode commands stage visible input only. `input.run` is the only terminal execution action and requires authenticated Warp-terminal authority plus underlying-data-mutation permission. +## Appearance, settings, and keybindings +- `theme.list` +- `theme.get` +- `theme.set` +- `theme.system.set` +- `theme.light.set` +- `theme.dark.set` +- `appearance.get` +- `appearance.font_size.increase` +- `appearance.font_size.decrease` +- `appearance.font_size.reset` +- `appearance.zoom.increase` +- `appearance.zoom.decrease` +- `appearance.zoom.reset` +- `setting.list` +- `setting.get` +- `setting.set` +- `setting.toggle` +- `keybinding.list` +- `keybinding.get` +Settings mutations are protocol actions handled by the running app. `warpctrl` must not bypass the app by editing settings files directly. +## Surfaces +- `surface.settings.open` +- `surface.command_palette.open` +- `surface.command_search.open` +- `surface.warp_drive.open` +- `surface.warp_drive.toggle` +- `surface.resource_center.toggle` +- `surface.ai_assistant.toggle` +- `surface.code_review.toggle` +- `surface.left_panel.toggle` +- `surface.right_panel.toggle` +- `surface.vertical_tabs.toggle` +## Files and projects +- `file.list` +- `file.open` +- `project.active` +- `project.list` +- `project.open` +These actions address Warp-visible app/editor/project state only. +## Warp Drive +- `drive.list` +- `drive.inspect` +- `drive.open` +- `drive.notebook.open` +- `drive.env_var_collection.open` +- `drive.object.share.open` +- `drive.object.create` +- `drive.object.update` +- `drive.object.delete` +- `drive.object.insert` +- `drive.object.share_to_team` +- `drive.workflow.run` +Drive metadata listing requires authenticated user state. Drive content reads and mutations require authenticated Warp-terminal authority and the corresponding data-read or data-mutation permission. +# CLI shape +The CLI command hierarchy is noun-oriented and mirrors the action names: - `warpctrl instance list` -- `warpctrl instance inspect [--instance <id>|--pid <pid>]` -- `warpctrl app ping [selectors]` -- `warpctrl app version [selectors]` -- `warpctrl app active [selectors]` -- `warpctrl capability list [selectors]` -- `warpctrl capability inspect <action> [selectors]` -Window, tab, pane, and session reads: -- `warpctrl window list [selectors]` -- `warpctrl window inspect [--window <selector>] [selectors]` -- `warpctrl tab list [--window <selector>] [selectors]` -- `warpctrl tab inspect [--tab <selector>] [selectors]` -- `warpctrl pane list [--tab <selector>] [selectors]` -- `warpctrl pane inspect [--pane <selector>] [selectors]` -- `warpctrl session list [--pane <selector>] [selectors]` -- `warpctrl session inspect [--session <selector>] [selectors]` -Underlying data reads, gated separately from structural metadata reads: -- `warpctrl block list [--session <selector>|--pane <selector>] [--limit <n>] [selectors]` -- `warpctrl block inspect --block <selector> [selectors]` -- `warpctrl block output --block <selector> [--plain|--ansi|--json] [selectors]` -- `warpctrl input get [--session <selector>] [selectors]` -- `warpctrl history list [--session <selector>] [--limit <n>] [selectors]` -Appearance, settings, and command-surface reads: -- `warpctrl theme list [selectors]` -- `warpctrl theme get [selectors]` -- `warpctrl appearance get [selectors]` -- `warpctrl setting list [--namespace <namespace>] [selectors]` -- `warpctrl setting get <key> [selectors]` -- `warpctrl keybinding list [selectors]` -- `warpctrl keybinding get <binding_name> [selectors]` -- `warpctrl action list [selectors]` -- `warpctrl action inspect <action> [selectors]` -Local file and project reads that expose only app/editor state, not arbitrary filesystem traversal: -- `warpctrl file list [selectors]` -- `warpctrl project active [selectors]` -- `warpctrl project list [selectors]` -Authenticated read-only Warp Drive metadata and data reads, enabled only when the selected app has a logged-in Warp user and the grant allows authenticated reads. Listing is metadata; inspecting object content is an underlying data read: -- `warpctrl drive list --type <workflow|notebook|env-var-collection|prompt|folder|ai-fact|mcp-server|space|trash> [selectors]` -- `warpctrl drive inspect <id> [selectors]` -### Authenticated scripting command set -The full product requires two authenticated scripting modes before high-risk underlying-data mutations ship: -- **Verified Warp-terminal authenticated scripting:** `warpctrl` runs inside a Warp-managed terminal, presents the app-issued terminal proof described in `TECH.md`, and may receive authenticated-user grants only when the selected app is logged into Warp and Settings > Scripting allows authenticated actions from verified Warp terminals. -- **External API-key authenticated scripting:** `warpctrl` runs outside Warp or in a pure automation environment and presents a Warp-issued API key or derived short-lived exchange token to the selected app's local broker. The broker verifies the key, scopes, expiry, and user subject before issuing local authenticated-user grants. This path is separate from the local-control bearer credential and is required for unattended scripts that need Drive or execution authority. -Recommended CLI surface for API-key setup and inspection: -- `warpctrl auth status [selectors]` reports local-control auth state, selected app login state, authenticated grant availability, and whether an external API-key identity is configured. -- `warpctrl auth login [selectors]` focuses the selected Warp app's sign-in UI for interactive app-login flows. -- `warpctrl auth api-key set --key-env <env_var>|--key-stdin [selectors]` stores or references an external scripting API key in platform secure storage without printing it. -- `warpctrl auth api-key status [selectors]` reports key subject/scope metadata without revealing the key. -- `warpctrl auth api-key revoke [selectors]` deletes the local stored reference and, where supported, revokes the server-side key. -The API-key path must support non-interactive scripts through an environment variable or secret manager reference, but raw keys must never be written to discovery records, logs, JSON output, shell completions, or repo config. -### Mutating command set -The mutating branches should build on the read-only and authenticated-scripting stack. `zach/warp-cli-mutating-layout` owns app/window/tab/pane layout mutations. `zach/warp-cli-mutating-input-settings-surfaces` owns input/session/settings/surface mutations. `zach/warp-cli-mutating-drive-data` owns Warp Drive underlying-data mutations. `zach/warp-cli-mutating-execution-underlying` owns terminal command execution and other approved execution-underlying actions. Mutating commands are split by what they mutate: app-state, metadata/configuration, or underlying data. Underlying data mutations require a separate and stronger permission plus an authenticated scripting identity. -App-state mutations for app, window, and surfaces: -- `warpctrl app focus [selectors]` -- `warpctrl window create [--shell <name>] [selectors]` -- `warpctrl window focus --window <selector> [selectors]` -- `warpctrl window close --window <selector> [selectors]` -- `warpctrl surface settings open [--page <page>] [--query <query>] [selectors]` -- `warpctrl surface command-palette open [--query <query>] [selectors]` -- `warpctrl surface command-search open [--query <query>] [selectors]` -- `warpctrl surface warp-drive open [selectors]` -- `warpctrl surface warp-drive toggle [selectors]` -- `warpctrl surface resource-center toggle [selectors]` -- `warpctrl surface ai-assistant toggle [selectors]` -- `warpctrl surface code-review toggle [selectors]` -- `warpctrl surface left-panel toggle [selectors]` -- `warpctrl surface right-panel toggle [selectors]` -- `warpctrl surface vertical-tabs toggle [selectors]` -App-state mutations for tabs: -- `warpctrl tab create [--type terminal|agent|cloud-agent|default] [--shell <name>] [selectors]` -- `warpctrl tab activate --tab <selector> [selectors]` -- `warpctrl tab activate --previous [selectors]` -- `warpctrl tab activate --next [selectors]` -- `warpctrl tab activate --last [selectors]` -- `warpctrl tab move --tab <selector> --direction <left|right> [selectors]` -- `warpctrl tab close --tab <selector> [selectors]` -- `warpctrl tab close --active [selectors]` -- `warpctrl tab close --others --tab <selector> [selectors]` -- `warpctrl tab close --right-of --tab <selector> [selectors]` -Metadata mutations for tabs: -- `warpctrl tab rename --tab <selector> <title> [selectors]` -- `warpctrl tab reset-name --tab <selector> [selectors]` -- `warpctrl tab color set --tab <selector> <color> [selectors]` -- `warpctrl tab color clear --tab <selector> [selectors]` -App-state mutations for panes: -- `warpctrl pane split --direction <left|right|up|down> [--shell <name>] [selectors]` -- `warpctrl pane focus --pane <selector> [selectors]` -- `warpctrl pane navigate --direction <left|right|up|down|previous|next> [selectors]` -- `warpctrl pane resize --direction <left|right|up|down> [--amount <cells>] [selectors]` -- `warpctrl pane maximize [--pane <selector>] [selectors]` -- `warpctrl pane unmaximize [selectors]` -- `warpctrl pane close --pane <selector> [selectors]` -Metadata mutations for panes: -- `warpctrl pane rename --pane <selector> <title> [selectors]` -- `warpctrl pane reset-name --pane <selector> [selectors]` -App-state mutations for sessions and input buffers: -- `warpctrl session activate --session <selector> [selectors]` -- `warpctrl session previous [selectors]` -- `warpctrl session next [selectors]` -- `warpctrl session reopen-closed [selectors]` -- `warpctrl input insert <text> [--session <selector>] [selectors]` -- `warpctrl input replace <text> [--session <selector>] [selectors]` -- `warpctrl input clear [--session <selector>] [selectors]` -- `warpctrl input mode set <terminal|agent> [--session <selector>] [selectors]` -These input-buffer commands only stage or edit text and must not submit the buffer. The separate `input run` command belongs only to the execution-underlying branch and requires authenticated scripting identity, underlying-data-mutation permission, explicit target resolution, and audit coverage. Accepted-command submission and agent-prompt submission remain excluded until separately reviewed. -Metadata/configuration mutations for appearance and settings: -- `warpctrl theme set <theme_name> [selectors]` -- `warpctrl theme system set <true|false> [selectors]` -- `warpctrl theme light set <theme_name> [selectors]` -- `warpctrl theme dark set <theme_name> [selectors]` -- `warpctrl appearance font-size increase [selectors]` -- `warpctrl appearance font-size decrease [selectors]` -- `warpctrl appearance font-size reset [selectors]` -- `warpctrl appearance zoom increase [selectors]` -- `warpctrl appearance zoom decrease [selectors]` -- `warpctrl appearance zoom reset [selectors]` -- `warpctrl setting set <key> <value> [selectors]` -- `warpctrl setting toggle <key> [selectors]` -App-state mutations for files, projects, and Warp Drive views: -- `warpctrl file open <path> [--line <line>] [--column <column>] [--new-tab] [selectors]` -- `warpctrl project open <path> [selectors]` -- `warpctrl drive open <id> [selectors]` -- `warpctrl drive notebook open <id> [selectors]` -- `warpctrl drive env-var-collection open <id> [selectors]` -- `warpctrl drive object share open <id> [selectors]` -Underlying data mutations for authenticated Warp Drive objects: -- `warpctrl drive object create --type <workflow|notebook|env-var-collection|prompt|folder> [--content <text>|--content-file <path>] [selectors]` -- `warpctrl drive object update <id> [--content <text>|--content-file <path>] [selectors]` -- `warpctrl drive object delete <id> [selectors]` -- `warpctrl drive object insert <id> [--target <selector>] [selectors]` -- `warpctrl drive object share-to-team <id> [selectors]` -- `warpctrl drive workflow run <id> [--arg <name=value>...] [selectors]` -Execution-underlying actions: -- `warpctrl input run <command> [--session <selector>] [selectors]` -These are underlying-data mutations because they can modify user data, execute code, trigger external side effects, share cloud-backed content, or run user-authored content. They require authenticated scripting identity, underlying-data-mutation permission, deterministic target resolution, audit records, and explicit tests proving lower-permission credentials cannot run them. `drive object share-to-team` is the only direct sharing mutation in the v0 product scope: it may make a personal Warp Drive object available to the user's current team using the app's standard team-sharing semantics. Arbitrary ACL editing, sharing with specific users, sharing with external guests, public-link creation, accepted-command submission, and agent-prompt submission remain excluded until separately reviewed. -### Excluded from the public command surface -The command surface must continue to exclude debug-only, crash-only, auth-token, heap-dump, and arbitrary internal dispatch actions even when those actions are available in command palette or keybinding registries. Examples that remain excluded are app crash/panic helpers, access-token copy helpers, heap profile dumps, debug reset actions, raw view-tree debugging, and broad internal action-by-string execution. -## Branch stacking and delivery model -The Warp Control CLI work should ship as a raw-git branch stack so the combined specs/foundation slice, read-only expansion, and mutating expansion remain reviewable independently: -- `zach/warp-cli-core-foundation` is the bottom review branch and targets `master`. It owns `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs alongside the first implementation slice: shared protocol, discovery/auth scaffolding, outside-Warp Settings > Scripting gates, local-control bridge/server, standalone `warpctrl` binary, packaging hooks, and the smallest safe end-to-end action. Verified inside-Warp invocation is documented for future implementation but is not supported by this branch. -- `zach/warp-cli-readonly-metadata` stacks on `zach/warp-cli-core-foundation` and implements structural metadata reads, including instance/app health, active-chain, windows, tabs, panes, sessions, and action metadata. -- `zach/warp-cli-readonly-data-settings` stacks on `zach/warp-cli-readonly-metadata` and fills in underlying-data reads plus read-only settings/appearance/docs, including terminal block output, input-buffer reads, history reads, and allowlisted settings metadata. -- `zach/warp-cli-authenticated-scripting` stacks on `zach/warp-cli-readonly-data-settings` and implements authenticated-user grant plumbing for both verified Warp-terminal invocations and external API-key scripting identities. It does not broaden action support by itself; it makes later high-risk branches enforceable. -- `zach/warp-cli-mutating-layout` stacks on `zach/warp-cli-authenticated-scripting` and implements app/window/tab/pane layout mutations. -- `zach/warp-cli-mutating-input-settings-surfaces` stacks on `zach/warp-cli-mutating-layout` and fills in approved input/session/settings/surface mutating command families while preserving the prohibition on accepted-command submission and agent-prompt submission. -- `zach/warp-cli-mutating-drive-data` stacks on `zach/warp-cli-mutating-input-settings-surfaces` and implements authenticated Warp Drive underlying-data mutations from the approved allowlist, including object creation/update/delete/insert and the v0 personal-to-team sharing path. -- `zach/warp-cli-mutating-execution-underlying` stacks on `zach/warp-cli-mutating-drive-data` and implements authenticated execution-underlying actions such as `input run` and typed workflow execution where supported. -The previous `zach/warp-cli-specs` branch is retained only as migration-source/history material. New spec changes originate on `zach/warp-cli-core-foundation` and are propagated upward through the stack with raw git so all higher implementation branches reflect the same product/security contract. Graphite is not part of this stack. If a lower branch merges first, higher branches should rebase onto the merged successor while preserving the approved spec content. -## Built-in Warp Agent skill -Warp should include a built-in Agent skill for `warpctrl`, analogous to the bundled `oz-platform` skill. The skill should teach Warp Agent when to use `warpctrl`, how to discover and target running instances, how to prefer read-only commands before mutating commands, how to request explicit user approval for underlying data mutations, and how to interpret structured errors. The skill should document the stable command hierarchy above, include concise recipes for common automation tasks, and avoid instructing agents to bypass the CLI by calling local-control HTTP endpoints directly. -## CLI implementation and documentation conventions -`warpctrl` should feel consistent with the Oz CLI from a developer's perspective and use the same CLI libraries and conventions: -- Argument parsing, subcommand structure, help text, and shell-completion generation should use the same `clap`/`clap_complete` patterns used by the Oz CLI. -- JSON serialization and machine-readable output should use the same `serde`/`serde_json` conventions and the same output-format vocabulary used by the Oz CLI. -- Human-readable help, examples, errors, and generated completions should follow Oz CLI conventions unless Warp Control has a documented product reason to differ. -CLI documentation should be generated from the command catalog instead of maintained by hand in multiple places: -- The typed action catalog is the source of truth for command names, selector flags, parameters, output formats, state/data category, required permission, authenticated-user requirement, support status, and examples. -- `warpctrl help`, shell completions, markdown reference docs, the built-in Warp Agent skill, and the operator README should be generated or checked from that catalog so they cannot drift silently. -- A later branch should add native Warp completions for `warpctrl` in addition to shell completions so Warp can suggest commands, flags, selectors, and action names directly in the input editor. -- Generated documentation must distinguish implemented commands from planned catalog entries. A command may appear in specs as planned, but public operator docs must not imply it is usable until the selected app build advertises support for it. -- CI or presubmit checks should fail when CLI parser/help output, generated reference docs, completions, or the built-in skill are stale relative to the command catalog. -## Action classification and permission model -Agents, scripts, and human developers are expected to be major consumers of `warpctrl`. The action catalog must therefore classify every action by risk posture, state/data category, permission category, and authenticated-user requirement so Warp can enforce local-control permissions in the app bridge. -Every action definition must include: -- a stable action name and namespace; -- a risk posture; -- a state/data category: metadata read, underlying data read, app-state mutation, metadata/configuration mutation, or underlying data mutation; -- whether a true logged-in Warp user is required; -- whether the action may run from external clients, verified Warp-terminal clients, or both; -- whether inside-Warp and outside-Warp scripting settings can enable the action; -- the required local-control permission category; -- any target-scope restrictions. -By default, new actions require an authenticated Warp user. An action may be marked logged-out-safe only after deliberate review confirms it does not touch Warp Drive, AI conversation traces, synced settings, team/account data, cloud-backed user state, terminal content, or other user-sensitive data. -### Authenticated scripting model -Authenticated scripting is required for any command that acts on a true Warp user identity or performs underlying-data mutation. Local-control credentials prove that a process may talk to the selected app; authenticated scripting credentials prove which Warp user or automation identity is allowed to request user-backed or high-risk actions. -Inside Warp, authenticated scripting uses the verified terminal proof flow: the selected app is already logged in, the terminal proof binds the CLI to a live Warp-managed session, and the broker may mint an authenticated-user grant for that app user when Settings > Scripting allows it. -Outside Warp, authenticated scripting uses a Warp-issued API key or exchanged short-lived token. The API key must be scoped for scripting/local control, optionally constrained to action categories or resource families, and tied to a Warp user subject. The selected app must either be logged in as the same subject or be able to validate that the API key's subject is authorized for the requested local action without exporting cloud auth tokens to the script. External API-key grants default off in Settings > Scripting and should be separable from ordinary outside-Warp logged-out-safe control. -### Permission categories -Every action in the catalog belongs to exactly one of the following permission categories, from least to most sensitive: -1. **Read-only / metadata.** Actions that return local app structure, app state, or configuration metadata without exposing terminal content, file content, Warp Drive object content, AI conversation content, or other user data. - - Instance discovery and health: `instance list`, `app active`, `app version`, `app ping`. - - Layout enumeration: `window list`, `tab list`, `pane list`, `session list`. - - Metadata reads: `theme list`, `setting list`, `keybinding list`, `action list`, `project active`, and Drive object listing that returns object IDs/names/types but not content. -2. **Read-only / underlying data.** Actions that return user content or data-bearing state without changing it. - - Terminal reads: block output, scrollback, command history, input editor contents, session replay, or terminal-derived traces. - - Warp Drive object content reads, AI conversation reads, and any authenticated-user data read. - This category is separate from metadata because read-only content can contain secrets, source code, customer data, command output, or other sensitive data. -3. **Mutating / app state.** Actions that change visible local Warp UI state without directly changing underlying user data. - - Layout and focus: `window create`, `window focus`, `tab create`, `tab activate`, `tab move`, `window close`, `tab close`, `pane split`, `pane focus`, `pane navigate`, `pane maximize`, `pane resize`, and panel/surface toggles. - - Input-buffer staging: `input insert`, `input replace`, and `input clear` as long as they do not submit or execute the buffer. - - Opening views: opening settings, command palette, command search, Warp Drive, code review, files, projects, notebooks, and env-var collections. -4. **Mutating / metadata or configuration.** Actions that change persistent metadata or configuration but do not directly mutate primary user data. - - Tab and pane names, tab colors, themes, system-theme settings, font size, zoom, allowlisted app settings, and keybindings. - Metadata/configuration writes need a stronger permission than app-state-only changes because they persist beyond the current UI interaction, but they are still distinct from data writes. -5. **Mutating / underlying data.** Actions that can change user data, execute code, submit prompts, or cause external side effects. - - Terminal execution through the explicit `input run` action and typed workflow execution where supported. - - Warp Drive CRUD and sharing: create, update, delete, insert, share to the user's current team, run, or otherwise mutate workflows, notebooks, prompts, env-var collections, folders, or other Drive objects. - - AI conversation history mutation and any action that modifies cloud-backed user content. - - Future agent execution: submitting an agent prompt, accepting an agent-proposed command, or otherwise causing an agent to act; these remain excluded until separately reviewed. - This category must be explicitly separate from app-state mutation and requires authenticated scripting identity. A client allowed to open or focus Warp UI must not automatically be allowed to execute commands, mutate Warp Drive content, or perform local file content operations. -### Authenticated-user requirement -An authenticated user means a true logged-in Warp user in the selected Warp app, not merely the local OS user or a `warpctrl` process authenticated to localhost. -The allowlist must clearly indicate `requires_authenticated_user` for every action: -- `false` only for logged-out-safe actions that operate on local app structure, local appearance metadata, or local-only settings that do not expose user-sensitive data. -- `true` for actions that read or mutate Warp Drive, AI conversation traces, synced settings, team/account data, user identity data, or any cloud-backed Warp state. -- `true` for actions that execute user-authored Warp Drive content, even if the execution target is a local terminal session. -If an authenticated-user action is invoked while the selected app has no logged-in user, the CLI reports a structured authenticated-user error. It must not silently return partial logged-out data as success. -### Warp Control authenticated scripting protocol -`warpctrl` has two authenticated scripting modes. Interactive inside-Warp use relies on the logged-in user in the selected Warp app and verified terminal proof. External or pure scripting use relies on a Warp-issued API key that is separate from local-control credentials and is exchanged for short-lived authenticated grants. -The CLI should expose auth/status flows for both modes: -- `warpctrl auth status [selectors]` reports whether the selected Warp app is logged in and returns a stable, non-secret user subject/identity summary when the caller has the required local-control grant. -- `warpctrl auth login [selectors]` does not collect credentials in the CLI or mint a separate CLI account session. It focuses or opens the selected Warp app's normal sign-in UI and waits, or exits with instructions, until the user completes sign-in in that app. -- After app login completes, the app-side credential broker may mint an app-user grant only for the same user subject that is currently logged in to the selected app. For external API-key mode, the broker may mint an API-key-backed grant only after validating the key, scopes, subject, and local Scripting permissions. -- Authenticated credentials are bound to the selected app instance, subject, grant mode, scopes, expiry, and optional target/resource restrictions. If the app logs out, switches users, loses auth state, or the grant's subject no longer matches a grant that requires the selected app's logged-in subject, authenticated actions fail with a structured authenticated-user error rather than using stale authority. -- Raw Firebase, server, OAuth, cloud API tokens, and raw scripting API keys are never exported to `warpctrl` output, shell scripts, generated docs, logs, discovery records, or JSON responses. -This authenticated scripting protocol applies only to actions whose allowlist entry requires a true logged-in Warp user, external API-key identity, or underlying-data-mutation authority. Logged-out-safe local actions continue to use local-control credentials without requiring Warp account login or API-key setup. -### Execution context policy -`warpctrl` should eventually distinguish verified invocations from inside Warp-managed terminal sessions from external invocations. The current foundation branch supports external invocation only and must reject verified Warp-terminal claims until the proof broker is implemented. -- **Verified Warp-terminal invocation:** a `warpctrl` process started inside a Warp-managed terminal session and able to present an app-issued execution-context proof. The top-level setting for this context should default to on. When the selected app has a logged-in Warp user, this context can receive authenticated-user grants if the user's Scripting permissions allow that grant. -- **External invocation:** a `warpctrl` process started outside Warp's terminal, such as from another terminal app, launch agent, IDE, or background script. The top-level setting for this context must default to off. When disabled, external invocations receive no local-control credentials, including logged-out-safe metadata credentials. -- The app must not trust a caller-declared label. Environment variables may help discover the context, but the broker must verify a session-bound capability or equivalent proof before issuing in-Warp-only grants. -### Settings surface -Warp should add a new top-level Settings pane page named **Scripting**. This page should own settings for local scripting and automation surfaces, including Warp control. The current foundation branch should expose only outside-Warp Warp control settings. In the long-term model, once verified Warp-terminal invocation is implemented, Warp control should include two top-level toggles: -- **Allow Warp control from inside Warp:** default on. Controls `warpctrl` invocations from verified Warp-managed terminal sessions. -- **Allow Warp control from outside Warp:** default off. Controls `warpctrl` invocations from external terminals, scripts, IDEs, launch agents, and other same-user processes. -The Scripting page should explain that inside-Warp control is scoped to commands launched from Warp-managed terminals, while outside-Warp control allows other local apps and scripts to talk to Warp's control plane. Disabling either top-level toggle should invalidate credentials for that invocation context. -### Granular local-control permissions -In the long-term model, the Scripting settings page should expose granular permissions beneath the inside-Warp and outside-Warp toggles. The current foundation branch exposes only the outside-Warp subset. Recommended controls: -- Allow metadata reads. -- Allow underlying data reads. -- Allow app-state mutations. -- Allow metadata/configuration mutations. -- Allow underlying data mutations. -- Allow authenticated-user actions from verified Warp terminals. -- Allow authenticated-user actions from external clients, default off and separate from the in-Warp permission. -These settings define the maximum grants the broker may issue. The app bridge still enforces the action's risk posture, state/data category, authenticated-user requirement, execution-context requirement, and target scope for every request. Enabling app-state mutation must not imply permission to mutate underlying data. -### Agent Profile permissions -Agent Profiles should expose a dedicated **Warp control** permission group for agents that can invoke `warpctrl`. This permission group should mirror the local-control action categories so users can choose different `warpctrl` authority for different agent workflows: -- Metadata reads. -- Underlying data reads. -- App-state mutations. -- Metadata/configuration mutations. -- Underlying data mutations. -Each category should support the same autonomy vocabulary used by other Agent Profile permissions: the agent may be allowed to proceed, required to ask, allowed to decide based on confidence and risk, or denied. A cautious profile can therefore allow metadata reads and ask for app-state mutations, while a demo or onboarding profile can be explicitly configured to allow workspace organization or presentation setup. -Agent Profile permissions and global Scripting settings both apply. Settings > Scripting defines the maximum local-control authority available for an execution context, such as verified inside-Warp invocation or outside-Warp invocation. The selected Agent Profile defines what that specific agent is allowed to request within that maximum. If either layer denies an action category, authenticated-user requirement, or execution context, the request fails with a structured permission error instead of falling back to a weaker action or a raw `warpctrl` shell command. -The profile-level permission group should preserve the native-tools-first boundary. Agents should prefer native tools for code editing, file reads/writes, shell command execution, web/MCP calls, and attached conversation or block context when those tools are available. Agents should prefer `warpctrl` when the task requires operating Warp product surfaces, preserving visible UI context for the user, using Warp Drive as a first-class app surface, or applying the app's own permissioned control plane. -### Scoped credentials -The local discovery record must not expose a reusable full-access credential. `warpctrl` should request scoped credentials from an app-owned broker or equivalent trusted path. -Scoped credentials should include: -- the selected Warp instance; -- granted permission categories; -- allowed action families; -- verified execution context; -- whether authenticated-user access is granted and for which logged-in user subject; -- optional target scopes; -- issuance and expiry metadata; -- revocation/audit identity. -The bridge, not the CLI frontend, enforces these grants. If a request exceeds its credential, the bridge returns `insufficient_permissions`, `authenticated_user_required`, `authenticated_user_unavailable`, or `execution_context_not_allowed` as appropriate. -### Future entity extensibility: files, blocks, and Warp Drive objects -The selector and action model should accommodate entity types beyond the current window/tab/pane/session hierarchy. Important entity families are **terminal blocks**, **file/path intents**, **projects/workspaces**, and **Warp Drive objects**. Broad Drive mutation and command execution are not in scope for the foundation branch, but they are in scope for later authenticated branches in the expanded stack. Local file content reads, writes, appends, deletes, and other filesystem-content mutations are intentionally out of scope for the public `warpctrl` catalog because native agent file tools are the preferred surface for file content operations. Agent-prompt submission remains excluded until separately reviewed. -**Terminal blocks.** Blocks are first-class targetable terminal entities, not just data hanging off a session. Block selectors should support the same addressing primitives as terminal sessions where meaningful: active/current block, opaque block ID, and block index scoped to the resolved session. Block reads can expose command text, output, status, timing, exit code, and metadata, so block content reads are underlying-data reads while block listing that returns only IDs/status/timestamps may be metadata reads. Stale, missing, or ambiguous block selectors must fail rather than selecting a neighboring block. -**Files.** Warp already supports file opening via deep links and the built-in editor. The `file` namespace is limited to app-state and metadata behaviors that operate Warp's visible UI: -- `warpctrl file open <path>` — app-state mutation that opens a file in a Warp editor tab, equivalent to clicking a file link. -- `warpctrl file open <path> --line <n>` — app-state mutation that opens at a specific line. -- `warpctrl file list` — metadata read that lists files currently open in editor tabs across the instance. -- `warpctrl project open <path>` — app-state mutation that opens or focuses a project/workspace in Warp where that matches existing user-visible behavior. -File selectors use filesystem paths (absolute or relative to the working directory of the target pane/session when the command defines that behavior). Unlike window/tab/pane selectors, file selectors are not opaque IDs — they are user-visible paths. The protocol should support a `file` field in the target selector that accepts a path string, distinct from the opaque ID selectors used for windows, tabs, and panes. `warpctrl` must not expose file content reads or filesystem-content mutations; agents and scripts should use native file tools for those operations. -**Warp Drive objects.** Warp Drive stores typed objects that users can reference, execute, edit, and share. The object taxonomy should include, at minimum, spaces, folders, notebooks, workflows, agent-mode workflows/prompts, environment variable collections, AI facts/rules, MCP servers, MCP server collections, and trash entries where trash operations are exposed. A future `drive` namespace could support: -- `warpctrl drive list --type workflow` — authenticated metadata read that lists Warp Drive objects by type. -- `warpctrl drive inspect <id>` — authenticated underlying data read when it returns object content. -- `warpctrl drive workflow run <workflow-id>` — authenticated underlying data mutation that executes a typed workflow in a target session, implemented only in the execution-underlying branch with authenticated scripting identity and audit coverage. -- `warpctrl drive object create|update|trash|restore <id>` — authenticated underlying data mutations that change cloud-backed user content. -- `warpctrl drive object share open <id>` — app-state mutation that opens the sharing dialog for user review without changing sharing state. -- `warpctrl drive object share-to-team <id>` — authenticated underlying data mutation that makes a personal object available to the user's current team using the app's standard team-sharing behavior. This is the only direct sharing mutation in the v0 product scope. -- `warpctrl drive notebook open <notebook-id>` — app-state mutation that opens a view of an existing notebook without modifying it. -Drive object selectors should support both opaque IDs (for automation stability) and human-friendly name/path lookups (for interactive use). The type field (`workflow`, `notebook`, `env_var_collection`, `prompt`, `folder`, `ai_fact`, `mcp_server`, etc.) acts as a namespace filter. Drive actions that execute content in a terminal session (e.g., running a workflow) inherit the underlying-data-mutation permission from the action classification model and are implemented only in the execution-underlying branch after authenticated scripting identity and audit coverage are in place. -**Design constraints for these future entity families:** -- File and Drive selectors are orthogonal to the window/tab/pane hierarchy — a file open action targets an instance (which window to open in), not a specific pane. A Drive workflow execution targets a session (which pane to run in). -- The `TargetSelector` type in the protocol should be extensible with optional fields for these new selector families without breaking existing requests that omit them. -- The action classification categories apply, and Drive actions require authenticated-user grants by default: listing Drive objects is metadata plus authenticated user, reading Drive object content is underlying-data-read plus authenticated user, opening an existing Drive object or its sharing dialog in the app is app-state mutation plus authenticated user, and executing, sharing, or changing a Drive object is underlying-data-mutation plus authenticated user. -### Settings: protocol-first -Settings reads and writes should go through the local-control protocol like other actions, not bypass it via direct file manipulation. -- `warpctrl setting get <key>`, `warpctrl setting set <key> <value>`, and `warpctrl setting toggle <key>` send requests to the running Warp instance through the standard authenticated control endpoint. -- The app bridge validates the key against the allowlist and the value against the expected type before applying the change. -- This keeps authorization enforcement consistent: the same permission category, execution-context, and authenticated-user policies apply to settings mutations as to any other action, rather than creating a second unguarded path through the filesystem. -- The app owns the write to the settings file and any side effects (e.g., theme reload, layout reflow) as a single atomic operation, avoiding races between a direct settings-file edit and the app's file watcher. -- If a future need arises for offline settings manipulation (no running Warp process), a separate file-based path can be added later with its own validation, but it should not be the default. -- The action classification still applies: settings reads are metadata reads, and settings writes are metadata/configuration mutations. Settings writes must not be authorized by app-state mutation permission alone. +- `warpctrl instance inspect --instance <id>` +- `warpctrl capability list` +- `warpctrl capability inspect tab.create` +- `warpctrl action inspect drive.workflow.run` +- `warpctrl tab create --window active` +- `warpctrl pane split --direction right` +- `warpctrl block output --block-id <id> --plain` +- `warpctrl surface.settings.open --page scripting` +- `warpctrl drive inspect <id>` +JSON output and structured errors are supported for discovery, reads, mutations, and failures. +# Implementation status +The running app advertises implementation status per action. Unsupported catalog entries return `unsupported_action`; names intentionally outside the public catalog return `not_allowlisted` or fail enum parsing before dispatch. diff --git a/specs/warp-control-cli/README.md b/specs/warp-control-cli/README.md index 6bf563e15c..b02e888556 100644 --- a/specs/warp-control-cli/README.md +++ b/specs/warp-control-cli/README.md @@ -1,93 +1,37 @@ # warpctrl operator README -`warpctrl` is the provisional standalone CLI for controlling an already-running local Warp app instance. It is intended for scripts, demos, agent workflows, and developer automation that need to perform allowlisted Warp UI actions without launching the GUI executable in CLI mode. -The first implementation slice is intentionally narrow: -- discover compatible running Warp instances; -- select one instance implicitly when unambiguous or explicitly with `--instance`; -- send authenticated local-control requests through the per-instance discovery record; -- create a new terminal tab with `warpctrl tab create`. -The local-control protocol and catalog are broader than this slice, but commands outside the implemented capability set should fail with structured unsupported-action errors until their handlers land. -## Packaging model -`warpctrl` should be packaged as a separate CLI artifact from the Warp GUI app while reusing shared repository code: -- `crates/local_control` owns discovery records, local authentication material, client transport, protocol envelopes, action names, and error types. +`warpctrl` is the standalone CLI for controlling already-running local Warp app instances. It is intended for scripts, demos, agent workflows, and developer automation that need allowlisted Warp UI actions without launching the GUI executable in CLI mode. +# Implementation status +The protocol catalog is broader than the set of handlers implemented by any one branch. Use `warpctrl capability list`, `warpctrl capability inspect <action>`, `warpctrl action list`, or `warpctrl action inspect <action>` when supported by the selected app to distinguish implemented actions from catalog stubs. +The current foundation path supports external logged-out-safe local control only. Authenticated actions require verified Warp-managed terminal invocation and are rejected until that proof path is implemented by the selected app. +# Packaging model +`warpctrl` is packaged as a separate CLI artifact from the Warp GUI app while reusing shared repository code: +- `crates/local_control` owns discovery records, authentication material, client transport, protocol envelopes, action names, and error types. - `crates/warp_cli` owns command parsing conventions for local-control subcommands. -- the app-side bridge owns the per-process loopback listener and dispatches supported actions onto the live Warp UI context. -The binary should initialize only CLI parsing, instance discovery, local authentication loading, request serialization, HTTP transport, and output formatting. It should not initialize GUI state, terminal models, rendering, workspaces, or main-app startup paths. -During the provisional naming period, release artifacts and helper names may be channelized, but operator docs and examples should use `warpctrl` unless an integration branch explicitly documents a channel-specific alias. -This branch wires the standalone binary target and the macOS/Linux bundle-script artifact selectors: -- `cargo build -p warp --bin warpctrl` -- `script/macos/bundle --artifact warpctrl ...` -- `script/linux/bundle --artifact warpctrl ...` -Windows has the native Rust binary target, but installer/release helper exposure remains follow-up packaging work. -## Install and invocation guidance -### macOS -Build locally with `cargo build -p warp --bin warpctrl`, then run `target/debug/warpctrl` or copy/symlink that binary onto `PATH`. -For distributable standalone artifact checks, use `script/macos/bundle --artifact warpctrl` with the desired channel/signing flags. The bundle script writes a standalone `warpctrl` binary into its macOS artifact output directory instead of embedding it in the GUI app bundle. -### Linux -Build locally with `cargo build -p warp --bin warpctrl`, then run `target/debug/warpctrl` or copy/symlink that binary onto `PATH`. -For distributable standalone artifact checks, use `script/linux/bundle --artifact warpctrl` with the desired channel/package selection. The Linux bundle script routes packaging through the standalone control-binary artifact path; downstream package installation should place the emitted `warpctrl` binary according to that package format. -Run `warpctrl --version` after installation to confirm the shell is resolving the expected build. -### Windows -Build locally with `cargo build -p warp --bin warpctrl`, then run `target\debug\warpctrl.exe` or copy that binary onto `PATH`. -The Windows-native binary target exists in this slice. Installer helper creation and release-artifact wiring still need a later packaging change before docs can promise an installer-provided `warpctrl` command. -## End-to-end local test flow -Use matching app and CLI bits from the same branch or release artifact so the protocol version and action catalog agree. +- the app-side bridge owns the per-process local listener and dispatches supported actions onto the live Warp UI context. +The binary initializes only CLI parsing, instance discovery, credential loading, request serialization, transport, and output formatting. It should not initialize GUI state, rendering, workspaces, or main-app startup paths. +# Local test flow +Use matching app and CLI bits from the same branch or artifact so protocol version and catalog agree. 1. Start Warp and leave at least one window open. -2. Confirm that the local-control server registered the running process: - ```bash - warpctrl instance list - ``` -3. If exactly one compatible instance is listed, create a new terminal tab: - ```bash - warpctrl tab create - ``` -4. If multiple compatible instances are listed, copy the desired `instance_id` and target it explicitly: - ```bash - warpctrl tab create --instance <instance_id> - ``` -5. Verify the running app receives focus for the selected instance and a new terminal tab appears according to Warp's normal new-tab placement behavior. -6. In a future slice that implements `tab list`, inspect state before and after the mutation: - ```bash - warpctrl tab list --instance <instance_id> - ``` +2. Confirm the app registered a local-control instance: `warpctrl instance list`. +3. If exactly one compatible instance is listed, run `warpctrl tab create`. +4. If multiple compatible instances are listed, pass `--instance <instance_id>`. +5. Verify the selected app creates a new terminal tab according to Warp's normal behavior. Expected failures: -- no running compatible app: exits non-zero with a no-instance error; -- multiple ambiguous instances: exits non-zero and asks for `--instance`; -- unsupported app build or stale discovery record: exits non-zero with a protocol, stale-target, or transport error; -- `tab.create` not yet implemented by the running app bridge: exits non-zero with an unsupported-action error. -## Security model -The local-control protocol is designed for same-user scripting, not cross-user or network access. The trust boundary is the local user account. -- **Loopback-only listener.** Each Warp process binds its control server to `127.0.0.1` on an ephemeral port. The listener is not reachable from the network. -- **Per-instance bearer token.** A random token is generated at startup and written into the discovery record. Every control request must present this token in the `Authorization` header; missing or invalid tokens are rejected with HTTP 401. -- **File-permission-gated discovery.** Discovery records are stored in a per-user local-control directory. On POSIX platforms, files must be created with `0600` permissions (owner read/write only). On Windows, records must be stored under the current user's app data directory with an ACL that grants access only to the current user, Administrators, and SYSTEM. Any same-user process that can read the credential can authenticate, so the baseline security boundary is same-user process isolation. -- **Stale-record pruning.** On each `instance list` or implicit discovery call, records whose PID is no longer alive are deleted automatically, preventing stale tokens from lingering on disk. -- **No CORS.** The control endpoints do not set permissive CORS headers, so browser-origin JavaScript cannot read responses even if it guesses the port. The bearer token requirement provides a second layer since browsers cannot read the discovery file. -```mermaid -sequenceDiagram - participant CLI as warpctrl - participant FS as ~/.warp/local-control/ - participant HTTP as Warp loopback server<br/>(127.0.0.1:ephemeral) - participant Bridge as App bridge - - CLI->>FS: Read discovery records (user-only permissions / ACL) - FS-->>CLI: instance_id, endpoint, auth_token - CLI->>CLI: Prune stale PIDs, select instance - CLI->>HTTP: POST /v1/control<br/>Authorization: Bearer <token> - HTTP->>HTTP: Verify token matches instance - alt Invalid or missing token - HTTP-->>CLI: 401 Unauthorized - else Valid token - HTTP->>Bridge: Dispatch action to app context - Bridge-->>HTTP: Structured result or error - HTTP-->>CLI: JSON response envelope - end -``` -**Known limitations and future hardening:** -- The token is stored in plaintext in the discovery JSON file. Any compromised process running as the same user can extract it. -- Tokens do not rotate or expire during a Warp session. A leaked token is valid until the process exits. -- Windows local-control authentication is not complete until discovery-record ACL creation and validation are implemented. -- Once higher-risk handlers land (e.g. `input.insert`, command execution), the same-user boundary becomes a code-execution trust boundary. Consider separating the token from the discovery metadata, adding per-request nonces, or switching to a Unix domain socket with `SO_PEERCRED` for kernel-verified caller identity. -## Documentation review notes -- Treat `warpctrl` as provisional executable naming until packaging signs off on final artifact aliases. -- Keep examples scoped to discovery and `tab create` until additional app-side handlers are implemented. -- Do not document catalog commands as usable just because they exist in protocol enums or parser scaffolding; operator docs should distinguish implemented commands from planned allowlist entries. -- Windows packaging may initially follow the existing helper-wrapper pattern rather than shipping a native standalone executable. Update this README when that decision is final. +- no running compatible app: `no_instance`; +- multiple ambiguous instances: `ambiguous_instance`; +- disabled outside-Warp control: `local_control_disabled`; +- unsupported app build or stale discovery record: protocol, stale-target, or transport error; +- catalog entry without handler support: `unsupported_action`. +# Security model +The protocol is local same-user scripting, not cross-user or network control. +- Each Warp process exposes local control through loopback or an owner-only local socket. +- Control requests require scoped credentials. +- Discovery metadata is per user and does not grant broad authority by itself. +- Browser-origin JavaScript must not receive a permissive CORS path to control endpoints. +- External invocations are limited to logged-out-safe local-control actions. +- Authenticated actions require verified Warp-managed terminal invocation and the selected app's logged-in Warp user. +- `warpctrl` does not provide standalone secret-based authenticated external scripting. +# Documentation notes +- Use `warpctrl` as the executable name. +- Keep operator examples tied to implemented commands or mark catalog entries as stubs. +- Do not document excluded surfaces as usable commands. diff --git a/specs/warp-control-cli/SECURITY.md b/specs/warp-control-cli/SECURITY.md index 8922516064..9b9c949c35 100644 --- a/specs/warp-control-cli/SECURITY.md +++ b/specs/warp-control-cli/SECURITY.md @@ -1,508 +1,125 @@ # warpctrl security architecture -`warpctrl` is a local-control CLI for an already-running Warp app instance. Its security architecture is designed to support the control catalog: discovery, structural metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, underlying data mutations, input-buffer staging, file/path app-state intents, Warp Drive operations, and explicitly authenticated execution-underlying actions. Accepted-command submission and agent-prompt submission remain future high-risk capabilities that require separate product/security review. Local file content operations are intentionally excluded from the public `warpctrl` catalog because native agent file tools are the preferred surface for file content reads and writes. -The correct architecture is not a single shared localhost bearer token with client-side conventions. The CLI, app bridge, and protocol must treat security as a local app-enforced capability system: discovery finds compatible instances, secure storage protects raw credential material, broker-issued credentials identify the granted scopes, the running Warp app's local-control bridge enforces action categories before dispatch, and target resolution never silently retargets a request. -The action-category model is primarily a safety and intent mechanism, not a hard security boundary against malicious same-user software. It lets a user, script, or agent intentionally request metadata-only, data-read, app-state mutation, metadata/configuration mutation, or underlying-data mutation access so it does not accidentally mutate state, expose sensitive content, or execute commands. It should not be described as strong access control against a process that can already run arbitrary commands as the user. -`warpctrl` has two distinct authorization dimensions: local-control authority and authenticated scripting authority. Local-control authority proves the request is allowed to control the local app. Authenticated scripting authority proves the Warp user or automation identity that is allowed to act on user-authenticated data such as Warp Drive objects, AI conversation traces, synced settings, cloud-backed user state, and execution-underlying actions. Logged-out users should retain a smaller local-only control surface, but authenticated-user and high-risk underlying-data mutation actions require either a verified Warp-terminal grant tied to the selected app's logged-in user or an external Warp-issued API-key grant. -## Current foundation status -The current foundation implementation supports outside-Warp local-control requests only. Verified inside-Warp invocation is specified as future work because the app-issued terminal-session proof broker, proof injection path, and session registry do not exist yet. Until those pieces land, `InvocationContext::InsideWarp` requests must be rejected with `execution_context_not_allowed`, implemented action metadata must not advertise inside-Warp support, and Settings > Scripting must not expose inside-Warp enablement or permission toggles. -## Security goals -- Allow trusted local users and approved automation to control a running Warp instance through a stable, scriptable interface. -- Prevent unauthenticated localhost clients from invoking read or mutating control actions. +`warpctrl` is a local same-machine control CLI for already-running Warp app instances. This document is the normative security policy for the feature. PRODUCT.md defines user-facing scope; TECH.md defines implementation mechanics. If either conflicts with this document, update that document before implementation. +# Current stance +- `warpctrl` is the decided binary name. +- The full allowlisted catalog is the public contract, with per-action implementation status advertised by the running app. +- Authenticated actions require verified Warp-managed terminal invocation and a true logged-in Warp user in the selected app. +- External invocations are limited to logged-out-safe local-control actions. +- There is no standalone secret-based authenticated external scripting path. +- Accepted command submission, agent prompt submission, local filesystem data mutation, arbitrary internal dispatch, arbitrary ACL/public/external sharing, and network control transports are out of scope. +# Security goals +- Prevent unauthenticated localhost clients from invoking control actions. - Prevent browser-origin JavaScript from becoming an ambient localhost control client. -- Support multiple running Warp processes without a shared global mutating port or global credential. -- Separate discovery metadata from control authority so enumerating an instance does not automatically grant full control. -- Require explicit in-app user enablement before local control scripting from outside Warp can issue credentials or accept control requests. -- Allow local control scripting from verified Warp-managed terminal sessions by default, subject to granular permission settings. -- Store the authoritative enablement states in protected local storage so external apps cannot enable outside-Warp control by editing ordinary settings. -- Keep raw credential material out of plaintext discovery records and protect it with platform secure storage where available. -- Distinguish verified `warpctrl` invocations that originate from a Warp-managed terminal session from external same-user invocations. -- When outside-Warp control is enabled, allow external invocations only for a smaller local-only action set by default that does not touch user-authenticated data. -- Allow in-Warp invocations to receive authenticated-user grants when the selected Warp app has a true logged-in user and the user's local-control settings permit that grant. -- Support least-privilege safety modes for automation and interactive use without relying on an unenforceable identity label. -- Classify every action by state/data category and enforce the required permission category in the local app bridge, not in the CLI frontend. -- Classify every action by whether it requires an authenticated Warp user. New actions should default to requiring an authenticated user unless they are deliberately reviewed as safe for logged-out or external use. -- Prevent `warpctrl` from becoming an ambient full-power confused deputy that any same-user process can invoke for high-risk actions. -- Require authenticated scripting identity, underlying-data-mutation permission, explicit target resolution, and audit records before terminal command execution or typed workflow execution can ship; accepted-command submission and agent-prompt submission remain prohibited until separately reviewed. -- Preserve deterministic targeting so a request never silently mutates or reads the wrong window, tab, pane, session, file/path intent, or Warp Drive object. -- Keep the action surface allowlisted and typed rather than exposing arbitrary internal app dispatch. -- Make high-risk operations auditable and configurable without logging sensitive terminal contents or credentials. -## Meaningful security boundaries -The most important security boundary is preventing control from places that should have no ambient authority over the user's Warp instance: -- arbitrary web apps running in a browser; -- other OS users on the same machine; -- unauthenticated clients that discover or guess the localhost control port; -- stale discovery records from exited Warp processes; -- malformed or unallowlisted direct protocol calls. -The local-control design can provide meaningful protection for those cases by binding only to loopback, avoiding permissive CORS, requiring local credentials, keeping credentials out of browser-readable and world-readable locations, pruning stale records, and validating every request in the local Warp app process. -The boundary is much weaker for a different local app running as the same OS user. Same-user local apps may already have access to user-owned files such as logs, may be able to observe the screen or UI through OS permissions such as Accessibility or Screen Recording, and can often invoke user-installed command-line tools. `warpctrl` should not imply strong isolation from such software. -For same-user local apps, the realistic goal is narrower: -- do not leave a raw bearer token in plaintext discovery records; -- prevent arbitrary direct HTTP calls to the localhost control listener by requiring a credential those apps cannot simply read; -- use platform secure storage, such as macOS Keychain, so raw credentials are accessible only to Warp-owned signed code where practical; -- make high-risk operations go through `warpctrl` or a Warp-owned helper where user approval, configured policy, and safety grants can be applied; -- avoid giving `warpctrl` ambient non-interactive full-control authority. -In other words, the security model can make arbitrary direct localhost protocol calls fail, and it can make direct credential theft harder. It cannot make a same-user malicious app safe if that app can invoke `warpctrl`, automate the user's desktop, read other local state, or wait for the user to approve prompts. -## Comparison with other local scripting models -Other developer tools expose local automation through a few recurring patterns. The `warpctrl` design should borrow the parts that match Warp's needs while avoiding designs that assume localhost or same-user access is enough by itself. -### VS Code -VS Code's `code` command is primarily a launch and routing CLI: it opens files, folders, diffs, merge views, chat sessions, extension-management commands, and remote/tunnel workflows. It is not a general unauthenticated localhost API for arbitrary UI control of an already-running desktop app. -VS Code's richer local automation runs through extension APIs and extension hosts. Extensions are installed into a trusted editor environment and run with broad access to the workspace or UI side depending on extension kind. Workspace Trust and remote extension placement help users reason about whether code should run locally, remotely, or in a browser sandbox, but they do not create a fine-grained same-user security boundary against arbitrary local software. -Lessons for `warpctrl`: -- a narrow, typed CLI command surface is safer to reason about than exposing arbitrary internal app commands; -- agent and script workflows should request explicit capabilities instead of inheriting ambient full-control authority; -- local UI control should remain distinct from remote/tunnel control because remote transports need stronger identity, approval, and network-security semantics. -### Chrome DevTools Protocol -Chrome DevTools Protocol is a powerful debugging and automation API. When Chrome is launched with remote debugging enabled, clients can discover targets over local HTTP endpoints and then control the browser over WebSocket. That protocol is intentionally high-power: it can inspect pages, navigate, execute JavaScript, observe network state, and interact with browser storage. -Chrome's security history is a useful warning for `warpctrl`: a local debugging port is dangerous if it becomes reachable by unexpected clients. Recent Chrome versions restrict remote debugging against the default user data directory and recommend isolated user data directories for automation, because debugging a real browser profile can expose sensitive cookies and credentials. Chrome also distinguishes command-line remote debugging from user-confirmed debugging flows. -Lessons for `warpctrl`: -- loopback binding is necessary but not sufficient; -- unauthenticated localhost endpoints should not expose powerful state or mutation; -- browser-origin protections matter because web pages can attempt localhost requests; -- high-power automation should prefer explicit, isolated, user-approved, or short-lived authority over a reusable full-profile control channel. -### Ghostty and macOS AppleScript -Ghostty exposes platform-native scripting on macOS through AppleScript. That model relies on macOS Automation/TCC prompts to decide whether one app may control another app, and Ghostty can disable AppleScript entirely with configuration. This is a good fit for macOS-native scripting, but it is platform-specific and inherits the limits of OS automation permission: once an app is allowed to automate another app, the boundary is not a per-action capability system. -Ghostty also supports terminal-oriented features such as shell integration and command-line window creation flows. Those are useful local automation conveniences, but they are not a general cross-platform authenticated control protocol with scoped credentials. -Lessons for `warpctrl`: -- use platform security mechanisms where they exist, such as macOS Keychain and Automation prompts; -- keep a user-visible kill switch or policy path for scripting/control surfaces; -- do not rely only on platform automation permission if Warp needs cross-platform, action-scoped safety grants. -### iTerm2 Python API -iTerm2's Python API is a close comparison for terminal automation. The API is disabled by default. When enabled, iTerm2 listens on a Unix domain socket and requires authentication by default. Scripts launched by iTerm2 receive a random cookie in the environment, while external programs can request a cookie through AppleScript so macOS Automation permission mediates access. iTerm2 also documents an administrator-gated escape hatch to allow unauthenticated local apps. -This model directly acknowledges that terminal contents are sensitive and that any local automation API can affect local and remote hosts connected through terminal sessions. -Lessons for `warpctrl`: -- default-off or policy-controlled high-power automation is reasonable for sensitive capabilities; -- random local credentials are useful, but the path that grants or unwraps them is just as important as the token itself; -- underlying data reads and input/command execution should be treated as higher-risk than structural metadata reads; -- macOS Automation can be part of the approval path, but Warp still needs local app-side enforcement because direct protocol clients can bypass the official CLI. -### tmux -tmux is a useful lower-level comparison because its clients and server communicate through local sockets. The default socket lives in a per-user directory under `/tmp`, and that directory must not be world readable, writable, or executable. tmux control mode then exposes a text protocol where clients can issue normal tmux commands and receive asynchronous pane/session notifications. Newer tmux versions also have explicit server-access controls for sharing across users. -tmux's model is mostly an OS-user and socket-permission model. Once a client can access the socket with write authority, it can generally control the session. Read-only modes are useful operational guardrails but are not a reason to trust untrusted users or processes with the socket. -Lessons for `warpctrl`: -- per-user discovery directories and sockets protect meaningfully against other OS users; -- structured control protocols are scriptable and durable, but broad socket access quickly becomes broad control access; -- read-only and low-risk modes are valuable “do not accidentally interfere” controls, not a complete hostile-client sandbox. -### Overall direction for `warpctrl` -Compared with these systems, `warpctrl` should combine: -- tmux-style local filesystem/socket hygiene for protecting against other OS users; -- Chrome's lesson that local debugging/control endpoints need authentication and browser-origin hardening; -- iTerm2's use of explicit local credentials and macOS Automation-style approval for external control; -- Ghostty's use of platform-native scripting controls where available; -- VS Code's preference for typed public commands and separate treatment of remote control. -The resulting architecture should not claim to solve arbitrary same-user malicious-app isolation. Its real value is preventing web-origin and other-user access, preventing unauthenticated direct localhost calls, keeping raw credentials out of plaintext discovery, giving honest scripts and agents narrow safety grants, and routing high-risk operations through local Warp app validation and user/policy approval. -## Authoritative enablement model -This section describes the long-term model. The current foundation branch implements only the outside-Warp half of this model and rejects inside-Warp requests until app-issued Warp-terminal proofs are implemented. -Warp control has two top-level enablement states based on invocation context: -- **Allow scripting from inside Warp:** controls `warpctrl` invocations from verified Warp-managed terminal sessions. This should default to on so commands run inside Warp can use local control subject to granular permissions. -- **Allow scripting from outside Warp:** controls `warpctrl` invocations from external terminals, scripts, launch agents, IDEs, or other same-user processes. This must default to off. -Both controls should live in a new top-level Settings pane page named **Scripting**. The Scripting page owns the user-facing controls for local scripting surfaces, including Warp control, and should explain the difference between commands run inside Warp and commands run from other apps. -The visible UI settings are not enough by themselves. The authoritative enablement states must be stored in protected local storage that ordinary same-user apps cannot update by writing a plist, settings database row, registry key, JSON file, or synced cloud preference. This avoids turning outside-Warp control into a feature that any process can silently enable before invoking `warpctrl`. -Current foundation implementation note: outside-Warp enablement and granular permission bits are represented in the typed `LocalControlSettings` group as private, local-only settings. Each implemented setting must use `private: true`, `SyncToCloud::Never`, an explicit private storage key, and no `toml_path`, so it is excluded from `settings.toml`, the generated settings schema, Settings Sync, Warp Drive, and user-editable or server-backed settings surfaces. This private-settings path is an interim storage boundary, not the final protected-storage requirement; before public shipment, these authoritative bits must move to platform protected storage where available. -Enablement requirements: -- The settings are local-only and must not sync through Settings Sync, Warp Drive, or server-backed user preferences. -- The implemented foundation settings must remain private and absent from user-visible settings files, generated schemas, local-control settings read/write commands, and any allowlisted settings mutation catalog. -- Only the running Warp app, through the Settings > Scripting UI, should be able to enable or disable the authoritative states. -- `warpctrl`, shell scripts, config files, command-line flags, registry edits, defaults writes, and direct local-control protocol requests must not be able to enable either setting. -- The in-Warp setting may default to enabled, but turning it off should prevent verified Warp-terminal invocations from receiving local-control grants. -- The outside-Warp setting defaults to disabled and should require an intentional user gesture before enabling; the UI should explain that it allows scripts and automation from other apps to control Warp. -- The Scripting page should expose granular local-control permission settings for implemented invocation contexts rather than a single all-powerful switch. -- Each setting should be easy to disable from the same UI, and disabling either setting should revoke or invalidate active local-control credentials for that invocation context. -- If enterprise or managed-device policy is added later, policy may force-disable either setting or allow an administrator-controlled default, but policy should be separate from user-editable local settings. -Disabled-state behavior: -- Warp should not mint scoped local-control credentials for a request whose invocation context is disabled. -- The control listener should reject requests from disabled contexts with a structured disabled-state error before authentication, selector resolution, or handler dispatch. -- Discovery records should avoid publishing actionable endpoint or credential-reference metadata for disabled outside-Warp control. If a minimal record is needed for UX, it should expose only non-sensitive status such as `outside_warp_control_enabled: false`. -- `warpctrl` may detect a disabled context and print instructions to enable it in Settings > Scripting, but it must not offer a command that flips the setting. -- Previously issued credentials must become unusable when their invocation context is disabled, even if their original expiry has not elapsed. -These enablement gates do not create perfect same-user malicious-app isolation. A hostile process with Accessibility or Screen Recording permission might still try to automate the Warp UI. The outside-Warp gate is still important because it closes the much easier paths where external apps silently edit local preferences, call a config CLI, or write synced settings to enable a powerful control surface. -### Granular permission settings -Once the relevant inside-Warp or outside-Warp enablement setting allows a request context, users should control which categories of `warpctrl` authority can be granted. These permissions should appear under Settings > Scripting. Recommended independent permissions: -- **Metadata reads:** permit external and in-Warp clients to inspect non-sensitive local app structure and configuration metadata such as instances, windows, tabs, panes, app version, theme names, setting keys, action metadata, and Drive object IDs/names/types without content. -- **Underlying data reads:** permit reads of terminal output, scrollback, input buffers, command history, session traces, Warp Drive object contents, AI conversation content, and other content-bearing state. -- **App-state mutations:** permit local UI/layout/focus changes such as opening windows, creating tabs, closing tabs, focusing panes, splitting panes, opening panels, opening files/projects/views, and staging text in the input buffer without executing it. -- **Metadata/configuration mutations:** permit persistent metadata or configuration changes such as tab/pane names, tab colors, themes, font size, zoom, allowlisted settings, and keybindings. -- **Underlying data mutations:** permit Warp Drive object CRUD and personal-to-team sharing, AI conversation mutations, and any other allowlisted action that can change user data or cause external side effects. Terminal command execution and Warp Drive workflow execution belong in this category when their later authenticated branches implement them. Accepted-command submission and agent-prompt submission remain unavailable until separately reviewed. Local file content operations are intentionally excluded from the public `warpctrl` catalog and should use native file tools instead. -- **Authenticated-user actions from Warp terminals:** permit `warpctrl` invocations that originate from a verified Warp-managed terminal session to receive grants backed by the currently logged-in Warp user, enabling actions that read or mutate Warp Drive, AI conversation traces, synced settings, or other user-authenticated state. -- **Authenticated-user actions from external clients:** default off. If supported, this must be a separate explicit permission from in-Warp authenticated actions because external same-user processes are a weaker context than a Warp-managed terminal session. -Granular permissions should be independently configurable for inside-Warp and outside-Warp contexts where the distinction matters. Disabling any category should invalidate active credentials that include that category. The broker and app bridge must enforce these settings locally for every credential request and every presented credential. App-state mutation permission must not imply metadata/configuration mutation or underlying data mutation permission. -## Trust boundaries -`warpctrl` has several distinct trust boundaries. -### Operating-system user boundary -The baseline local trust boundary is the OS user account. Discovery records and local credential material must be readable only by the owning user. This protects against other local users and network peers, but it does not protect against an already-compromised same-user process. -### Invocation boundary -Same-user does not mean same authority. Interactive use and unattended automation may both run commands under the same user account, but they should be able to intentionally request narrower capabilities. The protocol needs scoped credentials that encode concrete grants, target scopes, and lifetimes rather than an abstract caller type that the bridge cannot reliably verify. -These scoped credentials are guardrails for well-behaved clients. They prevent accidental overreach and make user intent explicit, but they are not a defense against malicious same-user code that can automate the CLI, inspect the user's environment, or wait for user approvals. -### Warp-terminal execution context boundary -`warpctrl` should be able to receive special grants when it is invoked from within a Warp-managed terminal session, but the bridge must not trust a caller-supplied string such as `caller_class=warp_terminal`. The app should issue a session-bound execution-context proof to Warp-managed terminal sessions and have the broker verify that proof before minting in-Warp-only grants. -Acceptable designs include a short-lived per-session capability, an app-owned broker handshake tied to the terminal session, or an equivalent proof that arbitrary external processes cannot mint by setting an environment variable. Plain environment variables may be used as handles or hints, but they must not be the sole authority for in-Warp privileges because external processes can spoof them. -Verified in-Warp context can raise the maximum eligible grant set, especially for authenticated-user actions. It does not by itself bypass the user's granular local-control settings, action categories, target scopes, or logged-in-user requirements. -### Authenticated scripting boundary -Actions that touch user-authenticated Warp data or perform high-risk underlying-data mutations require authenticated scripting authority. This includes Warp Drive object contents, object mutation, the v0 personal-to-team sharing path, AI conversation traces, cloud-backed user settings, team/account data, typed workflow execution, terminal command execution, and any other surface whose normal app access depends on the user's Warp account or can cause external side effects. -There are two supported authenticated scripting modes: -- **Verified Warp-terminal mode:** `warpctrl` presents an app-issued terminal-session proof. If the selected app is logged into Warp and Settings > Scripting permits authenticated actions from verified Warp terminals, the broker may mint an authenticated-user grant tied to the selected app's current user subject. -- **External API-key mode:** `warpctrl` presents a Warp-issued scripting API key or a short-lived token exchanged from that key. If outside-Warp scripting and external authenticated grants are enabled, the broker verifies the key, scopes, expiry, revocation state, and user subject before minting a local authenticated-user grant. -For app-backed authenticated actions, the app bridge should execute on behalf of the selected app's logged-in user through existing app auth state. For explicitly API-key-backed actions, the API key subject and scopes must be recorded in the local grant and the handler must not export raw Firebase, server, OAuth, or cloud API tokens to shell scripts. If the selected app logs out, switches users, or no longer matches a grant that requires app-user identity, authenticated actions fail with structured errors rather than falling back to logged-out behavior. -Logged-out users may still use the smaller local-only action set explicitly marked as not requiring authenticated scripting authority. -### Authenticated scripting protocol -`warpctrl` should provide auth/status flows for both interactive app login and external API-key automation. The CLI must not collect Warp passwords and must not print or persist raw API keys outside approved secret storage. -Requirements: -- `warpctrl auth status [selectors]` reports whether the selected app instance is logged in, whether verified Warp-terminal authenticated grants are available, and whether an external API-key identity is configured. It may return stable, non-secret subject/scope metadata when the caller has the required grant. -- `warpctrl auth login [selectors]` focuses or opens the selected Warp app's normal sign-in UI and waits, or exits with actionable instructions, until the user signs in through Warp itself. -- `warpctrl auth api-key set --key-env <env_var>|--key-stdin [selectors]` stores or references a Warp-issued scripting API key in platform secure storage. Non-interactive scripts may provide the key through a secret-manager-injected environment variable. -- `warpctrl auth api-key status [selectors]` reports non-secret subject, expiry, and scope metadata for the configured API key. -- `warpctrl auth api-key revoke [selectors]` removes the local key reference and revokes the server-side key where supported. -- The credential broker may mint an app-user authenticated grant only after confirming the selected app has a true logged-in Warp user and the requested authenticated-user setting is enabled for the verified invocation context. -- The credential broker may mint an external API-key grant only after validating the key or exchanging it for a short-lived assertion, confirming that external authenticated grants are enabled, and checking that the key scope covers the requested action family and permission category. -- Authenticated credentials are bound to the selected instance, subject, grant mode, scopes, expiry, and optional target/resource restrictions. If the app logs out, switches users, loses authenticated state, or the presented credential subject no longer matches a grant that requires app-user identity, authenticated actions fail with `authenticated_user_mismatch` or another structured authenticated-user error. -- Raw Firebase, server, OAuth, cloud API tokens, and raw API keys must not be exported to `warpctrl` output, shell completions, generated docs, logs, discovery records, or local-control JSON responses. -Logged-out-safe actions continue to use local-control credentials without requiring authenticated scripting identity. -### Application identity boundary -On platforms with secure credential storage, especially macOS, the raw local-control credential should be readable only by Warp-owned, correctly signed code. On macOS this means storing raw credential material in Keychain with access constrained by Warp's signing identity, designated requirement, Keychain access group, or equivalent platform mechanism. This narrows token extraction from “any same-user process can read a file” to “only trusted Warp-signed code can unwrap the secret.” -This boundary protects the credential from direct theft and prevents arbitrary apps from making authenticated raw HTTP requests to the local-control listener. It also lets the authoritative enablement state be stored somewhere harder to modify than ordinary user preferences. It does not prove that the user personally intended the specific action. Any same-user process may still be able to invoke the trusted `warpctrl` binary or automate the Warp UI. That confused-deputy risk is reduced by explicit in-app enablement, scoped credential issuance, action-category policy, and local app-side bridge enforcement, but it is not eliminated as a hard same-user security boundary. -### Action boundary -Every action belongs to a state/data category. The bridge must map the requested action to a required permission category and compare that category to the presented credential before selector resolution or handler dispatch. -### Target boundary -A valid credential for one instance or target must not imply authority over another. Credentials should be bound to the issuing Warp instance and may be further scoped to target families such as terminal sessions, files, or Warp Drive objects when those surfaces are exposed. -## Threat model -### In scope -- Other local OS users attempting to control a Warp instance owned by the current user. -- Browser-origin JavaScript attempting to call localhost control endpoints. -- Same-user automation attempting actions without the required scoped grants. -- Same-user processes attempting to extract plaintext credentials from local state. -- Same-user processes invoking `warpctrl` as a confused deputy for actions the process could not authorize directly. -- External same-user processes attempting authenticated-user actions that should be limited to verified Warp-terminal invocations. -- Logged-out requests attempting actions that require a true logged-in Warp user. -- Stale discovery records from exited Warp processes. -- Multiple running Warp instances where ambiguous selection could target the wrong process. -- Malformed clients attempting unknown, unsupported, unallowlisted, or invalid action payloads. -- Valid clients attempting actions above their granted permission category. -- Explicit target IDs that become stale between discovery and execution. -- Future handlers that expose terminal data, settings writes, input mutation, command execution, file intents, or Warp Drive object operations. -### Out of scope -- A malicious process that already has arbitrary same-user filesystem and process access, except that scoped credentials should still reduce accidental over-granting to ordinary automation. -- Kernel, hypervisor, or administrator-level compromise. -- Security semantics for remote URL control endpoints. Remote control requires a separate transport and identity design before it can ship. -## Architecture overview -The full security model has eight layers. The current foundation branch implements the outside-Warp path and keeps the inside-Warp execution-context layer as a rejected future protocol concept until proof verification exists. -The security model has eight layers: -1. **Protected enablement:** Use protected local storage for separate inside-Warp and outside-Warp enablement states, with inside-Warp on by default and outside-Warp off by default. -2. **Discovery:** Find compatible live Warp instances without granting broad authority. -3. **Secure credential storage:** Store raw secrets outside plaintext discovery records and restrict access to trusted Warp-owned code where the platform supports it. -4. **Execution context verification:** Distinguish verified Warp-terminal invocations from external same-user invocations without trusting caller-declared labels. -5. **Credential issuance:** Issue scope-specific credentials with explicit grants and lifetimes only when the request's invocation context is enabled and the user's granular permissions allow the requested category. -6. **Transport authentication:** Reject disabled or unauthenticated requests before reading or mutating app state. -7. **Safety and user-auth policy:** Enforce permission categories, target scopes, execution-context requirements, and authenticated-user requirements locally in the app bridge. -8. **Deterministic dispatch:** Resolve targets exactly and invoke only allowlisted typed handlers. -```mermaid -sequenceDiagram - participant Invoker as User / Automation - participant CLI as warpctrl - participant Registry as Per-user discovery registry - participant Enablement as Protected enablement state - participant Context as Execution context proof - participant Broker as Credential broker - participant Store as Secure credential storage - participant Auth as App auth state - participant HTTP as Warp control listener - participant Bridge as App bridge + safety policy - participant UI as Warp app state - - Invoker->>CLI: Invoke allowlisted command - CLI->>Registry: Read instance metadata - Registry-->>CLI: instance_id, endpoint, protocol version, credential reference - CLI->>Enablement: Check inside/outside context enablement - Enablement-->>CLI: Enabled or disabled - alt Disabled - CLI-->>Invoker: context disabled; enable in Settings > Scripting - else Enabled - CLI->>Broker: Request scoped credential for action - Broker->>Enablement: Verify protected enablement state - Broker->>Context: Verify external vs Warp-terminal context - opt Authenticated-user action - Broker->>Auth: Verify logged-in Warp user + setting - Auth-->>Broker: User subject or unavailable - end - Broker->>Store: Load or unwrap raw secret with Warp-signed access - Store-->>Broker: Raw secret or credential capability - Broker-->>CLI: Scoped credential with grants, context, user scope, expiry - CLI->>HTTP: Authenticated typed request - HTTP->>Bridge: Verify credential and protocol envelope - Bridge->>Bridge: Check permission category + context + authenticated-user + target scope - alt Denied - Bridge-->>CLI: structured safety-policy error - else Allowed - Bridge->>UI: Resolve target exactly and run allowlisted handler - UI-->>Bridge: typed result or structured target error - Bridge-->>CLI: response envelope - end - end -``` -## Discovery registry -Each participating Warp process writes a discovery record in a secure per-user local-control directory. Discovery records are metadata, not a full control-authority model. -A discovery record should contain: -- opaque `instance_id`; -- PID and process start timestamp; -- channel and build metadata; -- protocol version and supported capability summary; -- loopback endpoint for the instance-local control listener; -- credential reference or bootstrap credential metadata, not necessarily the full control credential. -Discovery rules: -- Records must be readable only by the owning user. -- POSIX records must use owner-only permissions such as `0600` for files and a non-world-readable directory. -- Windows records must live under the current user's app data directory with ACLs limited to the current user, Administrators, and SYSTEM. -- When outside-Warp control is disabled, records must not publish actionable control endpoints or credential references for external clients. A minimal disabled-status record is acceptable only if it contains no authority. -- The CLI must prune or ignore stale records whose PID is gone or whose health/protocol check fails. -- If multiple compatible instances are ambiguous, the CLI must require explicit `--instance` selection. -- Discovery metadata must not expose terminal contents, environment variables, auth tokens for cloud services, raw local-control credentials, or mutating capability grants. -## Credential model -The full `warpctrl` catalog requires scoped credentials. A single shared full-power bearer token is not sufficient once automation, underlying data reads, app-state mutations, metadata/configuration mutations, and underlying data mutations are supported. -### Credential properties -A control credential should encode or reference: -- issuing Warp instance; -- protocol version or accepted version range; -- granted permission categories; -- verified execution context, such as external client or Warp-managed terminal session; -- whether the credential may act on behalf of an authenticated Warp user; -- authenticated Warp user subject or stable user reference when an authenticated-user grant is present; -- optional allowed action families; -- optional target restrictions, such as one session, one workspace, one file path, or one Warp Drive object type; -- issued-at time; -- expiry time or process-lifetime binding; -- unique credential ID for revocation and auditing; -- integrity protection so callers cannot forge or widen grants. -### Credential issuance -Warp should issue credentials through an app-owned local broker or equivalent trusted path. The broker decides which grants to issue based on the requested permission category, target scope, user configuration, execution context, and any explicit user approval. -Recommended defaults: -- Credential issuance is unavailable unless the protected enablement state allows the request's invocation context: inside Warp or outside Warp. -- Commands should start from least privilege and request only the grant needed for the requested action. -- External same-user invocations should default to the smaller logged-out-safe local action set unless policy or explicit approval grants more. -- Verified Warp-terminal invocations may receive broader local-control grants when the user's granular settings allow them. -- App-user authenticated grants are available only when the selected Warp app has a true logged-in Warp user and the requested execution context is allowed by local-control settings. External API-key authenticated grants are available only after key validation/exchange and only when external authenticated scripting is enabled. -- Metadata reads require an explicit `read_metadata` grant. -- Underlying data reads require an explicit `read_underlying_data` grant. -- App-state mutations require an explicit `mutate_app_state` grant. -- Metadata/configuration mutations require an explicit `mutate_metadata` or `mutate_configuration` grant. -- Underlying data mutations require an explicit `mutate_underlying_data` grant and should require approval or policy for unattended automation. -- User-authenticated data reads or mutations require an explicit `authenticated_user` grant and an allowed authenticated action family in addition to the data-category grant. -- Integrations should receive the narrowest grant needed for the configured workflow. -The broker must not issue broad authority merely because the request came from the signed `warpctrl` binary. It should evaluate the requested permission category, target scope, configured policy, execution context, and whether user approval is required. The CLI must not mint its own authority. It can request, load, and present credentials, but the app bridge remains the enforcement point for these safety grants. -### Safety grants, not strong access control -The category system should be understood as a user-intent and accident-prevention mechanism: -- A user can ask an agent or script to operate with metadata-read grants so it can inspect structure but cannot read terminal content or mutate state. -- A workflow can request underlying-data reads separately from structural metadata reads because terminal output, files, Drive object content, and AI conversations can contain sensitive data. -- A script can request app-state mutation without also receiving permission to change persistent settings, execute commands, mutate Warp Drive objects, or perform local file content operations. -- Metadata/configuration mutations can be allowed without granting underlying data mutation. -- Underlying data mutations can require explicit approval or configured policy so surprising operations pause before they execute commands or change user data. -This model does not make untrusted same-user software safe. A malicious local process may invoke `warpctrl`, simulate user workflows, or use other OS-level capabilities outside `warpctrl`. The category model is still valuable because it lets honest clients, agents, and scripts constrain themselves and gives Warp a structured point to prompt, deny, or audit risky actions. -### Credential storage -Credential storage should be platform-appropriate: -- Local discovery may store a credential reference rather than the credential itself. -- The authoritative local-control enablement states for inside-Warp and outside-Warp scripting should use the same class of protected local storage as raw credential material, but they should be accessible to the Warp app for the Settings > Scripting UI and not writable by `warpctrl` or arbitrary external apps. -- Raw long-lived credentials should prefer platform-secure storage such as macOS Keychain or Windows Credential Manager when practical. -- On macOS, raw control secrets should be stored in Keychain and restricted to trusted Warp-signed code using a designated requirement, Keychain access group, trusted-application ACL, or equivalent code-signing based mechanism. Restricting by filesystem path alone is insufficient because paths can be replaced or wrapped. -- Keychain item access should include the Warp app, the signed `warpctrl` binary, and any signed Warp-owned local broker/helper that needs to unwrap raw secrets. It should exclude arbitrary same-user applications. -- Short-lived credentials may be stored in owner-only local state if their lifetime and scope are narrow. -- Credentials must never be printed in human-readable output, JSON output, logs, errors, or shell completion data. -### Confused-deputy mitigation -Secure storage prevents arbitrary apps from reading the token; it does not prevent arbitrary apps from asking trusted Warp code to use the token on their behalf. -For example, if `warpctrl` can silently unwrap a full-power credential and execute any action, another same-user process can invoke `warpctrl input run ...` without reading the credential directly. That makes `warpctrl` a confused deputy. -Mitigations: -- Do not give `warpctrl` ambient non-interactive access to an unrestricted full-control credential. -- Prefer action-scoped or session-scoped credentials minted just in time by the broker. -- Require explicit user approval or preconfigured policy for underlying data mutations and other sensitive grants. -- Distinguish user-approved credential requests from ambient unattended invocations through explicit approval prompts, configured policy, terminal/session context, or narrow credential request flows. -- Bind issued credentials to the requested instance, permission category, optional action family, optional target scope, and short expiry. -- Let `warpctrl` preflight and request credentials, but require the local app bridge to enforce scopes because direct protocol clients can bypass the CLI. -- Make denials structured and non-fatal for automation so callers can request narrower or user-approved grants rather than falling back to unsafe behavior. -These mitigations are about routing high-risk operations through intentional `warpctrl` flows rather than exposing a reusable localhost token to any process. They should not be documented as a guarantee that arbitrary same-user applications cannot cause Warp-visible actions. -## Transport authentication -The default transport is an instance-local loopback listener bound to `127.0.0.1` on an ephemeral per-process port. -Transport requirements: -- Bind only to loopback for local control. +- Support multiple running Warp app instances without a shared global mutating port or global credential. +- Separate discovery metadata from control authority. +- Require explicit in-app enablement before outside-Warp local control can issue credentials or accept requests. +- Distinguish verified Warp-managed terminal invocation from external same-user invocation using app-issued proof, not caller-declared labels. +- Classify every action by state/data category, permission category, authenticated-user requirement, target scope, and allowed invocation context. +- Enforce those classifications in the selected Warp app before selector resolution or handler dispatch. +- Preserve deterministic targeting and fail on ambiguous, missing, stale, malformed, unsupported, or unallowlisted requests. +- Keep raw credential material, app auth tokens, terminal output, input text, Drive contents, and other sensitive data out of logs, errors, discovery records, completions, and generated docs. +# Trust boundaries +## OS-user boundary +Discovery records, sockets or endpoints, and credential references must be readable only by the owning OS user. This protects against other local users and network peers. It is not a complete defense against compromised same-user software. +## Invocation boundary +Same-user invocation does not imply the same authority. External clients receive only logged-out-safe grants. Verified Warp-managed terminal clients can request broader grants only after presenting app-issued proof and satisfying Scripting settings. +## Authenticated-user boundary +Actions that touch Warp-user-backed state require authenticated authority tied to the selected app's current logged-in user. The CLI never receives raw Firebase, OAuth, server, or cloud service tokens. If the app logs out or switches users, authenticated grants fail. +## Action boundary +Every action maps to one permission category. The bridge compares requested action metadata against the presented grant before resolving selectors. +## Target boundary +Credentials may be scoped to an instance, action family, target family, or resource. A grant for one instance or target must not authorize another. +# Invocation contexts +## Verified Warp-managed terminal +A `warpctrl` process started inside a Warp-managed terminal may present an app-issued execution proof. The proof must be bound to a live terminal/session, selected app instance, expiry, and revocation state. Environment variables may carry handles or hints, but caller-set variables are not authority by themselves. +Verified terminal context can raise the maximum eligible grant set. It does not bypass Scripting settings, Agent Profile policy, authenticated-user requirements, action categories, or target restrictions. +## External client +A `warpctrl` process started outside Warp, such as another terminal app, IDE, launch agent, or background script, is external. External control defaults off. When enabled, external clients can receive only logged-out-safe local-control grants and cannot receive authenticated-user grants. +# Enablement and settings +Warp owns a Settings > Scripting surface for local scripting controls. +Required settings behavior: +- Inside-Warp control is enabled only for verified Warp-managed terminal invocations. +- Outside-Warp control defaults off and requires an explicit user gesture. +- Enablement states are local-only and must not sync through Settings Sync, Warp Drive, or server-backed preferences. +- Public `warpctrl` commands, direct protocol requests, scripts, ordinary settings files, registry/plist/defaults edits, and cloud preferences must not be able to enable local control. +- Disabling a context invalidates existing credentials for that context. +- Granular permissions are independent for metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, and underlying data mutations. +The foundation implementation may use private local-only settings as an interim storage mechanism only when those settings are excluded from user-visible settings files, generated schemas, Settings Sync, Warp Drive, and local-control settings read/write actions. +# Credential model +The credential model is scoped and action-aware. A credential or grant records: +- issuing instance; +- protocol version; +- action or action family; +- permission category; +- invocation context; +- authenticated-user subject when present; +- optional target/resource restrictions; +- issue time, expiry, and revocation identity; +- integrity protection against widening. +Credential issuance is app-owned. The CLI can request, load, and present credentials, but it cannot mint authority. The bridge validates credentials again on every request. +# Discovery +Each participating Warp process publishes per-user discovery metadata for compatible local instances. Discovery records contain instance identity, PID, build/channel metadata, protocol version, and only the endpoint/credential-reference data allowed by the selected invocation context. +Discovery requirements: +- owner-only file or socket permissions; +- no raw broad credential in plaintext discovery records; +- stale record pruning by PID and health checks; +- no terminal contents, environment values, auth tokens, Drive contents, or sensitive target state; +- no actionable outside-Warp endpoint or credential reference when outside-Warp control is disabled. +# Transport protections +- Bind local control listeners to loopback or an owner-only local socket. - Do not set permissive CORS headers. -- Reject control requests when their inside-Warp or outside-Warp invocation context is disabled, even if the request presents an otherwise valid credential. -- Authenticate every control request locally in the selected Warp app process before selector resolution or action dispatch. -- Reject missing, malformed, expired, revoked, or invalid credentials with structured authentication errors. -- Keep unauthenticated health metadata minimal and non-sensitive. -- Preserve structured error envelopes so the CLI does not collapse security failures into generic transport errors. -Remote URL support is a separate future transport mode. It should not reuse the local same-user credential model without additional identity, encryption, replay protection, and remote approval/policy design. -## Logged-in user requirements -Local-control validation always begins with local protocol state: discovery records, secure local credential references, scoped safety grants, execution-context proof, protocol version, request shape, allowlisted actions, typed parameters, and deterministic target selectors. -Some actions additionally require authenticated scripting authority: either a true logged-in Warp user in the selected app or an external API-key-backed subject with sufficient scopes. The action allowlist must declare this explicitly with a `requires_authenticated_user` or equivalent authenticated-scripting requirement field. -Default rule for new actions: -- New actions require an authenticated Warp user unless the implementer deliberately classifies them as logged-out-safe. -- The logged-out-safe set should remain meaningfully smaller and limited to local app structure, local appearance metadata, and other surfaces that do not depend on the user's cloud-backed Warp identity. -- Actions that read or mutate Warp Drive, AI conversation traces, synced settings, team/account data, or other user-authenticated state must require an authenticated user. -- Actions that execute user-authored cloud-backed content, such as running typed Warp Drive workflows, require both authenticated scripting authority and the appropriate high-risk action category. Agent-prompt submission remains excluded until separately reviewed. -When an authenticated-user or authenticated-scripting action is requested: -- app-user mode requires the selected app to have an active logged-in Warp user; -- API-key mode requires a validated key or exchanged assertion with sufficient scopes, subject, expiry, and revocation state; -- the presented local-control credential must include an authenticated grant for that user or API-key-backed subject; -- the user's granular settings must allow authenticated actions for the verified execution context or external API-key mode; -- the app bridge should execute app-user actions through the app's existing authenticated state rather than exporting raw cloud auth credentials to `warpctrl`. -If these conditions are not met, the app returns a structured error. It must not fall back to logged-out behavior or silently omit user-authenticated data from a result that claims success. -## Safety policy model -Safety grants are enforced in the app bridge after transport authentication and before target resolution or handler dispatch. This provides consistent “do not accidentally do more than requested” behavior for honest clients, not a sandbox for hostile same-user code. -The bridge must: -1. Parse the typed request envelope. -2. Verify protocol version compatibility. -3. Authenticate the credential. -4. Determine granted permission categories, execution context, target scopes, and authenticated-user grants. -5. Map the requested action to a required permission category, action family, execution-context requirement, and authenticated-user requirement. -6. Check optional target-family restrictions. -7. Reject requests that exceed the credential's grants with `insufficient_permissions`. -8. Reject authenticated-user or API-key-backed actions without the required app-user login, API-key validation, scopes, or authenticated grant with a structured authenticated-user/API-key error. -9. Only then resolve selectors and invoke the allowlisted handler. -The CLI frontend may provide helpful preflight errors, but those checks are advisory. Local app-side bridge enforcement is mandatory because other tools can bypass the official CLI and speak the protocol directly. -## Action permission categories -Every action belongs to exactly one state/data category for permission enforcement. These categories describe risk and intended safety prompts; they are not a sandbox or a complete OS-level access-control model. -### Metadata reads -Return app structure, app state, or configuration metadata without exposing terminal content, file content, Warp Drive object content, AI conversation content, or other user data. -Examples: -- `instance list`, `app active`, `app version`, `app ping`; -- `window list`, `tab list`, `pane list`, `session list`; -- `theme list`, `setting list`, `keybinding list`, and action/capability metadata; -- Drive object listing that returns object IDs, names, and types but not content. -Default unattended credentials may include this category. -### Underlying data reads -Return user content or data-bearing state without mutating state. -Examples: -- pane output, scrollback, current input buffer, command history, session replay, or transcript reads; -- Warp Drive object content reads; -- AI conversation content reads. -This category is separate from metadata because content often contains secrets, source code, file paths, command output, customer data, and other sensitive information. -### App-state mutations -Change visible local Warp UI state without directly changing underlying user data. -Examples: -- creating, focusing, activating, moving, or closing windows, tabs, panes, or sessions; -- splitting, navigating, maximizing, or resizing panes; -- opening panels, palettes, files, projects, notebooks, and other user-facing surfaces; -- inserting, replacing, or clearing staged input buffer text without submitting or executing it. -### Metadata/configuration mutations -Change persistent metadata or configuration without directly mutating primary user content. -Examples: -- renaming tabs or panes; -- changing tab colors; -- theme, font, zoom, keybinding, and allowlisted settings writes. -This category should not authorize terminal command execution, Warp Drive CRUD, Warp Drive sharing, or local file content operations. -### Underlying data mutations -Can change user data, execute code, submit prompts, or cause external side effects. -Examples: -- terminal command execution through the explicit `input.run` action; -- typed Warp Drive workflow execution or other approved user-authored runnable content; -- Warp Drive object create/update/delete/insert operations; -- Warp Drive object sharing, limited in v0 to making a personal object available to the user's current team through an explicit `share-to-team` command; -- AI conversation history mutation or other cloud-backed content mutation. -This category requires authenticated scripting identity plus explicit user or policy approval for unattended automation and integrations. It must remain separate from app-state mutation so a client that can open or focus Warp UI cannot automatically execute commands, submit prompts, mutate Warp Drive content, share Drive objects, or perform local file content operations. Accepted-command submission and agent-prompt submission remain unavailable until separately reviewed even if future protocol names are reserved for them. -## Target scoping and deterministic resolution -Targeting is part of security. The protocol must not convert ambiguous or stale selectors into best-effort mutations. -Rules: -- Instance selection happens before request dispatch and must be explicit when ambiguous. -- `active` selectors may be ergonomic defaults only when the active target is unambiguous. -- If no active target exists for a mutating request, return `missing_target` or `invalid_selector`. -- Explicit opaque IDs must resolve exactly or return `stale_target`. -- Index selectors must resolve to concrete IDs before execution and must not race into a different target silently. -- Session-scoped requests against non-terminal panes return `target_state_conflict`. -- File selectors use paths and must remain distinct from opaque UI IDs. -- Warp Drive selectors must include object type and resolve by opaque ID for automation stability, with name/path lookup only as an interactive convenience. -Target restrictions in credentials should be checked before invoking handlers. For example, a credential scoped to one session must not read another session's output even if the CLI can discover that session ID. -## Allowlisted handlers -The protocol must not expose arbitrary internal app actions by string. -Each supported command requires: -- a typed protocol action; -- typed parameters; -- validation rules; -- a documented state/data category and permission category; -- a documented `requires_authenticated_user` value; -- a documented allowed execution context, including whether external clients can run it or whether it is limited to verified Warp-terminal invocations; -- local app-side safety-grant checks; -- deterministic target resolution; -- a handler that reuses existing user-visible app behavior where possible; -- typed success and error responses. -Adding a new action should be additive and reviewable: extend the protocol enum, implement validation, map the action to a state/data category, declare whether it requires an authenticated user, declare its allowed execution contexts, add a handler, and add tests for authentication, safety-policy denial, authenticated-user denial, selector failure, and success behavior. -## Browser and localhost protections -Loopback is not sufficient by itself because browsers can send requests to localhost. -Required protections: -- No permissive CORS on control endpoints. -- No JSONP or browser-readable fallback formats. -- Valid scoped credentials required for all sensitive endpoints. -- Credentials stored outside browser-readable locations. -- Preflight and error responses must not reveal credentials or sensitive target state. -- The protocol should avoid GET endpoints for mutating actions. -The control plane should assume a malicious webpage can guess common localhost ports and send blind requests. It should not be able to read discovery records or obtain credentials. -## Auditing and logging -High-risk action support should include auditability without leaking sensitive data. -Recommended audit fields: -- timestamp; -- instance ID; -- credential ID or grant profile; -- action name, state/data category, and permission category; -- target type and opaque target ID when safe; -- success or structured error code. -Avoid logging: -- bearer tokens or scoped credentials; -- terminal output; -- command text for command execution unless explicitly approved by policy in a future version that supports execution; -- agent prompt text; -- input buffer contents; -- Warp Drive object contents; -- environment variable values. -Error-level logs should be used only for conditions that need developer attention, not normal denied requests or user-caused selector failures. -## Security- and safety-relevant errors -Structured errors are part of the security contract. -Important errors include: -- `local_control_disabled` when the relevant inside-Warp or outside-Warp scripting context is disabled in Settings > Scripting or has been disabled after credentials were issued; -- `unauthorized_local_client` for missing, malformed, expired, revoked, or invalid credentials; -- `insufficient_permissions` for valid credentials that lack the requested permission category or target scope; -- `authenticated_user_required` when an action requires authenticated scripting authority but the credential lacks an authenticated-user or API-key-backed grant; -- `api_key_required`, `api_key_invalid`, `api_key_expired`, `api_key_revoked`, and `api_key_insufficient_scope` for external API-key scripting failures, or equivalent structured variants if consolidated under existing authenticated-user errors; -- `authenticated_user_unavailable` when the selected Warp app has no logged-in Warp user or cannot access the required authenticated user state; -- `authenticated_user_mismatch` when an authenticated-user credential is bound to a different user subject than the user currently logged in to the selected Warp app; -- `execution_context_not_allowed` when the action or requested grant is not allowed from the verified invocation context, such as an external client attempting an in-Warp-only authenticated-user action; -- `ambiguous_instance` when multiple compatible instances cannot be resolved safely; -- `invalid_selector` for malformed or unsupported selector syntax; -- `missing_target` when an active/default target does not exist; -- `stale_target` when an explicit target ID no longer exists; -- `unsupported_action` for actions not implemented by the selected instance; -- `not_allowlisted` for actions intentionally excluded from the public control surface; -- `invalid_params` for malformed parameters; -- `target_state_conflict` when the target exists but cannot support the requested action. -The app must not downgrade these failures into broader default actions, and the CLI must preserve structured server errors in both human-readable and JSON output. -## Required controls before full catalog expansion -Before shipping each action family, verify that these controls are implemented for that family: -- Local control scripting must be enabled for the request's invocation context before the action family can run; inside-Warp control defaults on and outside-Warp control defaults off. -- The authoritative enablement states live under Settings > Scripting, are protected from external writes, and are local-only rather than synced. -- The action has a documented state/data category and required permission category. -- The action has a documented `requires_authenticated_user` value. New actions default to `true` unless explicitly reviewed as logged-out-safe. -- The action documents allowed execution contexts and whether external clients may run it. -- The bridge maps the action to that permission category locally in the selected Warp app process. -- The credential model can express the required grant. -- The credential model can express authenticated-user grants and verified execution context requirements when needed. -- The handler checks optional target restrictions where relevant. -- Requests with invalid credentials or insufficient safety grants fail before selector resolution or mutation. -- Requests that require authenticated-user access fail unless the selected app has a true logged-in Warp user and the credential includes an authenticated-user grant. -- Ambiguous, missing, and stale targets return structured errors. -- Tests cover allowed, insufficient-permission, and denied credential paths. -- Logs and errors do not expose credentials, terminal contents, command text, or sensitive settings. -- Operator docs distinguish available commands from planned catalog entries. -- Initial public action-family docs and tests prove terminal command execution, workflow execution, accepted-command submission, and agent-prompt submission are not allowlisted; input-buffer staging never submits the buffer. -- Initial public action-family docs and tests prove local file content reads, writes, appends, deletes, and filesystem-content mutations are not allowlisted; file/path support is limited to opening visible Warp UI surfaces and listing files already open in Warp. -## Platform requirements -### macOS and Linux -Discovery files must be stored in a per-user directory with owner-only permissions. -On macOS, raw credential material and the authoritative local-control enablement states should live in Keychain, not in the discovery record or an ordinary preferences file. Keychain access should be constrained to Warp-owned signed binaries or helpers using code-signing based access control. The enablement states should be writable by the Warp app's Settings > Scripting flow and not writable by `warpctrl`. The discovery record should hold only metadata and a credential reference when the relevant inside-Warp or outside-Warp context is enabled. -On Linux, raw credentials and the authoritative enablement states should prefer platform-secure storage where available; otherwise short-lived scoped credentials may live in owner-only local state with strict file and directory permissions. If an enablement state falls back to owner-only local state, the weaker same-user protection should be documented. -Unix domain sockets with peer credential checks may be considered for stronger same-machine identity than bearer tokens alone. -### Windows -Discovery records and credential material must live under the current user's app data directory with ACLs restricted to the current user, Administrators, and SYSTEM. -The authoritative enablement states should use Credential Manager, DPAPI-backed protected storage, or an equivalent app-controlled protected store rather than normal registry settings that arbitrary same-user processes can write. -Windows support for authenticated local control should not be considered complete until the implementation creates, validates, and tests those ACLs and the protected enablement-state behavior for both inside-Warp and outside-Warp settings. -## Remote control is separate -The local architecture intentionally assumes same-machine, same-user control over a loopback listener. Future remote URLs must use a different security design that includes: -- transport encryption; -- remote identity and authentication; -- replay protection; -- explicit user or admin approval/policy; -- network exposure review; -- separate credential issuance from local discovery; -- remote-safe auditing and revocation. -Remote support should not be enabled by simply allowing `warpctrl` to point the existing local credential at an arbitrary URL. +- Require valid scoped credentials for control requests. +- Use non-GET request methods for mutations. +- Keep unauthenticated health metadata minimal. +- Preserve structured error envelopes for security failures. +# Permission categories +## Metadata reads +Return local structure or non-content metadata, such as instances, app version, active chain, windows, tabs, panes, sessions, themes, setting keys, keybindings, action metadata, capability metadata, project identity, and Drive object IDs/names/types. +## Underlying data reads +Return user data without changing it, such as block output, terminal history, input buffer contents, Drive object contents, AI conversation content, and similar content-bearing state. +## App-state mutations +Change visible local Warp UI state without changing underlying user data, such as creating tabs, splitting panes, focusing targets, opening panels, opening files/projects/views, and staging input text without execution. +## Metadata/configuration mutations +Change persistent metadata or configuration, such as names, colors, themes, font size, zoom, keybindings, or allowlisted settings. +## Underlying data mutations +Can change user data, execute code, run approved workflows, or cause external side effects. This category includes `input.run`, Drive object create/update/delete/insert/share-to-team, and `drive.workflow.run`. It requires authenticated Warp-terminal authority plus explicit underlying-data-mutation permission. +# Authenticated-user requirements +New catalog actions default to authenticated-user required unless deliberately reviewed as logged-out-safe. Logged-out-safe actions are limited to local app structure, local appearance metadata, and UI/app-state operations that do not expose or mutate Warp-user-backed state. +Actions require authenticated user state when they read or mutate Warp Drive, AI conversation traces, synced settings, team/account data, cloud-backed user state, or user-authored runnable content. +# Deterministic target resolution +- Instance selection occurs before request dispatch. +- Multiple compatible instances require explicit selection unless there is one unambiguous active instance. +- Active selectors are allowed only when unambiguous. +- Explicit IDs resolve exactly or return `stale_target`. +- Missing targets return `missing_target`. +- Ambiguous selectors return `ambiguous_target`. +- Session-scoped actions against non-terminal panes return `target_state_conflict`. +- The bridge must not fall back to neighboring targets. +# Structured errors +Security- and safety-relevant errors include: +- `local_control_disabled` +- `unauthorized_local_client` +- `insufficient_permissions` +- `authenticated_user_required` +- `authenticated_user_unavailable` +- `execution_context_not_allowed` +- `ambiguous_instance` +- `ambiguous_target` +- `stale_target` +- `invalid_selector` +- `unsupported_action` +- `not_allowlisted` +- `invalid_params` +- `target_state_conflict` +- `missing_target` +- `no_instance` +The CLI must preserve these errors in human-readable and JSON output. +# Required controls for action families +Before an action family is advertised as implemented: +- The action exists in the typed catalog. +- Metadata declares state/data category, permission category, authenticated-user requirement, allowed invocation contexts, target scope, parameter spec, and result spec. +- The bridge enforces permission category and authenticated-user policy before selector resolution. +- Invalid, expired, revoked, insufficient, disabled, unsupported, and unallowlisted requests fail closed. +- Tests cover allowed and denied credential paths, authenticated-user denial, selector failure, and success behavior. +- Logs and errors avoid credentials and sensitive user data. +- Operator docs distinguish implemented actions from catalog stubs. diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index 311ff9a29c..91643497f3 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -1,611 +1,87 @@ # Context -`PRODUCT.md` defines a standalone local Warp control CLI binary, provisionally named `warpctrl`, with an allowlisted action catalog, deterministic addressing across multiple running Warp app processes, and an incremental implementation plan. -`SECURITY.md` is the normative security architecture for this feature. Implementation work must follow it for the top-level Settings > Scripting surface, protected enablement storage, granular permissions, discovery metadata, credential storage, scoped safety grants, verified execution context, authenticated-user requirements, localhost/browser protections, permission-category enforcement, deterministic target resolution, and local app-side validation. The long-term architecture includes separate verified inside-Warp and outside-Warp invocation contexts, but the current foundation implementation supports outside-Warp requests only and must reject `InvocationContext::InsideWarp` until the verified Warp-terminal proof broker lands. If this technical plan and `SECURITY.md` disagree, update the plan before implementing rather than treating the security architecture as optional follow-up work. -The existing app already has three relevant building blocks: -- `crates/http_server/src/lib.rs (7-61)` runs a native-only loopback Axum server on fixed port `9277`. -- `app/src/lib.rs (1993-2001)` registers that HTTP server in the native app and currently merges only installation-detection and profiling routers. -- `crates/app-installation-detection/src/lib.rs (15-60)` and `app/src/profiling.rs (208-242)` show the current local HTTP routes. They are narrow endpoints, not a general control plane. -Warp also already has the app-side behaviors the control API should reuse rather than reimplement: -- `app/src/terminal/view/action.rs (193-196)` defines split-pane terminal actions. -- `app/src/pane_group/mod.rs (4266-4360, 5377-5414)` shows pane creation/splitting semantics and how split events mutate pane layout. -- `app/src/workspace/action.rs (153-156)` defines the existing tab creation actions, including default and terminal-tab variants. -- `app/src/workspace/view.rs (21203-21244)` shows how user-visible default and terminal-tab actions are dispatched. -- `app/src/settings/theme.rs (9-82)` defines persisted theme settings. -- `app/src/themes/theme_chooser.rs (416-458)` shows persisted theme selection behavior. -- `app/src/workspace/action.rs (95-776)` is the largest existing inventory of user-visible workspace actions and informs the allowlist catalog. -- `app/src/workspace/util.rs (12-18)` defines `PaneViewLocator`, and `app/src/pane_group/pane/mod.rs (84-177)` defines serializable pane identifiers, both useful reference points for selector resolution. -- `app/src/uri/mod.rs (822-1093, 1166-1364)` demonstrates external intents being resolved into active windows/workspaces and dispatched into running app state. -The current Oz CLI build/distribution model is also directly relevant because the control CLI should follow the same standalone-artifact approach rather than relying on the Warp GUI executable to service ordinary shell invocations: -- `crates/warp_cli/src/lib.rs (88-188, 316-418)` defines the existing CLI/parser conventions and channel-specific command naming support. -- `app/src/lib.rs (631-746)` routes CLI invocations into CLI execution rather than GUI launch. -- `script/macos/bundle (353-735)` and `script/linux/bundle (157-294)` build standalone CLI artifacts with the `standalone` feature. -- `.github/workflows/create_release.yml (423-554, 660-858, 992-1276)` publishes macOS/Linux CLI artifacts. -- `script/windows/windows-installer.iss (235-263)` shows the current Windows helper-wrapper pattern for CLI access. -The most important constraint surfaced by this code is that the current fixed-port local HTTP server cannot be the entire solution for a multi-process control API. If multiple local Warp processes attempt to expose mutating routes through the same fixed port, only one can own it. The control design therefore needs explicit per-process discovery and addressing. -## 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: -- Long term, local control scripting has separate inside-Warp and outside-Warp enablement states. Inside-Warp control for verified Warp-managed terminal sessions can default on only after the app-issued proof broker exists; outside-Warp control for external terminals, scripts, IDEs, launch agents, and other same-user processes defaults off. -- In the current foundation slice, only outside-Warp enablement and permissions are implemented. Inside-Warp credential requests must be rejected and inside-Warp settings must not be exposed in the UI. -- In the long-term model, both controls live under a new top-level Settings pane page named **Scripting**. -- The authoritative enablement states are local-only, not Settings Sync'd, and stored in protected local storage rather than ordinary user-editable settings. -- The current foundation branch must mark all implemented outside-Warp local-control settings as `private: true` and `sync_to_cloud: SyncToCloud::Never`. They must not appear in the user-visible `settings.toml` file, generated settings schema, Settings Sync, Warp Drive, server-backed preferences, or any future `warpctrl settings` surface. -- `warpctrl`, direct protocol requests, shell scripts, config files, registry/plist edits, defaults writes, and server-backed preferences must not be able to enable either setting. -- Discovery records do not publish actionable endpoints or credential references for disabled outside-Warp control. -- Credential issuance is unavailable when the request's invocation context is disabled. -- Raw credential material is kept out of plaintext discovery records and stored in platform secure storage where available. -- The broker distinguishes verified Warp-terminal invocations from external invocations using an app-issued execution-context proof, not a caller-declared label. Until that broker exists, `InsideWarp` is a reserved protocol concept that must not receive credentials. -- External invocations default to a smaller logged-out-safe action set that does not touch user-authenticated data. -- Verified Warp-terminal invocations may receive authenticated-user grants only when the selected app has a true logged-in Warp user and local-control settings allow authenticated-user actions from Warp terminals. -- The app rejects disabled, unauthenticated, expired, revoked, insufficient-scope, unsupported, malformed, ambiguous, missing-target, and stale-target requests with structured errors. -- Every action has a documented state/data category and the app bridge enforces the required permission category locally before selector resolution or handler dispatch. -- Every action has a documented `requires_authenticated_user` value and allowed execution contexts. New actions default to requiring an authenticated user unless explicitly reviewed as logged-out-safe. -- Granular local-control settings under Settings > Scripting gate the maximum grants for metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, underlying data mutations, authenticated-user actions from Warp terminals, and authenticated-user actions from external clients. -- Permission categories are treated as user-intent and accident-prevention guardrails, not as strong same-user malicious-app isolation. -- Remote control remains out of scope for the local same-machine credential model. -The first implementation slice should include the protected enablement gate, credential issuance checks, and app-side permission-category enforcement even if the only mutating action initially implemented is `tab.create`. Shipping `tab.create` without the enablement and validation architecture would create the wrong foundation for the full catalog. -### 1. Protocol crate and stable envelope -Create a small shared protocol crate or equivalent shared module used by both the app server and standalone CLI client. It should define: -- Protocol version metadata. -- Discovery/health response types. -- Execution-context proof/request types for verified Warp-terminal invocations versus external invocations. -- Action metadata describing state/data category, required permission grant, `requires_authenticated_user`, allowed execution contexts, and target families. -- Selector types: - - `InstanceSelector` - - `WindowSelector` - - `TabSelector` - - `PaneSelector` - - `SessionSelector` - - `BlockSelector` - - `FileSelector` - - `DriveObjectSelector` -- Opaque protocol-facing ID newtypes for instance/window/tab/pane/session identifiers. -- Allowlisted `ControlAction` variants and typed parameter payloads. -- Success/error envelopes with stable machine-readable error codes. -The protocol should treat target IDs as opaque. The app may encode existing runtime identifiers internally, but the public wire contract should not require callers to understand `EntityId`, `PaneId`, or other implementation types. -Recommended selector variants: -- `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)`, `Index(u32)`. -- `BlockSelector`: `Id(BlockId)`. -- `FileSelector`: `Path { path, line, column }`. -- `DriveObjectSelector`: `Id(DriveObjectId)` or `Lookup { object_type, name_or_path }`. -Index selectors are resolved only within their parent selector context, so tab index resolution requires a resolved window and pane/session index resolution requires a resolved tab or pane. Title and name/path lookup selectors are ergonomic helpers for interactive use and must fail on ambiguity rather than choosing the first match. -Recommended top-level request shape for `tab.create`: -```json -{ - "protocol_version": 1, - "request_id": "client-generated-id", - "action": "tab.create", - "target": { - "window": "active" - }, - "params": {} -} -``` -Recommended response shape: -```json -{ - "ok": true, - "protocol_version": 1, - "request_id": "client-generated-id", - "instance_id": "opaque-instance-id", - "resolved_target": { - "window_id": "opaque-window-id", - "tab_id": "opaque-tab-id" - }, - "result": {} -} -``` -Error payloads should include stable codes defined in `SECURITY.md`, including `local_control_disabled`, `unauthorized_local_client`, `insufficient_permissions`, `authenticated_user_required`, `authenticated_user_unavailable`, `execution_context_not_allowed`, `ambiguous_instance`, `ambiguous_target`, `stale_target`, `invalid_selector`, `unsupported_action`, `not_allowlisted`, `invalid_params`, `target_state_conflict`, `missing_target`, and `no_instance`. -### 2. Per-process discovery instead of fixed-port-only routing -Keep the existing fixed-port HTTP behavior intact for installation detection/profiling compatibility. Add a separate local-control listener that follows the same native Axum/Tokio pattern but supports multiple local Warp app processes. -Recommended design: -- Each participating Warp process creates a random opaque `instance_id` at startup. -- Each process binds a loopback control listener on an ephemeral port or an app-managed available port. -- Each process writes a discovery record into a secure per-user Warp state directory. The record should contain: - - `instance_id` - - PID - - channel/build metadata - - control-listener endpoint - - protocol version - - start timestamp - - credential metadata or secure-storage references only when the relevant inside-Warp or outside-Warp context is enabled -- The CLI loads discovery records, removes or ignores stale records after health checks, and chooses an instance using the product selector rules. -- `warpctrl instance list` is a CLI-first projection of this discovery registry plus health responses. -When outside-Warp control is disabled, discovery must follow `SECURITY.md`: either publish no actionable local-control record for external clients or publish only a minimal disabled-status record with no endpoint authority or credential reference. -This design preserves the current `9277` behavior while avoiding cross-process port contention for the new control API. -### 3. Local authentication, enablement, and safety boundary -Mutating localhost routes should not copy the permissive CORS posture of `/install_detection`. -Recommended local trust model: -- No browser-readable CORS allowance on control endpoints. -- The relevant implemented Scripting setting must allow the request context before credentials are minted or sensitive control requests are accepted. In the current foundation branch that means outside-Warp only; future inside-Warp support must add its own verified setting gate. -- The authoritative enablement bit must live in protected local storage and must not be writable by `warpctrl` or ordinary same-user preference/config edits. -- Per-instance raw credential material must be kept out of plaintext discovery records and stored in platform secure storage where practical. -- The CLI may load or request scoped credentials through an app-owned broker/helper, but it must not mint authority itself. -- The broker verifies whether the invocation originated from a Warp-managed terminal session before issuing in-Warp-only grants. -- The broker issues authenticated-user grants only when the selected app has a true logged-in Warp user and the relevant local-control permission is enabled. -- The app rejects disabled-state, missing, malformed, invalid, expired, or revoked credentials before selector resolution or mutation. -- The app maps every action to a state/data category and rejects insufficient grants before selector resolution or mutation. -- The app maps every action to a `requires_authenticated_user` value and allowed execution contexts, rejecting mismatches before selector resolution or mutation. -- Health metadata exposed without credentials, if needed for stale-record pruning, must not reveal mutating capabilities, credentials, or sensitive target state. -This keeps the protocol local and scriptable without creating an ambient browser-to-localhost control surface. -Do not ship the first slice as a plaintext discovery bearer token, even for same-user human CLI use. The first slice is the foundation for underlying data reads, app-state mutations, metadata/configuration mutations, and underlying data mutations, so it must establish the protected enablement, credential storage, scoped grant, and app-side enforcement model from `SECURITY.md`. -### 4. Future verified Warp-terminal invocation context -The current foundation branch does not implement verified inside-Warp invocation. `InvocationContext::InsideWarp` and `ExecutionContextProof::VerifiedWarpTerminal` may remain in the shared protocol as reserved future concepts, but the credential broker must reject them until the proof broker described here exists. -Minimum implementable design: -- When Warp creates or Warpifies a terminal session, the app creates a high-entropy per-session capability and records verifier state in an app-owned terminal-session registry. -- The registry entry is bound to the selected app instance, terminal/session identifier, issuing process generation, expiry, and revocation state. -- The shell receives only proof material needed by `warpctrl`, such as an opaque handle plus a short-lived token or challenge-response input. Plain environment variables may carry handles or hints, but a caller-set variable must not be sufficient authority. -- `warpctrl` invoked from that terminal sends `InvocationContext::InsideWarp` and `ExecutionContextProof::VerifiedWarpTerminal` to `/v1/control/credentials` when it has proof material. Without proof material it must use `OutsideWarp`. -- The broker verifies the proof against the app-owned registry, including app instance, session liveness, expiry, revocation, and nonce or challenge binding before minting any inside-Warp scoped credential. -- The broker then checks Settings > Scripting and permission-category policy for the requested action. A valid proof raises the maximum eligible grant set; it does not bypass user settings, action metadata, authenticated-user requirements, target scopes, or bridge enforcement. -- The minted credential records `invocation_context: InsideWarp`, the granted permission category, expiry, instance, and any authenticated-user subject. The app bridge revalidates the grant and current app policy on every control request. -Hardened follow-ups can strengthen this minimum design by storing secret material in platform secure storage, exposing only opaque handles through the shell, using Unix-domain-socket or named-pipe peer-credential checks, binding proofs to broker challenges, and invalidating proofs on shell/session teardown, app logout, user switch, or Settings > Scripting changes. These hardening layers improve direct-token theft resistance, but they do not create a perfect security boundary against malicious same-user software with process, filesystem, Accessibility, or screen-observation access. -### 5. Authenticated scripting identity and API-key grants -The full control catalog includes Warp Drive data mutation and execution-underlying actions. Those actions require an authenticated scripting layer in addition to local-control credentials. Local-control credentials prove authority to call the local app; authenticated scripting credentials prove the Warp user or automation identity allowed to request user-backed or high-risk actions. -#### Inside-Warp authenticated scripting -For `warpctrl` launched inside a Warp-managed terminal, use the verified terminal proof broker from the previous section. When the proof is valid and the selected app is logged into Warp, the broker may mint an authenticated-user grant bound to the app's current user subject. The grant is available only if Settings > Scripting enables authenticated-user actions for verified Warp terminals and the requested action's permission category is enabled. -The CLI must not receive raw Firebase, OAuth, server, or session tokens. The app bridge executes authenticated actions through the selected app's existing auth state and rejects the grant if the app logs out, switches users, or the grant subject no longer matches the app user. -#### External API-key authenticated scripting -For `warpctrl` launched outside Warp, by cron, or by another pure scripting environment, introduce a separate API-key path. The user creates or supplies a Warp-issued scripting API key with explicit scopes such as local-control authenticated reads, Drive mutation, or execution-underlying actions. The CLI may reference the key from a secret manager or environment variable such as `WARPCTRL_API_KEY`, or store it in platform secure storage through `warpctrl auth api-key set --key-stdin`; it must never print or write the raw key to discovery records, logs, JSON output, shell completions, or repo config. -The local broker exchanges or validates the API key with Warp services, obtains a short-lived signed identity assertion, and mints a local authenticated-user grant only when all of the following hold: -- outside-Warp scripting is enabled; -- external authenticated-user grants are enabled separately from logged-out outside-Warp control; -- the API key is valid, unexpired, unrevoked, and scoped for the requested permission category and action family; -- the selected app is logged into the same Warp user subject, or the action is explicitly designed to use API-key-backed identity without exporting app cloud tokens; -- the requested local-control permission category is enabled; -- any resource or target restrictions in the key and grant are satisfied. -The grant should record the API-key subject, scopes, credential ID, expiry, invocation context, permission category, and optional target/resource restrictions. The app bridge revalidates the grant before selector resolution and handler dispatch. -#### Auth command surface and storage -Add CLI and broker support for: -- `warpctrl auth status [selectors]` to report selected app login state, configured API-key subject metadata, and available authenticated grant modes without exposing secrets. -- `warpctrl auth login [selectors]` to focus the selected app's normal sign-in UI for interactive app login. -- `warpctrl auth api-key set --key-env <env_var>|--key-stdin [selectors]` to store or reference an external scripting key. -- `warpctrl auth api-key status [selectors]` to show key subject/scope metadata. -- `warpctrl auth api-key revoke [selectors]` to delete the local reference and revoke the server-side key where supported. -Store raw API keys only in platform secure storage where available. Environment-variable use is allowed for non-interactive automation, but commands and docs should prefer secret-manager injection over plaintext shell profiles. -### 6. App-side request bridge onto the UI/application context -The HTTP handler runs on a Tokio runtime thread owned by the local-control server. It cannot directly access or mutate Warp's UI models, views, or app context because all WarpUI state is single-threaded and owned by the main app event loop. The bridge solves this by sending a closure from the Tokio handler thread to the main thread, executing it in the model's context, and returning the result to the waiting HTTP handler. -#### Thread model -- **Tokio runtime thread (HTTP handler):** Owns the Axum router, receives HTTP requests, authenticates, deserializes the `RequestEnvelope`. Cannot touch `AppContext`, views, or models. -- **Main app thread:** Owns all WarpUI entities (`App`, `AppContext`, views, models). All UI state reads and mutations must happen here. -- **Bridge:** Transfers a typed closure from the Tokio thread to the main thread, executes it with `&mut ModelContext`, and sends the return value back. -#### Implementation: `ModelSpawner` -The bridge uses WarpUI's `ModelSpawner<T>` mechanism, which is the standard way for background threads to schedule work on a model's main-thread context: -1. During app initialization, a `LocalControlBridge` singleton model is created. The model's `ModelContext::spawner()` method returns a `ModelSpawner<LocalControlBridge>` — a cloneable, `Send` handle that can enqueue closures from any thread. -2. The `ModelSpawner` is stored in the Axum router's shared state (`ControlServerState`), making it available to every HTTP handler. -3. When an HTTP request arrives, the handler calls `spawner.spawn(|bridge, ctx| { ... }).await`: - - `spawn` sends a boxed `FnOnce(&mut LocalControlBridge, &mut ModelContext<LocalControlBridge>) -> R` closure through an `async_channel` to the main thread's task-callback loop. - - The main thread dequeues the closure, constructs a fresh `ModelContext` for the bridge model, and calls the closure. - - Inside the closure, the bridge has full access to `ModelContext`, which derefs to `AppContext`. This means it can call `ctx.windows()`, `ctx.views_of_type::<Workspace>(window_id)`, `workspace.update(ctx, ...)`, and any other main-thread API. - - The closure returns a typed result (e.g., `ResponseEnvelope`), which is sent back to the Tokio thread via a `oneshot` channel. -4. The HTTP handler awaits the oneshot result and serializes it as the HTTP response. -#### Concrete flow for `tab.create` -``` -HTTP handler (Tokio thread) - │ - ├─ verify inside-Warp or outside-Warp context is enabled - ├─ verify credential, execution context, safety grant, and authenticated-user grant - ├─ deserialize RequestEnvelope - ├─ call bridge_spawner.spawn(move |bridge, ctx| { - │ bridge.handle_request(request, ctx) // runs on main thread - │ }).await - │ - └─ serialize ResponseEnvelope as JSON - -LocalControlBridge::handle_request (main thread) - │ - ├─ verify protected context-specific enablement state is still enabled - ├─ map action to required permission category - ├─ map action to authenticated-user and execution-context requirements - ├─ verify presented credential grants that category, target family, execution context, and authenticated-user access - ├─ match request.action.kind - │ └─ ActionKind::TabCreate - │ ├─ validate_tab_create_target(&request.target) - │ ├─ ctx.windows().active_window() - │ │ └─ if none: return invalid_selector / missing_target - │ ├─ ctx.views_of_type::<Workspace>(window_id) - │ └─ workspace.update(ctx, |workspace, ctx| { - │ workspace.handle_action( - │ &WorkspaceAction::AddTerminalTab { hide_homepage: false }, - │ ctx, - │ ) - │ }) - │ - └─ return ResponseEnvelope::ok(request_id, json!({ ... })) -``` -#### Why this pattern -- **Thread safety.** WarpUI's entity/view system is not `Send` or `Sync`. The only safe way to interact with it from a background thread is through `ModelSpawner`, which serializes access through the main event loop. -- **Synchronous result.** Unlike fire-and-forget patterns (e.g., URI intent dispatch in `app/src/uri/mod.rs`), the `spawn` call returns a concrete `Result<R, ModelDropped>`, so the HTTP handler can produce a structured success or error response. -- **Reuses existing infrastructure.** `ModelSpawner` is already used throughout the codebase for background-to-main-thread communication (e.g., async file I/O results, network responses). No new concurrency primitive is needed. -- **Action dispatch reuses existing app behavior.** The bridge calls `workspace.handle_action(&WorkspaceAction::AddTerminalTab { ... }, ctx)` — the exact same method the UI keybinding system uses. This ensures the control CLI produces identical behavior to the corresponding user action, including side effects like tab count updates, focus changes, and event emissions. -- **Deterministic targeting.** The bridge must not silently fall back from the active window to an arbitrary ordered window for mutating actions. If the caller relies on the default active selector and no active window exists, return a structured missing-target or invalid-selector error. If future command forms allow explicit window IDs, resolve the explicit ID exactly or return `stale_target`. -#### Adding new action handlers -To add a new action to the bridge: -1. Add a variant to `ActionKind` in `crates/local_control/src/protocol.rs`. -2. Document its `SECURITY.md` state/data category, required permission grant, `requires_authenticated_user` value, and allowed execution contexts. -3. Add a match arm in `LocalControlBridge::handle_request` in `app/src/local_control/mod.rs`. -4. Before selector resolution or dispatch, verify local control is enabled and the presented credential grants the action category, target family, execution context, and authenticated-user access if required. -5. Inside the match arm, use `ctx` (which is a `&mut ModelContext<LocalControlBridge>` that derefs to `&mut AppContext`) to resolve selectors and dispatch the action onto existing app types. -6. Return a `ResponseEnvelope::ok(...)` or `ResponseEnvelope::error(...)` with the result. -The bridge closure has access to the full `AppContext` API surface, including `ctx.windows()`, `ctx.window_ids()`, `ctx.views_of_type::<T>(window_id)`, `handle.update(ctx, ...)`, and `handle.read(ctx, ...)`. This makes it straightforward to wire new actions to existing UI behavior without introducing new concurrency concerns. -### 7. Target resolution model -Implement target resolution as a reusable component rather than scattering lookup logic across handlers. -Recommended resolution order: -1. Select instance in the CLI/discovery layer. -2. Resolve window inside the target process. -3. Resolve tab within the window. -4. Resolve pane within the tab/pane-group context. -5. Resolve session only for session-scoped commands. -6. Resolve block/file/Drive selectors only for commands whose action metadata declares that target family. -Selector behavior: -- `active` resolves from current app focus/selection state. -- Explicit opaque IDs must resolve exactly or return `stale_target`. -- Index selectors are allowed only for user-visible indexed concepts and should resolve to a concrete opaque ID before execution. -- Title, name, and path selectors are convenience selectors. They must be exact by default, document any future fuzzy behavior explicitly, and return `ambiguous_target` when more than one target matches. -- A session-scoped request against a non-terminal pane returns `target_state_conflict`. -Target resolution must happen after protected enablement, authentication, and safety-grant checks. This prevents denied requests from learning more target state than necessary and keeps enforcement centralized. -Implementation references: -- Window-level active selection already exists inside the app through `WindowManager`. -- Pane scoping can build on the conceptual model of `PaneViewLocator` in `app/src/workspace/util.rs (12-18)`. -- Existing URI intent routing in `app/src/uri/mod.rs (895-1093)` shows how to locate workspaces/windows and avoid silently acting in the wrong place. -#### CLI selector grammar -`crates/warp_cli/src/local_control.rs` should expose a shared selector argument group that is flattened into every command that accepts app targets. The parser must support: -- Instance selectors: `--instance <instance_id>` and `--pid <pid>`, with clap conflicts. -- Window selectors: `--window <active|id:<id>|index:<n>|title:<title>>`, `--window-id <id>`, `--window-index <n>`, and `--window-title <title>`, with one form allowed. -- Tab selectors: `--tab <active|id:<id>|index:<n>|title:<title>>`, `--tab-id <id>`, `--tab-index <n>`, and `--tab-title <title>`, with one form allowed. -- Pane selectors: `--pane <active|id:<id>|index:<n>>`, `--pane-id <id>`, and `--pane-index <n>`, with one form allowed. -- Session selectors: `--session <active|id:<id>|index:<n>>`, `--session-id <id>`, and `--session-index <n>`, with one form allowed. -- Block/file/Drive selectors only on commands that need them: `--block-id <id>`, path arguments or `--path <path>` plus `--line`/`--column`, and Drive object ID arguments or `--drive-id <id>`. -The CLI converts these flags into the protocol `TargetSelector` before sending the request. It must not rely on positional entity IDs for commands like `window close 1`; target entities are selected through the shared selector flags so command arguments remain reserved for action parameters. -### 8. Allowlisted handler families -Use one handler module per action family. The protocol layer owns parsing/validation; handler modules own target resolution and delegation to existing app logic. -Recommended modules/families: -- Discovery/state: - - instances, version, active chain, windows/tabs/panes/sessions listings. -- Window/tab: - - new, focus, close, activate, move, rename, color, close variants. -- Pane: - - split, focus, navigate, close, maximize, resize. -- Input/session: - - insert, replace, clear, run command, cycle session, mode switch where supported. -- Appearance/settings: - - theme list/set, system-theme controls, font/zoom actions, allowlisted settings reads/writes/toggles. -- Panels/surfaces: - - settings/page/search, palettes, left/right panels, Drive, resource center, code review, vertical tabs, AI assistant. -- Files/projects: - - app-state-only path opening, project opening, and metadata reads for files already open in Warp. File content reads and filesystem-content mutations are intentionally excluded from the public `warpctrl` catalog. -- Warp Drive: - - object listing/inspection/opening, object creation/update/delete/insert, opening the share dialog, the v0 personal-to-team share mutation, and typed workflow execution where supported. -Do not use a generic “dispatch action by string” endpoint. Every handler should be reachable only through an explicit `ControlAction` variant. -#### WarpCtrlBehavior review gate -The public `ControlAction` catalog remains the source of truth for the wire protocol, CLI parser, permission metadata, generated documentation, and app bridge handlers. Internal app actions such as `WorkspaceAction`, `TerminalAction`, `PaneGroupAction`, settings actions, and future user-visible action enums must not become the public protocol directly because they can contain transient view locators, indices, debug-only variants, implementation-specific payloads, and unstable internal semantics. -To prevent drift between user-visible Warp behavior and the `warpctrl` catalog, every user-visible app action enum should implement a `WarpCtrlBehavior` review mapping. The mapping is a code-level forcing function, not an automatic exposure mechanism. It answers whether each internal app action is: -- `Exposed` through a specific public `ControlAction` kind. -- `CoveredBy` an existing public `ControlAction` kind because several internal actions map to one stable CLI behavior. -- `Excluded` with an explicit reason such as debug-only, unsafe/privileged, internal implementation detail, not user-visible, no deterministic targeting model, no stable public semantics, or prohibited in the initial public version. -- `Deferred` with an explicit reason and tracking issue when the action might belong in `warpctrl` later but needs additional product, security, selector, or protocol design. -`WarpCtrlBehavior` implementations must use exhaustive matches without wildcard arms. Adding a new variant to a reviewed action enum should fail compilation until the developer or agent deliberately classifies its relationship to `warpctrl`. This mirrors the existing exhaustive-action-review style used by app-state saving decisions and makes “should this exist in Warp Control?” part of the ordinary code path for adding new user-visible actions. -Recommended shape: -- Define a shared `WarpCtrlBehavior` trait in the local-control integration layer or another app-visible module that does not force the core `warpui::Action` blanket implementation to change. -- Define review enums such as `WarpCtrlActionReview`, `WarpCtrlExclusionReason`, and `WarpCtrlDeferredReason`. -- Implement `WarpCtrlBehavior` for the major user-visible action enums, starting with `WorkspaceAction` and `TerminalAction`. -- Keep the mapping one-way from internal behavior to public catalog metadata. `WarpCtrlBehavior::Exposed(ControlActionKind::TabCreate)` means the action is represented by the public `tab.create` command; it does not mean raw `WorkspaceAction::AddTerminalTab` is serializable or dispatchable over the protocol. -- Add tests that collect reviewed action kinds and verify every `ControlActionKind` has protocol metadata, permission metadata, CLI parser coverage, generated-doc coverage, and an app-side handler before it can be advertised as supported. -The `warpui::Action` trait should not be extended for this purpose because it currently has a blanket implementation for any `Any + Debug + Send + Sync` type. The enforcement point is the concrete user-visible action enums and binding/action registration surfaces, where exhaustive review can be required without weakening the allowlisted protocol boundary. -### 9. First slice: prove discovery and `tab.create` -The first `warpctrl` implementation slice should land the minimum cross-cutting architecture plus a single representative tab mutation: -- Shared protocol types and error envelopes. -- `FeatureFlag::WarpControlCli` and Cargo feature `warp_control_cli`, with app-side runtime gating for settings, discovery, bridge registration, and local-control endpoints. -- New top-level Settings > Scripting page rendered only while `FeatureFlag::WarpControlCli` is enabled. The current foundation exposes outside-Warp local-control settings only; verified inside-Warp controls are deferred until the proof broker exists. -- Protected local-only enablement storage where outside-Warp control defaults off. Future inside-Warp enablement must use the same protected-storage class before it is exposed. -- As an interim foundation step, the outside-Warp top-level enablement and granular permission bits live in the typed `LocalControlSettings` group as private settings with `SyncToCloud::Never`, explicit private storage keys, and no `toml_path`. This keeps them out of the user-visible settings file and generated settings schema while leaving the protected-storage migration as a required pre-ship hardening step. -- Granular outside-Warp local-control permission storage under Settings > Scripting for metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, and underlying data mutations. Future inside-Warp permissions should be added only with verified terminal proof support. -- Discovery registry and CLI instance selection. -- A standalone `warpctrl` binary or artifact path that runs control commands without starting the GUI app runtime. -- Per-process authenticated local-control server that refuses sensitive work when outside-Warp control is disabled and rejects inside-Warp credential requests until verified terminal proof support is implemented. -- Scoped credential issuance/storage with no raw credentials in plaintext discovery records, including execution-context fields and authenticated-user grant fields. -- App-side request bridge and selector resolver. -- Action-category mapping and app-side safety-grant enforcement. -- Action metadata for `tab.create` that deliberately classifies it as a logged-out-safe app-state mutation only when the user's granular local-control settings allow app-state mutation. -- Read-only `ping/version` plus `warpctrl instance list` or equivalent minimal discovery command. -- End-to-end `warpctrl tab create` for the selected instance, reusing the same app behavior as the user-visible new-terminal-tab action. -Why `tab.create` first: -- It proves a UI/layout action can be targeted and executed against live app state. -- It exercises process discovery, local authentication, request bridging, selector defaults, app-context dispatch, and structured success/error output without introducing higher-risk terminal input execution. -- It exercises the protected enablement and scoped-grant model before higher-risk action families depend on it. -- It gives operators a concise end-to-end smoke test: discover a running instance, create a tab, and confirm the live app changed. -The PR should also introduce the shell-facing CLI command grammar that the remainder of the protocol will reuse and establish a lightweight CLI startup path distinct from GUI startup. -### 10. Follow-up slices: fill out the remaining protocol in parallel -After the first slice validates discovery, auth, selector resolution, CLI syntax, and server-to-app execution, follow-up slices can add the remaining allowlisted catalog in parallelized action-family groups. The baseline code should make new action additions mostly additive: -- Extend `ControlAction`. -- Update the relevant `WarpCtrlBehavior` mappings for the internal app actions that implement, overlap with, exclude, or defer the behavior. -- Add typed params/results. -- Add a handler. -- Add validation/tests. -- Add CLI surface/tests. -### 11. CLI parsing and output libraries -The `warpctrl` CLI must use the same argument parsing and output libraries as the existing Oz CLI so that conventions, derive patterns, and shell-completion generation remain consistent across both binaries. -- **clap** (with the `derive` feature) for argument parsing, subcommand trees, and help generation. Both binaries share the `warp_cli` crate, so parser types defined there are reused directly. -- **serde** / **serde_json** for JSON request/response serialization and for `--output-format json` output. -- **clap_complete** for shell completion generation, reusing the same infrastructure the Oz CLI uses. -- The `OutputFormat` enum (`Pretty`, `Json`, `Ndjson`, `Text`) is shared from `warp_cli::agent::OutputFormat` so human-readable vs. machine-readable output follows the same conventions. -- New subcommand types for `warpctrl` live in `warp_cli::local_control` and follow the same `#[derive(Parser)]` / `#[derive(Subcommand)]` / `#[derive(Args)]` patterns used by the Oz CLI's top-level `Args` and `CliCommand` types. -Do not introduce alternative parsing libraries (e.g., `structopt`, `argh`) or alternative serialization approaches. Keeping one set of libraries across both CLIs reduces dependency weight, ensures consistent `--help` formatting, and lets contributors move between the two surfaces without learning a different stack. -### 12. CLI packaging and release shape -The shipped product shape should be a separate bundled `warpctrl` CLI binary that reuses shared CLI/protocol crates but does not depend on launching the GUI binary in command mode. Follow the Oz CLI release model as closely as practical: -- macOS: - - Add a standalone control CLI artifact path next to the existing Oz standalone CLI artifact flow. - - If the app bundle also exposes a wrapper/install flow, keep channelized naming consistent with the final product name decision. -- Linux: - - Extend bundle/release scripts to emit control CLI standalone artifacts and packages in the same broad pattern as the current Oz CLI tarball/deb/rpm/Arch package flow. -- Windows: - - Mirror the existing installer-generated helper-wrapper pattern first if that remains the canonical Oz behavior on Windows. - - If the product decision is to ship a true standalone Windows control CLI binary, add a dedicated release path in follow-up work rather than silently diverging from existing Oz precedent. -Startup and dependency expectations: -- The CLI process should initialize only command parsing, discovery, authentication material loading, protocol serialization, HTTP transport, and output formatting needed for the requested command. -- The CLI should not initialize GUI state, rendering, terminal session models, app workspaces, or other main-app-only subsystems. -- Startup cost should be treated as part of the product contract because control commands are expected to compose naturally in scripts and repeated interactive shell usage. -Naming decision: -- Product examples use provisional `warpctrl ...` command lines for the standalone local-control binary. -- Final artifact filenames, channelized aliases, and installer exposure should be chosen before broad rollout to avoid churn in bundle scripts, docs, shell completions, and release workflow files. -## Implementation Plan -### Branch stack -Use raw git for the stack; do not use Graphite for these branches. -The durable review stack should optimize for reviewability rather than mirroring only broad product phases. The bottom review branch now combines specs and the shared foundation so reviewers can see the product/security contract next to the protocol, settings, bridge, and CLI scaffolding that enforce it. The intended stack is: -1. `zach/warp-cli-core-foundation` — create this branch from `master`. It owns the specs in `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs, plus the shared implementation foundation: protocol crate shape, discovery/auth scaffolding, selector and error types, action metadata/catalog structure, Scripting settings surface, protected local-control settings, local-control server/bridge skeleton, standalone `warpctrl` binary skeleton, packaging hooks, module split, `WarpCtrlBehavior` scaffolding, and the minimum `tab.create` smoke path if needed to prove the end-to-end architecture. -2. `zach/warp-cli-readonly-metadata` — create this branch from `zach/warp-cli-core-foundation`. It implements structural metadata reads: instance/app health and active-chain commands, windows, tabs, panes, sessions, capability/action metadata, opaque IDs, metadata target shapes, and metadata-read permission enforcement. It must not expose terminal output, input buffers, history, Drive object contents, or other underlying user data. -3. `zach/warp-cli-readonly-data-settings` — create this branch from `zach/warp-cli-readonly-metadata`. It implements underlying-data reads and read-only settings/appearance/docs: block listing/inspection/output, input-buffer reads, history reads, theme/settings/keybinding/action reads, project/file app-state reads, authenticated Drive metadata/content reads where present, read-only docs, and the read-only `warpctrl` Agent skill. -4. `zach/warp-cli-authenticated-scripting` — create this branch from `zach/warp-cli-readonly-data-settings`. It implements authenticated-user grant plumbing, the verified Warp-terminal proof broker, external API-key scripting identity, auth command surface, Settings > Scripting controls for authenticated grants, and tests proving high-risk actions cannot run without authenticated grants. It should not implement broad new action families by itself. -5. `zach/warp-cli-mutating-layout` — create this branch from `zach/warp-cli-authenticated-scripting`. It implements layout and app-state mutations for app/window/tab/pane behavior: window create/focus/close, tab create/activate/move/close, tab metadata that belongs with layout review if appropriate, pane split/focus/navigate/resize/maximize/close, app-state mutation permission checks, and layout mutation tests. -6. `zach/warp-cli-mutating-input-settings-surfaces` — create this branch from `zach/warp-cli-mutating-layout`. It implements session activation/cycling/reopen, input insert/replace/clear, input mode switching, theme/system-theme/font/zoom changes, allowlisted setting set/toggle, settings/palette/search/panel/surface commands, file/project/Drive open commands that are app-state-only, mutating docs, and the mutating-command Agent skill updates. It must preserve the prohibition on accepted-command submission and agent-prompt submission. -7. `zach/warp-cli-mutating-drive-data` — create this branch from `zach/warp-cli-mutating-input-settings-surfaces`. It implements authenticated underlying-data mutations for Warp Drive objects: typed Drive object create/update/delete/insert, the v0 personal-to-team sharing path, permission enforcement, authenticated-user/API-key enforcement, and tests using disposable resources. It must not implement local file content reads, writes, appends, deletes, or other filesystem-content mutations. -8. `zach/warp-cli-mutating-execution-underlying` — create this branch from `zach/warp-cli-mutating-drive-data`. It implements authenticated execution-underlying actions such as `input.run` and typed workflow execution where supported. It must require underlying-data-mutation permission plus authenticated scripting identity, audit records, explicit target resolution, and tests proving accepted-command submission and agent-prompt submission remain unavailable. -The previous `zach/warp-cli-specs` branch is retained only as migration-source/history material. It is no longer a separate review PR or an authoritative branch in the active stack. -The goal is to keep durable review branches close to roughly 2,000 lines of incremental changes where practical while avoiding a one-branch-per-command maintenance burden. Product phases still matter, but they are not the primary PR boundary. The durable branches are the review spine; short-lived shard branches can feed into them during implementation. -Spec changes are an important part of the stacking strategy. All new spec changes must originate on `zach/warp-cli-core-foundation`, which is the authoritative source for `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs. After a spec change lands there, propagate it upward through every stacked branch with raw git so the spec files stay synchronized across `zach/warp-cli-readonly-metadata`, `zach/warp-cli-readonly-data-settings`, `zach/warp-cli-authenticated-scripting`, `zach/warp-cli-mutating-layout`, `zach/warp-cli-mutating-input-settings-surfaces`, `zach/warp-cli-mutating-drive-data`, and `zach/warp-cli-mutating-execution-underlying`. Do not make independent spec edits directly on higher implementation branches except as part of resolving a propagation conflict in a way that preserves the authoritative bottom-branch content. -Recommended raw-git setup after `zach/warp-cli-core-foundation` is ready: -```bash -git fetch origin -git checkout -b zach/warp-cli-core-foundation origin/master -git checkout -b zach/warp-cli-readonly-metadata -git checkout -b zach/warp-cli-readonly-data-settings -git checkout -b zach/warp-cli-authenticated-scripting -git checkout -b zach/warp-cli-mutating-layout -git checkout -b zach/warp-cli-mutating-input-settings-surfaces -git checkout -b zach/warp-cli-mutating-drive-data -git checkout -b zach/warp-cli-mutating-execution-underlying -``` -If a lower branch changes after higher branches exist, rebase each higher branch onto its immediate lower branch with raw git and resolve conflicts by preserving both the lower branch's stable API/permission model and the higher branch's owned behavior. -### Migrating from the earlier four-branch stack -The earlier four-branch stack (`zach/warp-cli-specs`, `zach/warp-cli`, `zach/warp-cli-readonly`, `zach/warp-cli-read-write`) should be treated as source material for the expanded eight-PR review stack, not as the final review structure. -Recommended migration: -1. Create backup refs before rewriting or replacing anything: - - `backup/warp-cli-specs` from `zach/warp-cli-specs`. - - `backup/warp-cli` from `zach/warp-cli`. - - `backup/warp-cli-readonly` from `zach/warp-cli-readonly`. - - `backup/warp-cli-read-write` from `zach/warp-cli-read-write`. -2. Create `zach/warp-cli-core-foundation` from latest `origin/master` and bring over both the specs from `zach/warp-cli-specs` and only the foundation pieces from `zach/warp-cli`. Prefer path-level checkout followed by selective editing or `git add -p`; do not preserve every old commit if that makes review boundaries worse. -3. Create `zach/warp-cli-readonly-metadata` from `zach/warp-cli-core-foundation` and bring over only metadata-read pieces from `zach/warp-cli-readonly`. -4. Create `zach/warp-cli-readonly-data-settings` from `zach/warp-cli-readonly-metadata` and bring over the remaining read-only underlying-data, settings, docs, and skill pieces from `zach/warp-cli-readonly`. -5. Create `zach/warp-cli-authenticated-scripting` from `zach/warp-cli-readonly-data-settings` and bring over or implement the verified terminal proof broker, external API-key scripting identity, authenticated-user grant plumbing, auth command surface, and related Settings > Scripting controls. -6. Create `zach/warp-cli-mutating-layout` from `zach/warp-cli-authenticated-scripting` and bring over only layout/app-state mutations from `zach/warp-cli-read-write` and its layout shards. -7. Create `zach/warp-cli-mutating-input-settings-surfaces` from `zach/warp-cli-mutating-layout` and bring over mutating input/session/settings/surface pieces from `zach/warp-cli-read-write`. -8. Create `zach/warp-cli-mutating-drive-data` from `zach/warp-cli-mutating-input-settings-surfaces` and bring over the approved `zach/warp-cli-read-write-drive-data` functionality. Do not bring over local file content read/write/delete functionality because it is no longer part of the public catalog. -9. Create `zach/warp-cli-mutating-execution-underlying` from `zach/warp-cli-mutating-drive-data` and bring over `zach/warp-cli-read-write-execution-underlying` functionality while keeping accepted-command and agent-prompt submission excluded. -10. Recompute incremental diff sizes, validate compilation/tests for each branch, push the new stack, and retire or close the old broad branches once the new review stack is accepted. -Before redistributing feature work, prefer landing a mechanical module-split commit in `zach/warp-cli-core-foundation` so later branches do not all expand the same large files. The app-side target should be: -- `app/src/local_control/mod.rs` for registration and top-level exports. -- `app/src/local_control/bridge.rs` for the app request bridge. -- `app/src/local_control/resolver.rs` for target resolution. -- `app/src/local_control/permissions.rs` for app-side permission/auth checks. -- `app/src/local_control/handlers/metadata.rs`. -- `app/src/local_control/handlers/data.rs`. -- `app/src/local_control/handlers/layout.rs`. -- `app/src/local_control/handlers/input.rs`. -- `app/src/local_control/handlers/settings_surfaces.rs`. -Likewise, split CLI and protocol code if they become review bottlenecks: -- `crates/warp_cli/src/local_control/mod.rs`. -- `crates/warp_cli/src/local_control/selectors.rs`. -- `crates/warp_cli/src/local_control/output.rs`. -- `crates/warp_cli/src/local_control/commands/{metadata,data,layout,input,settings_surfaces}.rs`. -- `crates/local_control/src/{protocol,catalog,selectors}.rs`. -- `crates/local_control/src/actions/{metadata,data,layout,input,settings_surfaces}.rs`. -### Feature flag and rollout gate -The entire feature should be gated behind a Warp feature flag, proposed as `FeatureFlag::WarpControlCli` with Cargo feature `warp_control_cli`. -Implementation should follow the existing runtime feature-flag conventions: -- Add `warp_control_cli = []` under `[features]` in `app/Cargo.toml`, not under the default feature set until launch. -- Add `WarpControlCli` to the `FeatureFlag` enum in `crates/warp_features/src/lib.rs`. -- Add the `#[cfg(feature = "warp_control_cli")] FeatureFlag::WarpControlCli` entry in `app/src/features.rs` so the compile-time feature initializes the runtime flag. -- Enable the flag for dogfood or preview by adding it to `DOGFOOD_FLAGS` or `PREVIEW_FLAGS` only when the rollout plan calls for that exposure. -- Prefer runtime checks with `FeatureFlag::WarpControlCli.is_enabled()` over broad `#[cfg]` gates except where code cannot compile without the Cargo feature. -When `FeatureFlag::WarpControlCli` is disabled in the Warp app: -- the Scripting settings page should not expose Warp control settings; -- `LocalControlSettings` should not register user-visible controls for Warp control; -- the app should not create `LocalControlBridge` or `LocalControlServer`; -- no local-control discovery record should be written; -- no `/v1/control` or `/v1/control/credentials` local server endpoints should be exposed; -- command-palette/keybinding entries related specifically to installing, configuring, or using `warpctrl` should be hidden; -- tests should cover both enabled and disabled flag states with the repo's normal feature-flag override helpers. -The standalone `warpctrl` binary can still exist in a build where the app feature is disabled, but it should find no compatible enabled app instance and should return a structured no-instance or feature-disabled error rather than relying on hidden server state. -### Merge and review strategy -Keep PR boundaries aligned with the stack: -- PR1: `zach/warp-cli-core-foundation` into `master` for the combined specs, shared protocol, CLI, settings, bridge, and module scaffolding. -- PR2: `zach/warp-cli-readonly-metadata` into `zach/warp-cli-core-foundation` or its merged successor for metadata reads. -- PR3: `zach/warp-cli-readonly-data-settings` into `zach/warp-cli-readonly-metadata` or its merged successor for underlying-data reads, settings reads, docs, and skill updates. -- PR4: `zach/warp-cli-authenticated-scripting` into `zach/warp-cli-readonly-data-settings` or its merged successor for verified terminal proofs, external API-key scripting auth, and authenticated-user grants. -- PR5: `zach/warp-cli-mutating-layout` into `zach/warp-cli-authenticated-scripting` or its merged successor for app/window/tab/pane layout mutations. -- PR6: `zach/warp-cli-mutating-input-settings-surfaces` into `zach/warp-cli-mutating-layout` or its merged successor for input/session/settings/surface mutations. -- PR7: `zach/warp-cli-mutating-drive-data` into `zach/warp-cli-mutating-input-settings-surfaces` or its merged successor for authenticated Warp Drive underlying-data mutations. -- PR8: `zach/warp-cli-mutating-execution-underlying` into `zach/warp-cli-mutating-drive-data` or its merged successor for authenticated execution-underlying actions. -If a lower PR merges before higher branches are ready, rebase the next branch onto the merged successor of its base and continue upward through the stack. Use raw git for all rebases, conflict resolution, cherry-picks, and pushes. -## End-to-end flow -```mermaid -sequenceDiagram - participant CLI as Warp control CLI - participant REG as Local discovery registry - participant PROC as Selected Warp process - participant BROKER as Credential broker - participant HTTP as Local control listener - participant BRIDGE as App request bridge - participant RES as Target resolver - participant ACT as Allowlisted action handler - participant UI as Live Warp app state - - CLI->>REG: Read local instance records - CLI->>PROC: Health/protocol check for candidates - PROC-->>CLI: Instance metadata + compatibility - CLI->>CLI: Resolve instance selector - CLI->>BROKER: Request scoped credential for action + execution context - BROKER-->>CLI: Grant or structured denial - CLI->>HTTP: Authenticated POST tab.create request - HTTP->>HTTP: Verify context-specific enablement + credential + execution context - HTTP->>BRIDGE: Typed request + response channel - BRIDGE->>BRIDGE: Recheck enablement + permission + auth-user policy - BRIDGE->>RES: Resolve window/tab/pane/session selectors - RES-->>BRIDGE: Concrete target handles or typed error - BRIDGE->>ACT: Execute allowlisted ControlAction - ACT->>UI: Reuse existing tab creation behavior - UI-->>ACT: Mutation/read result - ACT-->>BRIDGE: Typed result - BRIDGE-->>HTTP: Response envelope - HTTP-->>CLI: JSON success/error response - CLI-->>CLI: Pretty or JSON output -``` -## Testing and validation -Map tests directly to `PRODUCT.md` behavior. -- Security architecture: - - Protected enablement tests proving outside-Warp control defaults off, disabled outside-Warp context rejects credential issuance, sensitive discovery, and mutating requests with `local_control_disabled`, and current inside-Warp credential requests are rejected with `execution_context_not_allowed`. - - Tests proving discovery in disabled state exposes no actionable endpoint authority or credential reference. - - Credential-storage tests proving raw credentials are not written into plaintext discovery records. - - Execution-context tests proving external clients cannot receive grants reserved for verified Warp-terminal invocations. - - Permission-category enforcement tests proving insufficient grants fail with `insufficient_permissions` before selector resolution or handler dispatch, including separate denial cases for app-state mutation, metadata/configuration mutation, and underlying-data mutation. - - Authenticated-user tests proving user-authenticated actions fail without a logged-in app user or authenticated-user grant. - - External API-key tests proving missing, invalid, expired, revoked, wrong-subject, and insufficient-scope keys fail before selector resolution or handler dispatch. - - Settings > Scripting tests proving both top-level toggles and granular disabled categories invalidate credentials and prevent new grants. - - Structured-error tests for disabled, unauthenticated, expired, revoked, insufficient-scope, execution-context-denied, authenticated-user-required, authenticated-user-unavailable, unsupported, malformed, ambiguous, missing-target, stale-target, and invalid-selector requests. -- Behavior 1-6, 29-31: - - Protocol version/unit tests. - - Discovery-registry tests with zero, one, multiple, stale, and incompatible instance records. - - Local-auth tests for missing, invalid, expired, revoked, and valid credentials. -- Behavior 7-13: - - Selector-resolution unit tests for active, explicit ID, index, stale target, ambiguous target, and non-terminal session target. - - Tests that no lower-level selector silently retargets after an explicit stale selector fails. - - CLI selector parsing tests for every generic and explicit alias form: `--window`, `--window-id`, `--window-index`, `--window-title`, `--tab`, `--tab-id`, `--tab-index`, `--tab-title`, `--pane`, `--pane-id`, `--pane-index`, `--session`, `--session-id`, and `--session-index`. - - CLI conflict tests proving only one selector form per entity family is accepted and that positional target IDs are rejected where the command expects selector flags. -- Behavior 15-28: - - Parser/serde tests for every first-slice `ControlAction` variant. - - Router tests proving unknown/unallowlisted actions are rejected. - - CLI parse/output tests for pretty and JSON rendering. -- Behavior 18 and 33: - - App-side tests for `tab.create` using existing workspace/tab helpers or a narrow extracted helper. - - Manual local verification that `warpctrl tab create` creates a terminal tab in a running app. -- Behavior 30: - - Multi-process integration-style coverage using two synthetic discovery records and mock health responders, plus manual testing with multiple channel builds where practical. -- Packaging: - - `--artifact cli`-style bundle smoke tests or script-level checks for each supported platform path touched by the first slice. - - Startup-path tests or focused checks confirming `warpctrl` dispatches commands without entering GUI-app launch code. - - Shell completions/help output checks once final command naming is selected. -### Computer-use CLI verification -Before any stacked PR is considered ready for review, run an end-to-end computer-use verification pass against a cloud built validation artifact from that branch. The validation artifact is a Warp app build and standalone `warpctrl` binary built by the validation agent from the exact commit under review, with `warp_control_cli` compiled in and `FeatureFlag::WarpControlCli` enabled at runtime. -The verifier must launch the built Warp app, enable the Scripting settings needed for the command category under test, and capture screenshots or screen recordings that prove both the terminal invocation and the user-visible result of each basic command family. Every `warpctrl` invocation executed during verification must have a durable screenshot captured immediately after the command runs, showing the exact command line and full terminal output in either pretty or JSON mode. Screenshots should be saved as durable artifacts and named by branch, invocation context, command family, command name, and whether the image shows terminal output or app UI state. -The verifier must exercise every invocation context implemented by the branch under review. For the current foundation branch, inside-Warp verification means proving `InsideWarp` requests are rejected and no inside-Warp Settings > Scripting controls are exposed. Once verified Warp-terminal support is implemented, the verifier must also run `warpctrl` from a terminal session inside the feature-flag-enabled Warp app and prove the app-issued execution-context proof is accepted and inside-Warp settings gate command categories. The outside-Warp path must run the same `warpctrl` binary from an external terminal or automation shell outside the built Warp app and prove outside-Warp control is default-off, then works only after enabling outside-Warp scripting and the required granular permissions in Settings > Scripting. -The verification matrix should cover every implemented command in `PRODUCT.md` at least once in JSON output mode. The verifier must capture a terminal-output screenshot for every command invocation, including successful calls, expected denials, unsupported stubs, and fail-closed policy gates. Where there is a visible UI effect, the verifier must also capture app UI screenshots after the command runs, and before the command when that is needed to prove a before/after state transition. At minimum: -- read-only metadata commands show successful CLI output in a terminal screenshot and, for active/focus/list commands, a visible target that matches the output; -- underlying data read commands show successful CLI output only when the underlying-data-read permission is enabled, plus terminal screenshots for disabled-permission denials; -- app-state mutation commands show terminal-output screenshots plus before/after app UI screenshots proving the visible Warp UI changed; -- metadata/configuration mutation commands show terminal-output screenshots plus before/after app UI screenshots proving the persisted setting or label changed; -- underlying data mutation commands run only in a disposable test workspace/session with test Warp Drive objects, show terminal screenshots for denial without the underlying-data-mutation permission, then show terminal screenshots and any relevant app/file/Drive state evidence for success with the permission enabled; -- authenticated-user commands show terminal screenshots for both the logged-out or missing-grant denial path and the enabled authenticated path when a test account/environment is available. -The verifier should produce a verification manifest checked into or attached to the PR artifacts. The manifest should list each command, branch under test, invocation context, required permission category, expected result, actual result, terminal screenshot artifact path, UI screenshot artifact path when applicable, and any skipped case with a reason. Missing terminal screenshots for any executed `warpctrl` invocation block review readiness. Missing UI screenshots for visible commands also block review readiness. -## Parallelization -The durable review stack should remain linear, but implementation can still happen in parallel with Oz cloud agents. The pattern is contract-first fan-out: land the shared contracts and module boundaries in `zach/warp-cli-core-foundation`, then let cloud agents work on short-lived shard branches that feed the durable review branches. -Wave 0: foundation: -- Keep `zach/warp-cli-core-foundation` mostly sequential or use at most one or two tightly scoped agents because protocol envelope, discovery, authentication, feature-flag gating, selector resolution, module boundaries, and `tab.create` smoke behavior need one coherent architecture. -- Acceptable foundation shards are `core-protocol-cli` for shared protocol/CLI skeleton and `core-app-foundation` for settings, bridge, resolver, permissions, and handler skeletons. These shards should merge into the single durable `zach/warp-cli-core-foundation` branch before feature fan-out begins. -Wave 1: read-only fan-out: -- Launch short-lived Oz cloud shard branches from `zach/warp-cli-core-foundation` once the contracts compile. -- Suggested shards: - - `zach/warp-cli-shard/readonly-metadata` owns structural metadata commands and feeds `zach/warp-cli-readonly-metadata`. - - `zach/warp-cli-shard/readonly-data` owns block output, input-buffer reads, history reads, and other underlying-data reads, then feeds `zach/warp-cli-readonly-data-settings`. - - `zach/warp-cli-shard/readonly-settings-docs` owns theme/settings/keybinding/action reads, docs, and read-only skill updates, then feeds `zach/warp-cli-readonly-data-settings`. -Wave 2: mutating fan-out: -- Launch mutating shards only after read-only target resolution and result shapes are stable. -- Suggested shards: - - `zach/warp-cli-shard/mutating-window-tab-pane` owns window/tab/pane layout mutations and feeds `zach/warp-cli-mutating-layout`. - - `zach/warp-cli-shard/mutating-input-session` owns session activation/cycling/reopen, input insert/replace/clear, and input mode switching, then feeds `zach/warp-cli-mutating-input-settings-surfaces`. - - `zach/warp-cli-shard/authenticated-scripting` owns verified terminal proofs, external API-key auth, authenticated-user grants, and auth command tests, then feeds `zach/warp-cli-authenticated-scripting`. - - `zach/warp-cli-shard/mutating-settings-surfaces` owns theme/font/zoom/setting mutations and settings/palette/panel/surface commands, then feeds `zach/warp-cli-mutating-input-settings-surfaces`. - - `zach/warp-cli-shard/mutating-drive-data` owns Drive object data mutations, including the v0 personal-to-team sharing path, then feeds `zach/warp-cli-mutating-drive-data`. - - `zach/warp-cli-shard/mutating-execution-underlying` owns `input.run` and typed workflow execution, then feeds `zach/warp-cli-mutating-execution-underlying`. -Each cloud shard prompt should include: -- The exact base branch and shard branch name. -- Owned command families. -- Owned files/modules. -- Files/modules the shard must not edit without calling out the need for integration. -- Required permission categories and authenticated-user behavior. -- Selector resolution requirements. -- Validation commands and expected tests. -- A handoff requirement: report branch name, changed files, implemented commands, permission decisions, validation results, and any conflicts or follow-ups. -Default file ownership for shards: -- Metadata shards own metadata handler/protocol/CLI modules and metadata tests. -- Data shards own data handler/protocol/CLI modules and underlying-data permission tests. -- Layout shards own layout handler/protocol/CLI modules and app-state mutation tests. -- Authenticated-scripting shards own auth broker/protocol/CLI modules, Settings > Scripting authenticated grant controls, API-key storage/exchange tests, and authenticated-user denial tests. -- Input/session shards own input/session handler/protocol/CLI modules and tests proving staging does not submit or execute unless the branch explicitly owns `input.run`. -- Settings/surface shards own settings/surface handler/protocol/CLI modules and metadata/configuration mutation tests. -- Drive data shards own Drive underlying-data handler/protocol/CLI modules, authenticated-user/API-key enforcement tests, personal-to-team sharing tests, and disposable-resource tests. -- Execution-underlying shards own `input.run` and typed workflow execution handler/protocol/CLI modules, audit tests, and denial tests proving accepted-command and agent-prompt submission remain unavailable. -The lead integrator merges or cherry-picks accepted shard work into the durable stack with raw git, in review order. Shard branches should not become independent long-lived PRs unless the lead intentionally splits review further; their default purpose is to feed the durable stack while preserving parallel implementation and focused context windows. -```mermaid -flowchart LR - Core["zach/warp-cli-core-foundation<br/>specs + contracts + bridge"] --> ROMeta["zach/warp-cli-readonly-metadata<br/>structural reads"] - ROMeta --> ROData["zach/warp-cli-readonly-data-settings<br/>data/settings reads"] - ROData --> Auth["zach/warp-cli-authenticated-scripting<br/>terminal proof + API key auth"] - Auth --> MutLayout["zach/warp-cli-mutating-layout<br/>layout mutations"] - MutLayout --> MutInput["zach/warp-cli-mutating-input-settings-surfaces<br/>input/settings/surfaces"] - MutInput --> MutData["zach/warp-cli-mutating-drive-data<br/>Drive data"] - MutData --> MutExec["zach/warp-cli-mutating-execution-underlying<br/>execution actions"] - ROMetaShard["shard/readonly-metadata"] --> ROMeta - RODataShard["shard/readonly-data"] --> ROData - ROSettingsShard["shard/readonly-settings-docs"] --> ROData - MutLayoutShard["shard/mutating-window-tab-pane"] --> MutLayout - MutInputShard["shard/mutating-input-session"] --> MutInput - MutSettingsShard["shard/mutating-settings-surfaces"] --> MutInput - AuthShard["shard/authenticated-scripting"] --> Auth - MutDataShard["shard/mutating-drive-data"] --> MutData - MutExecShard["shard/mutating-execution-underlying"] --> MutExec -``` -## Risks and mitigations -- Fixed-port server assumptions: - - Mitigation: leave current `9277` endpoints undisturbed and use a per-process control listener plus discovery registry. -- Browser-to-localhost abuse: - - Mitigation: no permissive CORS, protected in-app enablement, explicit local auth, scoped grants, and mutating routes gated before selector resolution. -- External apps silently enabling outside-Warp local control: - - Mitigation: the outside-Warp enablement state defaults off, lives in protected local storage behind Settings > Scripting, is local-only, is not Settings Sync'd, and is not writable through `warpctrl`, config files, registry/plist preference edits, defaults writes, or server-backed settings. -- External apps obtaining in-Warp authenticated-user grants: - - Mitigation: require an app-issued execution-context proof for Warp-terminal-only grants, do not trust caller-declared labels or plain environment variables as sole authority, and keep external authenticated-user grants behind a separate default-off permission. -- Logged-out requests touching user-authenticated data: - - Mitigation: every action declares `requires_authenticated_user`, new actions default to true, and the bridge returns authenticated-user errors before selector resolution or dispatch. -- Implementation drift from `SECURITY.md`: - - Mitigation: treat `SECURITY.md` as normative for security behavior; update this technical plan before implementation when there is disagreement, and include tests for the security architecture in the first slice. -- Action catalog drift from real UI behavior: - - Mitigation: each control action reuses or factors existing UI action paths rather than duplicating behavior, and user-visible app action enums implement exhaustive `WarpCtrlBehavior` mappings so new internal actions cannot be added without an explicit expose/cover/exclude/defer decision. -- Leaking internal unstable identifiers: - - Mitigation: public protocol exposes opaque IDs and selectors; internal runtime IDs stay implementation details. -- Over-broad settings mutation: - - Mitigation: allowlisted setting keys only, with private/debug/derived settings rejected. -- Command execution risk: - - Mitigation: keep `input.run`/typed workflow execution in the catalog but implement it only in `zach/warp-cli-mutating-execution-underlying` after authenticated scripting identity, underlying-data-mutation permission, deterministic target resolution, and audit coverage are in place. -- Packaging churn due to provisional executable naming: - - Mitigation: document `warpctrl` as provisional and settle final aliases before broad release workflow rollout. -- Heavyweight CLI startup caused by sharing the GUI binary's launch path: - - Mitigation: ship a separate control CLI artifact with a narrow initialization path and keep GUI-only subsystems out of ordinary CLI command execution. -## Follow-ups -- Decide the final artifact filename/channel alias scheme around the provisional `warpctrl ...` public command surface. -- Decide whether Windows should follow the current Oz wrapper pattern indefinitely or gain standalone control CLI artifacts. -- Decide whether a future subscription/watch protocol is useful for scripts that want live state changes, rather than single request/response calls only. +PRODUCT.md defines the public `warpctrl` product contract. SECURITY.md is the normative security policy. This document describes implementation mechanics for the shared protocol, catalog, app bridge, CLI, and validation flow. +# Current implementation baseline +The repository already has these local-control building blocks: +- `crates/local_control/src/catalog.rs` owns public action metadata. +- `crates/local_control/src/protocol.rs` owns wire envelopes, typed parameter/result payloads, and structured errors. +- `crates/local_control/src/selectors.rs` owns current window/tab/pane selector shapes. +- `crates/local_control/src/auth.rs` owns scoped credential request and grant types. +- `crates/local_control/src/discovery.rs` owns per-instance discovery records. +- `app/src/local_control/mod.rs` owns the app-side bridge/server skeleton. +- `crates/warp_cli/src/bin/warpctrl.rs` and `app/src/bin/warpctrl.rs` own standalone CLI entry points. +# Contract rules +- `ActionKind` serialized names are the canonical public protocol names. +- `ActionKind::ALL` contains only approved public actions. +- Excluded local filesystem mutation names and standalone secret-auth names may be listed as excluded constants, but they must not deserialize into `ActionKind`. +- `ActionMetadata` must include implementation status, state/data category, permission category, authenticated-user requirement, allowed invocation contexts, target scope, parameter spec, and result spec. +- Implemented foundation actions can advertise `OutsideWarp` only when logged-out-safe. +- Authenticated actions advertise `InsideWarp` only and require a verified Warp-managed terminal grant before execution. +# Canonical action name changes +Use these public names when porting handlers and tests: +- `app.inspect` -> `instance.inspect` +- `app.settings.open` -> `surface.settings.open` +- `app.command_palette.open` -> `surface.command_palette.open` +- `app.command_search.open` -> `surface.command_search.open` +- `app.warp_drive.open` -> `surface.warp_drive.open` +- `app.warp_drive.toggle` -> `surface.warp_drive.toggle` +- `app.resource_center.toggle` -> `surface.resource_center.toggle` +- `app.ai_assistant.toggle` -> `surface.ai_assistant.toggle` +- `app.code_review.toggle` -> `surface.code_review.toggle` +- `app.vertical_tabs.toggle` -> `surface.vertical_tabs.toggle` +- `pane.session.previous` -> `session.previous` +- `pane.session.next` -> `session.next` +- `appearance.font_size` -> `appearance.font_size.increase`, `appearance.font_size.decrease`, or `appearance.font_size.reset` +- `appearance.zoom` -> `appearance.zoom.increase`, `appearance.zoom.decrease`, or `appearance.zoom.reset` +- `appearance.set` -> `theme.set`, `theme.system.set`, `theme.light.set`, or `theme.dark.set` +Add these metadata/read names instead of using app-specific aliases: +- `capability.list` +- `capability.inspect` +- `action.list` +- `action.inspect` +- `block.inspect` +- `block.output` +- `drive.inspect` +- `drive.object.create` +- `drive.object.update` +- `drive.object.delete` +- `drive.object.insert` +- `drive.object.share_to_team` +- `drive.workflow.run` +# Shared protocol mechanics +The request envelope contains protocol version, request ID, target selector, action kind, and action parameters. The response envelope contains protocol version, request ID, and either success data or `ControlError`. +Selectors remain extensible. Current compiled selectors cover window, tab, and pane. Protocol payloads add Drive object IDs/types so Drive shards can share canonical parameter and result contracts without changing action names. +# App bridge mechanics +The local-control HTTP or socket handler runs off the UI thread. It must authenticate and deserialize requests, then schedule app-state work onto the main app context using the existing model-spawning bridge. The bridge must revalidate credentials, action metadata, invocation context, authenticated-user requirement, and target scope before resolving selectors or dispatching handlers. +Handler implementation order: +1. Decode request envelope. +2. Verify protocol version. +3. Authenticate credential. +4. Load `ActionMetadata` from the catalog. +5. Verify invocation context, permission category, authenticated-user requirement, and target/resource restrictions. +6. Validate action parameters. +7. Resolve selectors deterministically. +8. Dispatch only typed allowlisted handlers. +9. Return structured result or error. +# CLI mechanics +`warpctrl` should follow existing CLI conventions used by the repository's CLI tooling: +- clap-style noun subcommands; +- JSON and human-readable output modes; +- stable structured errors; +- generated or checked completions and reference docs from the catalog; +- no GUI initialization for ordinary CLI invocation. +CLI parser work must be derived from the catalog so names, help, completions, and docs do not drift from `ActionKind::ALL`. +# Security implementation notes +- Outside-Warp control defaults off. +- Inside-Warp credential requests are rejected until app-issued terminal proof verification is implemented. +- External clients cannot receive authenticated-user grants. +- Public settings read/write actions must not expose or mutate private local-control enablement settings. +- The bridge, not the CLI, is the enforcement point for action metadata and grants. +# Validation plan +Run the narrowest useful checks first: +- `git diff --check -- specs/warp-control-cli/PRODUCT.md specs/warp-control-cli/TECH.md specs/warp-control-cli/SECURITY.md crates/local_control/src/catalog.rs crates/local_control/src/protocol.rs crates/local_control/src/protocol_tests.rs` +- stale-language grep across `specs/warp-control-cli/*.md` for banned framing and auth-scope terms; +- `cargo check -p local_control` when the Rust toolchain is available; +- `cargo nextest run --no-fail-fast --workspace local_control::protocol_tests` when tests are available in the environment. +If a command is unavailable in a cloud shard, report it as skipped with the exact toolchain or environment blocker. +# Fan-out handoff +This shard establishes the dependency gate for other implementation shards. Other shards should port handlers and tests to the canonical names above, use `ActionMetadata` for permission enforcement, and avoid adding handlers for excluded surfaces. From 64985cf13ec9705cea3f4a531aafc2f26ec0f22f Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Tue, 26 May 2026 12:45:04 -0600 Subject: [PATCH 25/48] Document warpctrl spec ownership invariant Co-Authored-By: Oz <oz-agent@warp.dev> --- specs/warp-control-cli/TECH.md | 1 + 1 file changed, 1 insertion(+) diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index 91643497f3..07c1f3a1a7 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -85,3 +85,4 @@ Run the narrowest useful checks first: If a command is unavailable in a cloud shard, report it as skipped with the exact toolchain or environment blocker. # Fan-out handoff This shard establishes the dependency gate for other implementation shards. Other shards should port handlers and tests to the canonical names above, use `ActionMetadata` for permission enforcement, and avoid adding handlers for excluded surfaces. +Implementation branches must treat `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and `README.md` as contract-owned after this branch. If any spec correction is needed, land it on the contract/spec branch first, then propagate the resulting spec files forward unchanged. From 5dcf32c28edee4e28464cc99d2e27dc6f4d2e0c0 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Tue, 26 May 2026 13:13:57 -0600 Subject: [PATCH 26/48] Restore canonical warpctrl specs Co-Authored-By: Oz <oz-agent@warp.dev> --- specs/warp-control-cli/PRODUCT.md | 699 +++++++++++++++++++++-------- specs/warp-control-cli/README.md | 122 +++-- specs/warp-control-cli/SECURITY.md | 627 +++++++++++++++++++++----- specs/warp-control-cli/TECH.md | 697 ++++++++++++++++++++++++---- 4 files changed, 1720 insertions(+), 425 deletions(-) diff --git a/specs/warp-control-cli/PRODUCT.md b/specs/warp-control-cli/PRODUCT.md index 793fe0960f..1052c7c38b 100644 --- a/specs/warp-control-cli/PRODUCT.md +++ b/specs/warp-control-cli/PRODUCT.md @@ -1,185 +1,518 @@ # Summary -Warp ships `warpctrl` as the standalone local control CLI for operating already-running local Warp app instances. `warpctrl` is an allowlisted, typed control plane for agent and developer workflows that need to inspect or change Warp product surfaces such as windows, tabs, panes, terminal sessions, blocks, settings, appearance, command surfaces, files opened in Warp, projects, and Warp Drive objects. -The public contract is catalog-first: command names, selectors, permissions, result types, and errors are defined up front so implementation shards can add handlers without changing the security stance. -# Product stance -`warpctrl` is the decided binary name. -The initial target is the full allowlisted public catalog, with implementation status advertised per action by the running app. -Authenticated actions require a verified Warp-managed terminal invocation and a logged-in Warp user in the selected app. -External invocations are limited to logged-out-safe local-control actions. -There is no standalone secret-based authenticated external scripting path. -The transport is local same-machine control of running Warp app instances only. Network or hosted control transports are out of scope. -# Goals -- Provide a stable scriptable CLI for operating running Warp app instances without launching the GUI executable in CLI mode. -- Give agents a typed, permissioned way to preserve and organize visible Warp UI context instead of relying on brittle screen automation or arbitrary internal dispatch. -- Support deterministic targeting across instances, windows, tabs, panes, sessions, terminal blocks, visible file/path intents, projects, surfaces, and Warp Drive objects. -- Keep every action allowlisted, named by public product nouns, classified by state/data category, permission category, authenticated-user requirement, allowed invocation context, and implementation status. -- Preserve native-tools-first boundaries: agents should still use native file, shell, web, MCP, and conversation tools when those tools are the better surface. -# Non-goals and excluded surfaces -- Replacing the Oz CLI or mixing cloud-agent management into `warpctrl`. -- Exposing arbitrary internal action dispatch, raw view dispatch, debug helpers, crash/panic helpers, heap dumps, token-copying helpers, or broad developer-only commands. -- Mutating local filesystem data through `warpctrl`; file/path support is limited to visible Warp app intents such as opening a path and listing files already open in Warp. -- Submitting accepted commands, submitting agent prompts, or causing agent execution through this catalog. -- Arbitrary ACL editing, public sharing, guest sharing, or broad sharing policy mutation. The catalog may include the typed personal-to-team Drive sharing action only because it maps to a constrained Warp Drive product flow. -- Standalone secret-based authenticated external scripting. -- Network control endpoints or hosted control URLs. -# User stories -## Agent workspace orchestration -An agent can inspect visible Warp structure, choose or create an appropriate workspace layout, focus or name targets, open relevant surfaces, and leave Warp in a readable task-shaped state for the user. -## Existing-session debugging and repair -An agent can inspect which instance, window, tab, pane, session, block, and surface are active or targetable before applying focus, layout, panel, or settings changes. -## Warp Drive navigation and typed operations -An agent can list, inspect, open, create, update, insert, delete, share to the current team, or run approved Warp Drive objects only through typed catalog actions with authenticated Warp-terminal grants where required. -## Demos and walkthroughs -A script or agent can put Warp into a known presentation state: theme, zoom, window/tab/pane layout, focused targets, panels, command surfaces, and Warp Drive views. -## Personalization and onboarding -An agent can inspect user-approved preferences, propose Warp equivalents, and apply allowlisted settings, appearance, keybinding, layout, and surface changes with explicit permission categories. -# Targeting model -Selectors are deterministic and hierarchical where the UI hierarchy is hierarchical: -- Instance: active, opaque instance ID, or PID convenience filter. -- Window: active, opaque ID, scoped index, or exact title. -- Tab: active, opaque ID, scoped index, or exact title. -- Pane: active, opaque ID, or scoped index. -- Session: active, opaque ID, or scoped index. -- Block: active/current where unambiguous, opaque ID, or scoped index. -- File/path: path plus optional line and column for visible app intents. -- Project/workspace: path, opaque project/workspace ID, or exact name where exposed. -- Drive object: opaque ID, with type-scoped exact lookup for interactive use. -Active defaults are allowed only when unambiguous. Explicit IDs must resolve exactly or fail with `stale_target`. Missing active targets fail with `missing_target`. Ambiguous selectors fail with `ambiguous_target`. Commands must not silently retarget to a nearby instance, tab, pane, session, file, or Drive object. -# State/data categories and permissions -Every action belongs to exactly one category: -- Metadata reads: structure and non-content metadata such as instances, windows, tabs, panes, sessions, capabilities, actions, settings keys, themes, keybindings, project identity, and Drive object IDs/names/types. -- Underlying data reads: terminal block output, input buffer contents, history, Drive object contents, AI conversation content, or other user data. -- App-state mutations: visible UI state such as focus, layout, panels, surface opens, file/project opens, and input-buffer staging without execution. -- Metadata/configuration mutations: persistent metadata or configuration such as titles, tab colors, themes, zoom, font size, keybindings, and allowlisted settings. -- Underlying data mutations: terminal execution through explicit `input.run`, typed Warp Drive CRUD/insert/share-to-team/workflow-run operations, and other actions that can change user data or cause external side effects. -A command that touches multiple categories requires the strongest applicable permission. -# Public action catalog -## Instance, app, capability, and action metadata -- `instance.list` -- `instance.inspect` -- `app.ping` -- `app.version` -- `app.active` -- `app.focus` -- `capability.list` -- `capability.inspect` -- `action.list` -- `action.inspect` -## Auth status and app login routing -- `auth.status` -- `auth.login` -These actions report local/authenticated grant availability or open the selected app's normal sign-in UI. They do not create a standalone external secret identity. -## Windows, tabs, panes, and sessions -- `window.list` -- `window.inspect` -- `window.create` -- `window.focus` -- `window.close` -- `tab.list` -- `tab.inspect` -- `tab.create` -- `tab.activate` -- `tab.move` -- `tab.close` -- `tab.rename` -- `tab.reset_name` -- `tab.color.set` -- `tab.color.clear` -- `pane.list` -- `pane.inspect` -- `pane.split` -- `pane.focus` -- `pane.navigate` -- `pane.resize` -- `pane.maximize` -- `pane.unmaximize` -- `pane.close` -- `pane.rename` -- `pane.reset_name` -- `session.list` -- `session.inspect` -- `session.activate` -- `session.previous` -- `session.next` -- `session.reopen_closed` -## Blocks, input, and history -- `block.list` -- `block.inspect` -- `block.output` -- `input.get` -- `input.insert` -- `input.replace` -- `input.clear` -- `input.mode.set` -- `input.run` -- `history.list` -Input insert/replace/clear/mode commands stage visible input only. `input.run` is the only terminal execution action and requires authenticated Warp-terminal authority plus underlying-data-mutation permission. -## Appearance, settings, and keybindings -- `theme.list` -- `theme.get` -- `theme.set` -- `theme.system.set` -- `theme.light.set` -- `theme.dark.set` -- `appearance.get` -- `appearance.font_size.increase` -- `appearance.font_size.decrease` -- `appearance.font_size.reset` -- `appearance.zoom.increase` -- `appearance.zoom.decrease` -- `appearance.zoom.reset` -- `setting.list` -- `setting.get` -- `setting.set` -- `setting.toggle` -- `keybinding.list` -- `keybinding.get` -Settings mutations are protocol actions handled by the running app. `warpctrl` must not bypass the app by editing settings files directly. -## Surfaces -- `surface.settings.open` -- `surface.command_palette.open` -- `surface.command_search.open` -- `surface.warp_drive.open` -- `surface.warp_drive.toggle` -- `surface.resource_center.toggle` -- `surface.ai_assistant.toggle` -- `surface.code_review.toggle` -- `surface.left_panel.toggle` -- `surface.right_panel.toggle` -- `surface.vertical_tabs.toggle` -## Files and projects -- `file.list` -- `file.open` -- `project.active` -- `project.list` -- `project.open` -These actions address Warp-visible app/editor/project state only. -## Warp Drive -- `drive.list` -- `drive.inspect` -- `drive.open` -- `drive.notebook.open` -- `drive.env_var_collection.open` -- `drive.object.share.open` -- `drive.object.create` -- `drive.object.update` -- `drive.object.delete` -- `drive.object.insert` -- `drive.object.share_to_team` -- `drive.workflow.run` -Drive metadata listing requires authenticated user state. Drive content reads and mutations require authenticated Warp-terminal authority and the corresponding data-read or data-mutation permission. -# CLI shape -The CLI command hierarchy is noun-oriented and mirrors the action names: +Warp should ship an allowlisted standalone local control CLI binary, provisionally named `warpctrl`, that acts as an agent control plane for operating Warp itself. `warpctrl` lets agents and developers script the same classes of user-visible actions they can already perform inside the running app: manipulating windows, tabs, panes, sessions, terminal blocks, appearance, settings, Warp Drive views, and selected UI surfaces. The CLI should operate against one or more already-running local Warp app processes through a stable machine protocol, with deterministic target selection and clear errors when a process or target is ambiguous. +## Problem +Warp already has rich interactive actions, but they are primarily reachable through UI, keybindings, menus, or deeplinks. Agents can use native tools for files, code, shell commands, MCP calls, and many context reads, but they cannot reliably operate Warp's own product surfaces: arranging the user's workspace, focusing the correct pane, opening Warp Drive objects, presenting settings, or recovering from ambiguous UI state. Developers also cannot reliably compose those same actions into shell scripts, demos, automation, or agent workflows, and there is no general local protocol for addressing a specific running Warp instance, window, pane, session, terminal block, Warp Drive object, or other uniquely named Warp entity. +## Goals / Non-goals +Goals: +- Provide a first-class, scriptable standalone `warpctrl` binary for controlling running Warp app processes. +- Make Warp's own UI and app state available to agents through a typed, permissioned control plane instead of brittle screen automation or arbitrary internal dispatch. +- Keep CLI startup lightweight by avoiding GUI-app startup or full terminal initialization for routine control commands. +- Keep the surface allowlisted and finite instead of exposing arbitrary internal actions. +- Make targeting explicit and deterministic across multiple Warp processes, windows, tabs, panes, terminal sessions, terminal blocks, Warp Drive objects, files, projects/workspaces, command surfaces, and other uniquely addressable Warp nouns. +- Support both ergonomic active-target defaults and precise selectors for automation. +- Define a complete protocol/catalog up front, while shipping the implementation incrementally. +Non-goals: +- Replacing the Oz CLI or mixing cloud-agent management into this CLI. +- Exposing every internal app action, debug action, developer-only helper, or privileged state mutation. +- Treating the CLI as a general RPC escape hatch into Warp internals. +- Replacing native agent tools for code editing, file operations, shell execution, web/MCP calls, or attached conversation/block context when those tools already solve the task better. +- Requiring developers or automation to spawn the Warp GUI executable in CLI mode for ordinary control commands. +- Requiring the first implementation slice to ship every action in the catalog. +## Primary user stories +These stories define the most compelling product uses for `warpctrl`. The command catalog below is intentionally broader, but the product should prioritize surfaces that agents cannot already operate well through native tools. +1. **Agent workspace orchestration.** When a user asks an agent to work on a task, the agent can inspect the current Warp state, create or reuse an appropriate window/tab layout, split panes, name and focus targets, open relevant Warp surfaces, and leave the workspace in a readable task-shaped state for the user. The agent should continue to use native tools for code edits, file reads/writes, shell execution, MCP calls, and other work that does not require operating Warp's UI or local-control permission model. +2. **Existing-session debugging and repair.** When a user asks for help with an existing Warp session, the agent can understand Warp-specific UI and session structure before acting: which instance/window/tab/pane/session is active, whether the relevant pane still exists, whether the correct surface is focused, which panels or settings pages are open, and which selector should be used for follow-up actions. The story should focus on UI/session structure, focus, panels, settings, and deterministic targeting; native agent context tools should remain the preferred way to read attached blocks, conversations, and other content when they are available. +3. **Warp Drive creation, navigation, and sharing.** When an agent notices reusable knowledge during normal work, it can help the user turn that knowledge into a Warp Drive object, open it for review, and guide sharing with the right scope. This includes workflows from repeated command sequences, notebooks from task writeups, prompts/rules/facts from user or project preferences, environment variable collections, MCP setup objects, folders, and spaces. Existing object navigation remains important, but creation and sharing are first-class because reusable team knowledge cannot be used until users are guided into creating it. +4. **Deterministic demos and walkthroughs.** When a user, teammate, or go-to-market workflow needs a reliable Warp demo, an agent or script can put Warp into a known presentation state: theme, zoom, windows, tabs, panes, focused targets, panels, command palette/search, and Warp Drive surfaces. The walkthrough can then advance using structured target IDs and recover from stale or missing targets instead of relying on screen coordinates, manual setup, or brittle UI automation. +5. **Personalization, onboarding, and preference migration.** When a user wants Warp to feel familiar, an agent can inspect user-approved settings from tools such as VS Code, iTerm, Ghostty, or shell configuration, propose Warp equivalents, apply allowlisted changes through `warpctrl`, and report unsupported mappings explicitly instead of guessing. The same flow can support team onboarding presets, presentation preferences, accessibility-related settings, themes, font and zoom, keybindings, notifications, and panels. +Human power-user scripting is a secondary beneficiary of the same design. Scripts get reliable JSON, target selectors, structured errors, and permission categories because the API is strong enough for agents, but the primary product narrative remains agent-led operation of Warp itself. +Persistent settings changes, Warp Drive creation or sharing, cross-app preference migration, terminal command execution, and other underlying-data mutations must be visibly reviewable or require stronger explicit permission than low-risk workspace organization. `warpctrl` should support full typed control over time, but each command must be progressively unlocked through action categories, target resolution, Agent Profile permissions, Scripting settings, and authenticated-user requirements rather than broad unchecked authority. +## Behavior +1. The Warp control CLI operates only on running local Warp app processes. If no compatible Warp process is available, the CLI exits non-zero with a clear “no running Warp instance found” error. +2. The CLI exposes only explicitly allowlisted actions. Unknown action names, unsupported parameter combinations, or requests for non-allowlisted capabilities fail with structured errors; they are never forwarded to arbitrary internal dispatch. +3. Every successful mutating request identifies: + - The Warp process instance that executed it. + - The resolved target, when the action addresses a window, tab, pane, terminal session, terminal block, file, project/workspace, Warp Drive object, surface, or other targetable noun. + - A success payload suitable for JSON output. +4. Every failure identifies: + - A stable machine-readable error code. + - A human-readable explanation. + - Any selector that was ambiguous, missing, stale, unsupported, or invalid. +5. The CLI supports human-readable output by default and JSON output for scripts. JSON output has stable field names and is available for discovery commands, read commands, successful mutations, and failures. +6. The CLI supports process discovery and instance selection: + - `warpctrl instance list` returns all reachable local Warp app processes that support the protocol. + - Each process has an opaque `instance_id`, a channel/build identity, and enough display metadata for a developer to choose it. + - If exactly one compatible process is available, commands may target it implicitly. + - If multiple compatible processes are available, the CLI may select a single clearly active/frontmost instance when that state is unambiguous; otherwise it fails and asks the developer to pass an explicit instance selector. + - Developers can explicitly choose an instance by opaque instance ID. Channel or PID filters may be offered as convenience filters, but opaque instance ID is the canonical selector. +7. The CLI supports introspection for target discovery: + - `warpctrl window list` + - `warpctrl tab list` + - `warpctrl pane list` + - `warpctrl session list` + - `warpctrl block list` + - `warpctrl drive list` + - `warpctrl app active` + These commands return opaque protocol-facing IDs and enough metadata for subsequent commands without requiring knowledge of internal Warp identifiers. +8. The target selector model is hierarchical: + - Instance selector resolves a running Warp process. + - Window selector resolves within the instance. + - Tab selector resolves within the window. + - Pane selector resolves within the tab or active pane group context. + - Session selector resolves within the pane when the pane hosts terminal session state. + - Block selector resolves within the terminal session when the command is block-scoped. + Non-hierarchical selectors such as file paths, projects/workspaces, Warp Drive objects, and global app surfaces still resolve inside the selected instance and must not silently borrow lower-level pane/session defaults unless the action definition explicitly requires that scope. +9. Every selector family supports an ergonomic `active` form when that concept exists: + - Active instance, if unambiguous. + - Active window in the selected instance. + - Active tab in the selected window. + - Active pane in the selected tab. + - Active session in the selected pane. + - Active or selected terminal block in the selected session when a current block is unambiguous. +10. Every selector family supports explicit opaque IDs returned by introspection. Selector families may also support scoped indices, titles/names, or paths where those concepts are already user-visible, but IDs remain the preferred automation surface. + - Window selectors support `active`, opaque window IDs, window indices from `window list`, and exact window titles for interactive use. + - Tab selectors support `active`, opaque tab IDs, tab indices scoped to the resolved window, and exact tab titles for interactive use. + - Pane selectors support `active`, opaque pane IDs, and pane indices scoped to the resolved tab or pane group. + - Session selectors support `active`, opaque session IDs, and session indices scoped to the resolved pane when sessions are user-visible as an ordered list. + - Block selectors support `active`, opaque block IDs, and block indices scoped to the resolved terminal session when blocks are user-visible as an ordered list. A block command may also support read-only filters such as command text, status, time range, or “last completed” for interactive lookup, but those filters must fail on ambiguity and resolve to concrete block IDs before reading output. + - File selectors use paths, plus optional line/column coordinates where the command supports opening a location. + - Project/workspace selectors use paths, opaque project/workspace IDs when exposed by introspection, and exact names only as interactive convenience selectors. + - Warp Drive selectors use opaque object IDs, with optional type-scoped exact name/path lookups for interactive use. Type scopes must include the user-facing object families Warp exposes today: spaces, folders, notebooks, workflows, agent-mode workflows/prompts, environment variable collections, AI facts/rules, MCP servers, MCP server collections, and trash entries when trash operations are supported. +11. “Active session” means the currently selected terminal session for the resolved pane/window context. If the selected target does not contain a terminal session, session-scoped actions fail rather than silently redirecting elsewhere. +12. When a command omits lower-level selectors, it resolves them from the chosen higher-level context using active defaults. Example: a pane split command with only `--instance` uses that instance’s active window, active tab, and active pane. +13. When an explicitly supplied target disappears between discovery and execution, the request fails with a stale-target error. The CLI must not silently choose a different tab, pane, or session. +14. The protocol is command-oriented, not open-ended state mutation. Each action has a named command, validated parameters, and defined target scope. +15. The complete allowlisted action catalog should be organized around stable public nouns rather than internal view/action names. The target taxonomy includes instances, windows, tabs, panes, terminal sessions, terminal blocks, input buffers, command history entries, file/path intents, projects/workspaces, Warp Drive spaces, folders, notebooks, workflows, agent-mode workflows/prompts, environment variable collections, AI facts/rules, MCP servers, MCP server collections, settings, themes, keybindings, command surfaces such as the command palette and command search, panels/surfaces such as Warp Drive, resource center, AI assistant, code review, left/right panels, and vertical tabs, plus action/capability metadata. The initial implementation may expose only a subset, but new command families should extend this noun taxonomy instead of inventing unrelated selector conventions. +16. Discovery and read-only state actions: + - List instances. + - Get protocol/app version information for one instance. + - List windows, tabs, panes, and sessions. + - Get the currently active instance/window/tab/pane/session chain when available. + - Inspect enough target metadata to let a script decide what to address next. +17. Window actions: + - Create a new window. + - Focus a target window. + - Close a target window. +18. Tab actions: + - Create a new terminal tab. + - Create a new agent tab where that is already a user-visible app capability. + - Activate a target tab. + - Activate previous, next, or last tab. + - Move a target tab left or right. + - Rename or reset a tab title. + - Set or clear active-tab color where that is already supported in the UI. + - Close the active tab, a target tab, other tabs, or tabs to the right of a target tab. +19. Pane actions: + - Split a target pane left, right, up, or down. + - Optionally choose the shell/session profile for split operations when that already maps to user-facing behavior. + - Focus a target pane. + - Navigate focus left, right, up, or down among panes. + - Close a target pane. + - Toggle maximize for a target pane. + - Resize pane dividers left, right, up, or down when that is supported by the app. +20. Session and terminal-input actions: + - Cycle to the previous or next session where the app exposes session cycling. + - Insert text into the active input without executing it. + - Replace the active input buffer. + - Clear the active input buffer where that matches existing user behavior. + - Switch input mode between terminal and agent modes only where that mode switch is already user-visible and valid for the selected target. + Input staging commands must not submit terminal input or press Enter. The separate `input run` execution action may submit a command only in the later execution-underlying branch, after authenticated scripting identity, underlying-data-mutation permission, audit coverage, and explicit target resolution are implemented. Accepted-command submission and agent-prompt submission remain future protocol concepts that require separate product/security review. +21. Appearance actions: + - List available themes. + - Set the current fixed theme. + - Toggle or set “follow system theme.” + - Set the light and dark themes used when following the system theme. + - Increase, decrease, or reset font size. + - Increase, decrease, or reset UI zoom. + - Set other allowlisted appearance controls only when they correspond to stable user-facing controls. +22. Settings actions: + - Read allowlisted user-facing settings. + - Set allowlisted settings to validated values. + - Toggle allowlisted boolean settings. + - Reject attempts to mutate private, debug-only, unsafe, derived, or unsupported settings. + - Return a stable error when a named setting exists internally but is not part of the public local-control allowlist. +23. The settings allowlist should initially cover settings families that are already plainly user-facing and valuable for scripting: + - Theme/system-theme configuration. + - Font/zoom-related controls. + - Notifications. + - Syntax highlighting and error-underlining toggles. + - Accessibility verbosity where exposed to users. + - Selected panel/layout toggles when the user-facing behavior is already stable. + Additional settings families can be added only by extending the allowlist. +24. Panel and surface actions: + - Open the general settings surface. + - Open a specific settings page or settings search result. + - Open or toggle the command palette with an optional initial query where the app already supports query seeding. + - Open or toggle command search where that is already user-visible. + - Toggle or open the left panel, Warp Drive surface, right panel, resource center, AI assistant panel, code review panel, and vertical tabs panel where valid. +25. File/path intent actions may be included when they already mirror existing user-visible deep-link behavior: + - Open a path in a new tab or window. + - Open a repository picker or repo path flow where the current app already supports it. + These should remain allowlisted intent actions rather than arbitrary filesystem RPCs. +26. The following categories are explicitly excluded from the public allowlist even when internal actions exist for them: + - Crash, panic, heap-dump, token-copying, debug-reset, and similar developer/debug helpers. + - Arbitrary auth manipulation outside the explicit authenticated-scripting flows. + - Arbitrary cloud object mutation or broad Warp Drive CRUD outside the typed Drive actions in this spec. + - Arbitrary internal view dispatch by string. + - Arbitrary setting names outside the allowlist. + - Accepted-command submission and agent-prompt submission until they receive a separate product/security review. + Terminal command execution and typed Warp Drive object mutations are no longer excluded from the full product scope, but they belong to later authenticated underlying-data mutation branches and require stronger authenticated-user and permission gates than app-state or settings mutations. Local file content reads, writes, appends, deletes, and other filesystem-content mutations are excluded from the public `warpctrl` catalog; file/path support is limited to app-state intents such as opening a path in Warp and metadata reads of files already open in Warp. +27. CLI command names should be noun-oriented and discoverable. During the provisional standalone-binary phase, the control CLI should expose a `warpctrl ...` command surface: + - `warpctrl instance list` + - `warpctrl app active` + - `warpctrl tab create` + - `warpctrl tab rename --window-id <window_id> --tab-id <tab_id> "Build logs"` + - `warpctrl tab rename --window active --tab-index 0 "Build logs"` + - `warpctrl window close --window-title "Scratch"` + - `warpctrl pane split --direction right` + - `warpctrl pane split --instance <id> --window active --pane active --direction right` + - `warpctrl input replace --session-id <session_id> "cargo check"` + - `warpctrl block output --pane-id <pane_id> --block-id <block_id> --plain` + - `warpctrl theme set "Warp Dark"` + - `warpctrl setting set appearance.themes.system_theme true` + - `warpctrl input insert "cargo check" --replace` + Channelized install names or aliases may vary during packaging. If the product later converges on `warp ...`, update packaging, shell completions, and operator docs together. +28. The wire protocol mirrors the CLI model. A mutating request contains: + - An action name from the allowlist. + - A structured target selector. + - Validated parameters. + A response contains: + - Success/failure status. + - Resolved instance and target metadata. + - Result data or structured error data. +29. The protocol is versioned. Clients must be able to determine whether a running Warp process supports the protocol version and action they intend to call. +30. Multiple running Warp processes are a supported normal case, not an error state. A local stable build and local dev build, or multiple supported local app instances, can coexist; the CLI provides deterministic discovery and addressing rather than assuming one global server. +31. Requests should be scoped to local-user control of the running app, with separate enforcement for actions that require a true logged-in Warp user. A command that fails local authentication, local authorization, execution-context checks, or authenticated-user checks reports that condition explicitly and does not degrade into a less-specific request. +32. If a selected action is valid in general but impossible in the current UI state, the CLI reports a state-specific failure. Examples include: + - Splitting a pane that no longer exists. + - Issuing a session-scoped action against a non-terminal pane. + - Focusing a window that has closed. + - Setting a theme that is not available in that instance. +33. The first `warpctrl` implementation slice should ship the smallest end-to-end vertical slice that proves: + - The current implementation supports outside-Warp local-control requests only; verified inside-Warp requests are specified for future work and rejected until the app-issued terminal proof broker exists. + - Process discovery and target resolution work. + - A standalone CLI binary can reach a running local Warp process without launching or initializing the GUI app. + - `warpctrl tab create` creates a new terminal tab in the selected running instance. + - The command returns a structured success or failure payload suitable for human-readable and JSON output. + The first slice should include the minimum health/introspection commands needed to discover a running instance and exercise `tab.create`. +34. Follow-up PRs should fill out the remaining catalog in parallelizable groups once the protocol, discovery model, target resolution, error model, `tab.create` action path, and standalone `warpctrl` packaging shape have been validated by the first slice. +35. The protocol transport should be designed so that the default target is localhost but the CLI can be extended in the future to target remote URLs (e.g., a Warp instance on another machine or a hosted control endpoint). This is not in scope for the first implementation but should not be precluded by the architecture. +## API command surface +The public `warpctrl` API is organized around nouns that map to stable user-facing entities. Command names are intentionally not a dump of every internal `WorkspaceAction`, `TerminalAction`, keybinding, or command-palette binding. Internal actions inform the catalog, but a command is added only when it has a stable user-facing behavior, typed parameters, deterministic target resolution, and an explicit risk classification. +### State and data taxonomy +The product surface must distinguish what kind of state a command touches. This distinction is part of the public API and the permission model, not just an implementation detail. +- **Metadata reads** inspect app structure or configuration metadata without exposing user content: instances, windows, tabs, panes, sessions, capability metadata, action metadata, keybinding metadata, theme names, setting keys, current project identity, and other structural state. +- **Underlying data reads** expose user content or data-bearing state without changing it: terminal output, block contents, command history, input buffer contents, Warp Drive object contents, AI conversation content, and any other content that could contain user data or secrets. +- **App-state mutations** change visible local Warp UI state without directly changing user data: opening or focusing windows, creating or closing tabs, splitting panes, focusing panes, opening panels, opening command surfaces, opening files in Warp, and editing the input buffer without submitting it. +- **Metadata/configuration mutations** change persistent configuration or metadata, but not primary user content: changing themes, font size, zoom, allowlisted settings, keybindings, tab names, pane names, and tab colors. +- **Underlying data mutations** can change user data or cause external side effects: typed CRUD operations on Warp Drive objects, sharing Warp Drive objects to the user's team through an explicit approved command, inserting content into Warp Drive views, running allowlisted Warp Drive workflows, and running terminal commands through an explicit `input run` action. Accepted-command submission, agent-prompt submission, local file content mutation, arbitrary workflow execution, and arbitrary internal dispatch remain excluded until separately reviewed. +A command that touches multiple categories must require the strongest applicable permission. For example, `file open` is an app-state mutation because it opens a visible Warp editor/view, while `input run` is an underlying data mutation because it executes a command in the target session. +### Targeting flags +All commands that address a running app target accept the same selector flags where meaningful. Generic `--window`, `--tab`, `--pane`, `--session`, and `--block` flags accept the selector grammar below; explicit typed aliases are provided so scripts can avoid string parsing ambiguity: +- `--instance <instance_id>` selects a running Warp process from `warpctrl instance list`. +- `--pid <pid>` is a convenience instance selector and conflicts with `--instance`. +- `--window <active|id:<id>|index:<n>|title:<title>>` selects a window inside the instance. +- `--window-id <id>`, `--window-index <n>`, and `--window-title <title>` are exact aliases for the corresponding `--window ...` forms. +- `--tab <active|id:<id>|index:<n>|title:<title>>` selects a tab inside the resolved window. +- `--tab-id <id>`, `--tab-index <n>`, and `--tab-title <title>` are exact aliases for the corresponding `--tab ...` forms. +- `--pane <active|id:<id>|index:<n>>` selects a pane inside the resolved tab or pane-group context. +- `--pane-id <id>` and `--pane-index <n>` are exact aliases for the corresponding `--pane ...` forms. +- `--session <active|id:<id>|index:<n>>` selects a terminal or agent session inside the resolved pane when the command is session-scoped. +- `--session-id <id>` and `--session-index <n>` are exact aliases for the corresponding `--session ...` forms. +- `--block <active|id:<id>|index:<n>>` selects a terminal block inside the resolved terminal session when the command is block-scoped. +- `--block-id <id>` and `--block-index <n>` are exact aliases for the corresponding `--block ...` forms. +- File commands use path arguments or `--path <path>` where the path is the selected file entity; `--line <n>` and `--column <n>` refine the location when supported. +- Drive commands use object ID arguments or `--drive-id <id>` where the ID is the selected Warp Drive entity; name/path lookup must be type-scoped when supported. +- `--output-format <pretty|json|ndjson|text>` controls output shape and remains globally available. +Within a selector family, specifying more than one form is invalid. For example, `--tab-id` conflicts with `--tab-index`, `--tab-title`, and `--tab`. Omitted lower-level selectors use active defaults only when that active target is unambiguous. Explicit IDs must resolve exactly or fail with `stale_target`; index/title/name/path selectors that match zero targets fail with `missing_target`, and selectors that match multiple targets fail with `ambiguous_target`. +### Read-only command set +The read-only branches should implement the following commands before mutating catalog expansion begins: `zach/warp-cli-readonly-metadata` owns structural metadata reads, and `zach/warp-cli-readonly-data-settings` owns underlying-data reads plus read-only settings/appearance/docs. Read-only does not mean one permission: metadata reads and underlying data reads are separate grant categories. +Metadata and capability reads: - `warpctrl instance list` -- `warpctrl instance inspect --instance <id>` -- `warpctrl capability list` -- `warpctrl capability inspect tab.create` -- `warpctrl action inspect drive.workflow.run` -- `warpctrl tab create --window active` -- `warpctrl pane split --direction right` -- `warpctrl block output --block-id <id> --plain` -- `warpctrl surface.settings.open --page scripting` -- `warpctrl drive inspect <id>` -JSON output and structured errors are supported for discovery, reads, mutations, and failures. -# Implementation status -The running app advertises implementation status per action. Unsupported catalog entries return `unsupported_action`; names intentionally outside the public catalog return `not_allowlisted` or fail enum parsing before dispatch. +- `warpctrl instance inspect [--instance <id>|--pid <pid>]` +- `warpctrl app ping [selectors]` +- `warpctrl app version [selectors]` +- `warpctrl app active [selectors]` +- `warpctrl capability list [selectors]` +- `warpctrl capability inspect <action> [selectors]` +Window, tab, pane, and session reads: +- `warpctrl window list [selectors]` +- `warpctrl window inspect [--window <selector>] [selectors]` +- `warpctrl tab list [--window <selector>] [selectors]` +- `warpctrl tab inspect [--tab <selector>] [selectors]` +- `warpctrl pane list [--tab <selector>] [selectors]` +- `warpctrl pane inspect [--pane <selector>] [selectors]` +- `warpctrl session list [--pane <selector>] [selectors]` +- `warpctrl session inspect [--session <selector>] [selectors]` +Underlying data reads, gated separately from structural metadata reads: +- `warpctrl block list [--session <selector>|--pane <selector>] [--limit <n>] [selectors]` +- `warpctrl block inspect --block <selector> [selectors]` +- `warpctrl block output --block <selector> [--plain|--ansi|--json] [selectors]` +- `warpctrl input get [--session <selector>] [selectors]` +- `warpctrl history list [--session <selector>] [--limit <n>] [selectors]` +Appearance, settings, and command-surface reads: +- `warpctrl theme list [selectors]` +- `warpctrl theme get [selectors]` +- `warpctrl appearance get [selectors]` +- `warpctrl setting list [--namespace <namespace>] [selectors]` +- `warpctrl setting get <key> [selectors]` +- `warpctrl keybinding list [selectors]` +- `warpctrl keybinding get <binding_name> [selectors]` +- `warpctrl action list [selectors]` +- `warpctrl action inspect <action> [selectors]` +Local file and project reads that expose only app/editor state, not arbitrary filesystem traversal: +- `warpctrl file list [selectors]` +- `warpctrl project active [selectors]` +- `warpctrl project list [selectors]` +Authenticated read-only Warp Drive metadata and data reads, enabled only when the selected app has a logged-in Warp user and the grant allows authenticated reads. Listing is metadata; inspecting object content is an underlying data read: +- `warpctrl drive list --type <workflow|notebook|env-var-collection|prompt|folder|ai-fact|mcp-server|space|trash> [selectors]` +- `warpctrl drive inspect <id> [selectors]` +### Authenticated scripting command set +The full product requires two authenticated scripting modes before high-risk underlying-data mutations ship: +- **Verified Warp-terminal authenticated scripting:** `warpctrl` runs inside a Warp-managed terminal, presents the app-issued terminal proof described in `TECH.md`, and may receive authenticated-user grants only when the selected app is logged into Warp and Settings > Scripting allows authenticated actions from verified Warp terminals. +- **External API-key authenticated scripting:** `warpctrl` runs outside Warp or in a pure automation environment and presents a Warp-issued API key or derived short-lived exchange token to the selected app's local broker. The broker verifies the key, scopes, expiry, and user subject before issuing local authenticated-user grants. This path is separate from the local-control bearer credential and is required for unattended scripts that need Drive or execution authority. +Recommended CLI surface for API-key setup and inspection: +- `warpctrl auth status [selectors]` reports local-control auth state, selected app login state, authenticated grant availability, and whether an external API-key identity is configured. +- `warpctrl auth login [selectors]` focuses the selected Warp app's sign-in UI for interactive app-login flows. +- `warpctrl auth api-key set --key-env <env_var>|--key-stdin [selectors]` stores or references an external scripting API key in platform secure storage without printing it. +- `warpctrl auth api-key status [selectors]` reports key subject/scope metadata without revealing the key. +- `warpctrl auth api-key revoke [selectors]` deletes the local stored reference and, where supported, revokes the server-side key. +The API-key path must support non-interactive scripts through an environment variable or secret manager reference, but raw keys must never be written to discovery records, logs, JSON output, shell completions, or repo config. +### Mutating command set +The mutating branches should build on the read-only and authenticated-scripting stack. `zach/warp-cli-mutating-layout` owns app/window/tab/pane layout mutations. `zach/warp-cli-mutating-input-settings-surfaces` owns input/session/settings/surface mutations. `zach/warp-cli-mutating-drive-data` owns Warp Drive underlying-data mutations. `zach/warp-cli-mutating-execution-underlying` owns terminal command execution and other approved execution-underlying actions. Mutating commands are split by what they mutate: app-state, metadata/configuration, or underlying data. Underlying data mutations require a separate and stronger permission plus an authenticated scripting identity. +App-state mutations for app, window, and surfaces: +- `warpctrl app focus [selectors]` +- `warpctrl window create [--shell <name>] [selectors]` +- `warpctrl window focus --window <selector> [selectors]` +- `warpctrl window close --window <selector> [selectors]` +- `warpctrl surface settings open [--page <page>] [--query <query>] [selectors]` +- `warpctrl surface command-palette open [--query <query>] [selectors]` +- `warpctrl surface command-search open [--query <query>] [selectors]` +- `warpctrl surface warp-drive open [selectors]` +- `warpctrl surface warp-drive toggle [selectors]` +- `warpctrl surface resource-center toggle [selectors]` +- `warpctrl surface ai-assistant toggle [selectors]` +- `warpctrl surface code-review toggle [selectors]` +- `warpctrl surface left-panel toggle [selectors]` +- `warpctrl surface right-panel toggle [selectors]` +- `warpctrl surface vertical-tabs toggle [selectors]` +App-state mutations for tabs: +- `warpctrl tab create [--type terminal|agent|cloud-agent|default] [--shell <name>] [selectors]` +- `warpctrl tab activate --tab <selector> [selectors]` +- `warpctrl tab activate --previous [selectors]` +- `warpctrl tab activate --next [selectors]` +- `warpctrl tab activate --last [selectors]` +- `warpctrl tab move --tab <selector> --direction <left|right> [selectors]` +- `warpctrl tab close --tab <selector> [selectors]` +- `warpctrl tab close --active [selectors]` +- `warpctrl tab close --others --tab <selector> [selectors]` +- `warpctrl tab close --right-of --tab <selector> [selectors]` +Metadata mutations for tabs: +- `warpctrl tab rename --tab <selector> <title> [selectors]` +- `warpctrl tab reset-name --tab <selector> [selectors]` +- `warpctrl tab color set --tab <selector> <color> [selectors]` +- `warpctrl tab color clear --tab <selector> [selectors]` +App-state mutations for panes: +- `warpctrl pane split --direction <left|right|up|down> [--shell <name>] [selectors]` +- `warpctrl pane focus --pane <selector> [selectors]` +- `warpctrl pane navigate --direction <left|right|up|down|previous|next> [selectors]` +- `warpctrl pane resize --direction <left|right|up|down> [--amount <cells>] [selectors]` +- `warpctrl pane maximize [--pane <selector>] [selectors]` +- `warpctrl pane unmaximize [selectors]` +- `warpctrl pane close --pane <selector> [selectors]` +Metadata mutations for panes: +- `warpctrl pane rename --pane <selector> <title> [selectors]` +- `warpctrl pane reset-name --pane <selector> [selectors]` +App-state mutations for sessions and input buffers: +- `warpctrl session activate --session <selector> [selectors]` +- `warpctrl session previous [selectors]` +- `warpctrl session next [selectors]` +- `warpctrl session reopen-closed [selectors]` +- `warpctrl input insert <text> [--session <selector>] [selectors]` +- `warpctrl input replace <text> [--session <selector>] [selectors]` +- `warpctrl input clear [--session <selector>] [selectors]` +- `warpctrl input mode set <terminal|agent> [--session <selector>] [selectors]` +These input-buffer commands only stage or edit text and must not submit the buffer. The separate `input run` command belongs only to the execution-underlying branch and requires authenticated scripting identity, underlying-data-mutation permission, explicit target resolution, and audit coverage. Accepted-command submission and agent-prompt submission remain excluded until separately reviewed. +Metadata/configuration mutations for appearance and settings: +- `warpctrl theme set <theme_name> [selectors]` +- `warpctrl theme system set <true|false> [selectors]` +- `warpctrl theme light set <theme_name> [selectors]` +- `warpctrl theme dark set <theme_name> [selectors]` +- `warpctrl appearance font-size increase [selectors]` +- `warpctrl appearance font-size decrease [selectors]` +- `warpctrl appearance font-size reset [selectors]` +- `warpctrl appearance zoom increase [selectors]` +- `warpctrl appearance zoom decrease [selectors]` +- `warpctrl appearance zoom reset [selectors]` +- `warpctrl setting set <key> <value> [selectors]` +- `warpctrl setting toggle <key> [selectors]` +App-state mutations for files, projects, and Warp Drive views: +- `warpctrl file open <path> [--line <line>] [--column <column>] [--new-tab] [selectors]` +- `warpctrl project open <path> [selectors]` +- `warpctrl drive open <id> [selectors]` +- `warpctrl drive notebook open <id> [selectors]` +- `warpctrl drive env-var-collection open <id> [selectors]` +- `warpctrl drive object share open <id> [selectors]` +Underlying data mutations for authenticated Warp Drive objects: +- `warpctrl drive object create --type <workflow|notebook|env-var-collection|prompt|folder> [--content <text>|--content-file <path>] [selectors]` +- `warpctrl drive object update <id> [--content <text>|--content-file <path>] [selectors]` +- `warpctrl drive object delete <id> [selectors]` +- `warpctrl drive object insert <id> [--target <selector>] [selectors]` +- `warpctrl drive object share-to-team <id> [selectors]` +- `warpctrl drive workflow run <id> [--arg <name=value>...] [selectors]` +Execution-underlying actions: +- `warpctrl input run <command> [--session <selector>] [selectors]` +These are underlying-data mutations because they can modify user data, execute code, trigger external side effects, share cloud-backed content, or run user-authored content. They require authenticated scripting identity, underlying-data-mutation permission, deterministic target resolution, audit records, and explicit tests proving lower-permission credentials cannot run them. `drive object share-to-team` is the only direct sharing mutation in the v0 product scope: it may make a personal Warp Drive object available to the user's current team using the app's standard team-sharing semantics. Arbitrary ACL editing, sharing with specific users, sharing with external guests, public-link creation, accepted-command submission, and agent-prompt submission remain excluded until separately reviewed. +### Excluded from the public command surface +The command surface must continue to exclude debug-only, crash-only, auth-token, heap-dump, and arbitrary internal dispatch actions even when those actions are available in command palette or keybinding registries. Examples that remain excluded are app crash/panic helpers, access-token copy helpers, heap profile dumps, debug reset actions, raw view-tree debugging, and broad internal action-by-string execution. +## Branch stacking and delivery model +The Warp Control CLI work should ship as a raw-git branch stack so the combined specs/foundation slice, read-only expansion, and mutating expansion remain reviewable independently: +- `zach/warp-cli-core-foundation` is the bottom review branch and targets `master`. It owns `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs alongside the first implementation slice: shared protocol, discovery/auth scaffolding, outside-Warp Settings > Scripting gates, local-control bridge/server, standalone `warpctrl` binary, packaging hooks, and the smallest safe end-to-end action. Verified inside-Warp invocation is documented for future implementation but is not supported by this branch. +- `zach/warp-cli-readonly-metadata` stacks on `zach/warp-cli-core-foundation` and implements structural metadata reads, including instance/app health, active-chain, windows, tabs, panes, sessions, and action metadata. +- `zach/warp-cli-readonly-data-settings` stacks on `zach/warp-cli-readonly-metadata` and fills in underlying-data reads plus read-only settings/appearance/docs, including terminal block output, input-buffer reads, history reads, and allowlisted settings metadata. +- `zach/warp-cli-authenticated-scripting` stacks on `zach/warp-cli-readonly-data-settings` and implements authenticated-user grant plumbing for both verified Warp-terminal invocations and external API-key scripting identities. It does not broaden action support by itself; it makes later high-risk branches enforceable. +- `zach/warp-cli-mutating-layout` stacks on `zach/warp-cli-authenticated-scripting` and implements app/window/tab/pane layout mutations. +- `zach/warp-cli-mutating-input-settings-surfaces` stacks on `zach/warp-cli-mutating-layout` and fills in approved input/session/settings/surface mutating command families while preserving the prohibition on accepted-command submission and agent-prompt submission. +- `zach/warp-cli-mutating-drive-data` stacks on `zach/warp-cli-mutating-input-settings-surfaces` and implements authenticated Warp Drive underlying-data mutations from the approved allowlist, including object creation/update/delete/insert and the v0 personal-to-team sharing path. +- `zach/warp-cli-mutating-execution-underlying` stacks on `zach/warp-cli-mutating-drive-data` and implements authenticated execution-underlying actions such as `input run` and typed workflow execution where supported. +The previous `zach/warp-cli-specs` branch is retained only as migration-source/history material. New spec changes originate on `zach/warp-cli-core-foundation` and are propagated upward through the stack with raw git so all higher implementation branches reflect the same product/security contract. Graphite is not part of this stack. If a lower branch merges first, higher branches should rebase onto the merged successor while preserving the approved spec content. +## Built-in Warp Agent skill +Warp should include a built-in Agent skill for `warpctrl`, analogous to the bundled `oz-platform` skill. The skill should teach Warp Agent when to use `warpctrl`, how to discover and target running instances, how to prefer read-only commands before mutating commands, how to request explicit user approval for underlying data mutations, and how to interpret structured errors. The skill should document the stable command hierarchy above, include concise recipes for common automation tasks, and avoid instructing agents to bypass the CLI by calling local-control HTTP endpoints directly. +## CLI implementation and documentation conventions +`warpctrl` should feel consistent with the Oz CLI from a developer's perspective and use the same CLI libraries and conventions: +- Argument parsing, subcommand structure, help text, and shell-completion generation should use the same `clap`/`clap_complete` patterns used by the Oz CLI. +- JSON serialization and machine-readable output should use the same `serde`/`serde_json` conventions and the same output-format vocabulary used by the Oz CLI. +- Human-readable help, examples, errors, and generated completions should follow Oz CLI conventions unless Warp Control has a documented product reason to differ. +CLI documentation should be generated from the command catalog instead of maintained by hand in multiple places: +- The typed action catalog is the source of truth for command names, selector flags, parameters, output formats, state/data category, required permission, authenticated-user requirement, support status, and examples. +- `warpctrl help`, shell completions, markdown reference docs, the built-in Warp Agent skill, and the operator README should be generated or checked from that catalog so they cannot drift silently. +- A later branch should add native Warp completions for `warpctrl` in addition to shell completions so Warp can suggest commands, flags, selectors, and action names directly in the input editor. +- Generated documentation must distinguish implemented commands from planned catalog entries. A command may appear in specs as planned, but public operator docs must not imply it is usable until the selected app build advertises support for it. +- CI or presubmit checks should fail when CLI parser/help output, generated reference docs, completions, or the built-in skill are stale relative to the command catalog. +## Action classification and permission model +Agents, scripts, and human developers are expected to be major consumers of `warpctrl`. The action catalog must therefore classify every action by risk posture, state/data category, permission category, and authenticated-user requirement so Warp can enforce local-control permissions in the app bridge. +Every action definition must include: +- a stable action name and namespace; +- a risk posture; +- a state/data category: metadata read, underlying data read, app-state mutation, metadata/configuration mutation, or underlying data mutation; +- whether a true logged-in Warp user is required; +- whether the action may run from external clients, verified Warp-terminal clients, or both; +- whether inside-Warp and outside-Warp scripting settings can enable the action; +- the required local-control permission category; +- any target-scope restrictions. +By default, new actions require an authenticated Warp user. An action may be marked logged-out-safe only after deliberate review confirms it does not touch Warp Drive, AI conversation traces, synced settings, team/account data, cloud-backed user state, terminal content, or other user-sensitive data. +### Authenticated scripting model +Authenticated scripting is required for any command that acts on a true Warp user identity or performs underlying-data mutation. Local-control credentials prove that a process may talk to the selected app; authenticated scripting credentials prove which Warp user or automation identity is allowed to request user-backed or high-risk actions. +Inside Warp, authenticated scripting uses the verified terminal proof flow: the selected app is already logged in, the terminal proof binds the CLI to a live Warp-managed session, and the broker may mint an authenticated-user grant for that app user when Settings > Scripting allows it. +Outside Warp, authenticated scripting uses a Warp-issued API key or exchanged short-lived token. The API key must be scoped for scripting/local control, optionally constrained to action categories or resource families, and tied to a Warp user subject. The selected app must either be logged in as the same subject or be able to validate that the API key's subject is authorized for the requested local action without exporting cloud auth tokens to the script. External API-key grants default off in Settings > Scripting and should be separable from ordinary outside-Warp logged-out-safe control. +### Permission categories +Every action in the catalog belongs to exactly one of the following permission categories, from least to most sensitive: +1. **Read-only / metadata.** Actions that return local app structure, app state, or configuration metadata without exposing terminal content, file content, Warp Drive object content, AI conversation content, or other user data. + - Instance discovery and health: `instance list`, `app active`, `app version`, `app ping`. + - Layout enumeration: `window list`, `tab list`, `pane list`, `session list`. + - Metadata reads: `theme list`, `setting list`, `keybinding list`, `action list`, `project active`, and Drive object listing that returns object IDs/names/types but not content. +2. **Read-only / underlying data.** Actions that return user content or data-bearing state without changing it. + - Terminal reads: block output, scrollback, command history, input editor contents, session replay, or terminal-derived traces. + - Warp Drive object content reads, AI conversation reads, and any authenticated-user data read. + This category is separate from metadata because read-only content can contain secrets, source code, customer data, command output, or other sensitive data. +3. **Mutating / app state.** Actions that change visible local Warp UI state without directly changing underlying user data. + - Layout and focus: `window create`, `window focus`, `tab create`, `tab activate`, `tab move`, `window close`, `tab close`, `pane split`, `pane focus`, `pane navigate`, `pane maximize`, `pane resize`, and panel/surface toggles. + - Input-buffer staging: `input insert`, `input replace`, and `input clear` as long as they do not submit or execute the buffer. + - Opening views: opening settings, command palette, command search, Warp Drive, code review, files, projects, notebooks, and env-var collections. +4. **Mutating / metadata or configuration.** Actions that change persistent metadata or configuration but do not directly mutate primary user data. + - Tab and pane names, tab colors, themes, system-theme settings, font size, zoom, allowlisted app settings, and keybindings. + Metadata/configuration writes need a stronger permission than app-state-only changes because they persist beyond the current UI interaction, but they are still distinct from data writes. +5. **Mutating / underlying data.** Actions that can change user data, execute code, submit prompts, or cause external side effects. + - Terminal execution through the explicit `input run` action and typed workflow execution where supported. + - Warp Drive CRUD and sharing: create, update, delete, insert, share to the user's current team, run, or otherwise mutate workflows, notebooks, prompts, env-var collections, folders, or other Drive objects. + - AI conversation history mutation and any action that modifies cloud-backed user content. + - Future agent execution: submitting an agent prompt, accepting an agent-proposed command, or otherwise causing an agent to act; these remain excluded until separately reviewed. + This category must be explicitly separate from app-state mutation and requires authenticated scripting identity. A client allowed to open or focus Warp UI must not automatically be allowed to execute commands, mutate Warp Drive content, or perform local file content operations. +### Authenticated-user requirement +An authenticated user means a true logged-in Warp user in the selected Warp app, not merely the local OS user or a `warpctrl` process authenticated to localhost. +The allowlist must clearly indicate `requires_authenticated_user` for every action: +- `false` only for logged-out-safe actions that operate on local app structure, local appearance metadata, or local-only settings that do not expose user-sensitive data. +- `true` for actions that read or mutate Warp Drive, AI conversation traces, synced settings, team/account data, user identity data, or any cloud-backed Warp state. +- `true` for actions that execute user-authored Warp Drive content, even if the execution target is a local terminal session. +If an authenticated-user action is invoked while the selected app has no logged-in user, the CLI reports a structured authenticated-user error. It must not silently return partial logged-out data as success. +### Warp Control authenticated scripting protocol +`warpctrl` has two authenticated scripting modes. Interactive inside-Warp use relies on the logged-in user in the selected Warp app and verified terminal proof. External or pure scripting use relies on a Warp-issued API key that is separate from local-control credentials and is exchanged for short-lived authenticated grants. +The CLI should expose auth/status flows for both modes: +- `warpctrl auth status [selectors]` reports whether the selected Warp app is logged in and returns a stable, non-secret user subject/identity summary when the caller has the required local-control grant. +- `warpctrl auth login [selectors]` does not collect credentials in the CLI or mint a separate CLI account session. It focuses or opens the selected Warp app's normal sign-in UI and waits, or exits with instructions, until the user completes sign-in in that app. +- After app login completes, the app-side credential broker may mint an app-user grant only for the same user subject that is currently logged in to the selected app. For external API-key mode, the broker may mint an API-key-backed grant only after validating the key, scopes, subject, and local Scripting permissions. +- Authenticated credentials are bound to the selected app instance, subject, grant mode, scopes, expiry, and optional target/resource restrictions. If the app logs out, switches users, loses auth state, or the grant's subject no longer matches a grant that requires the selected app's logged-in subject, authenticated actions fail with a structured authenticated-user error rather than using stale authority. +- Raw Firebase, server, OAuth, cloud API tokens, and raw scripting API keys are never exported to `warpctrl` output, shell scripts, generated docs, logs, discovery records, or JSON responses. +This authenticated scripting protocol applies only to actions whose allowlist entry requires a true logged-in Warp user, external API-key identity, or underlying-data-mutation authority. Logged-out-safe local actions continue to use local-control credentials without requiring Warp account login or API-key setup. +### Execution context policy +`warpctrl` should eventually distinguish verified invocations from inside Warp-managed terminal sessions from external invocations. The current foundation branch supports external invocation only and must reject verified Warp-terminal claims until the proof broker is implemented. +- **Verified Warp-terminal invocation:** a `warpctrl` process started inside a Warp-managed terminal session and able to present an app-issued execution-context proof. The top-level setting for this context should default to on. When the selected app has a logged-in Warp user, this context can receive authenticated-user grants if the user's Scripting permissions allow that grant. +- **External invocation:** a `warpctrl` process started outside Warp's terminal, such as from another terminal app, launch agent, IDE, or background script. The top-level setting for this context must default to off. When disabled, external invocations receive no local-control credentials, including logged-out-safe metadata credentials. +- The app must not trust a caller-declared label. Environment variables may help discover the context, but the broker must verify a session-bound capability or equivalent proof before issuing in-Warp-only grants. +### Settings surface +Warp should add a new top-level Settings pane page named **Scripting**. This page should own settings for local scripting and automation surfaces, including Warp control. The current foundation branch should expose only outside-Warp Warp control settings. In the long-term model, once verified Warp-terminal invocation is implemented, Warp control should include two top-level toggles: +- **Allow Warp control from inside Warp:** default on. Controls `warpctrl` invocations from verified Warp-managed terminal sessions. +- **Allow Warp control from outside Warp:** default off. Controls `warpctrl` invocations from external terminals, scripts, IDEs, launch agents, and other same-user processes. +The Scripting page should explain that inside-Warp control is scoped to commands launched from Warp-managed terminals, while outside-Warp control allows other local apps and scripts to talk to Warp's control plane. Disabling either top-level toggle should invalidate credentials for that invocation context. +### Granular local-control permissions +In the long-term model, the Scripting settings page should expose granular permissions beneath the inside-Warp and outside-Warp toggles. The current foundation branch exposes only the outside-Warp subset. Recommended controls: +- Allow metadata reads. +- Allow underlying data reads. +- Allow app-state mutations. +- Allow metadata/configuration mutations. +- Allow underlying data mutations. +- Allow authenticated-user actions from verified Warp terminals. +- Allow authenticated-user actions from external clients, default off and separate from the in-Warp permission. +These settings define the maximum grants the broker may issue. The app bridge still enforces the action's risk posture, state/data category, authenticated-user requirement, execution-context requirement, and target scope for every request. Enabling app-state mutation must not imply permission to mutate underlying data. +### Agent Profile permissions +Agent Profiles should expose a dedicated **Warp control** permission group for agents that can invoke `warpctrl`. This permission group should mirror the local-control action categories so users can choose different `warpctrl` authority for different agent workflows: +- Metadata reads. +- Underlying data reads. +- App-state mutations. +- Metadata/configuration mutations. +- Underlying data mutations. +Each category should support the same autonomy vocabulary used by other Agent Profile permissions: the agent may be allowed to proceed, required to ask, allowed to decide based on confidence and risk, or denied. A cautious profile can therefore allow metadata reads and ask for app-state mutations, while a demo or onboarding profile can be explicitly configured to allow workspace organization or presentation setup. +Agent Profile permissions and global Scripting settings both apply. Settings > Scripting defines the maximum local-control authority available for an execution context, such as verified inside-Warp invocation or outside-Warp invocation. The selected Agent Profile defines what that specific agent is allowed to request within that maximum. If either layer denies an action category, authenticated-user requirement, or execution context, the request fails with a structured permission error instead of falling back to a weaker action or a raw `warpctrl` shell command. +The profile-level permission group should preserve the native-tools-first boundary. Agents should prefer native tools for code editing, file reads/writes, shell command execution, web/MCP calls, and attached conversation or block context when those tools are available. Agents should prefer `warpctrl` when the task requires operating Warp product surfaces, preserving visible UI context for the user, using Warp Drive as a first-class app surface, or applying the app's own permissioned control plane. +### Scoped credentials +The local discovery record must not expose a reusable full-access credential. `warpctrl` should request scoped credentials from an app-owned broker or equivalent trusted path. +Scoped credentials should include: +- the selected Warp instance; +- granted permission categories; +- allowed action families; +- verified execution context; +- whether authenticated-user access is granted and for which logged-in user subject; +- optional target scopes; +- issuance and expiry metadata; +- revocation/audit identity. +The bridge, not the CLI frontend, enforces these grants. If a request exceeds its credential, the bridge returns `insufficient_permissions`, `authenticated_user_required`, `authenticated_user_unavailable`, or `execution_context_not_allowed` as appropriate. +### Future entity extensibility: files, blocks, and Warp Drive objects +The selector and action model should accommodate entity types beyond the current window/tab/pane/session hierarchy. Important entity families are **terminal blocks**, **file/path intents**, **projects/workspaces**, and **Warp Drive objects**. Broad Drive mutation and command execution are not in scope for the foundation branch, but they are in scope for later authenticated branches in the expanded stack. Local file content reads, writes, appends, deletes, and other filesystem-content mutations are intentionally out of scope for the public `warpctrl` catalog because native agent file tools are the preferred surface for file content operations. Agent-prompt submission remains excluded until separately reviewed. +**Terminal blocks.** Blocks are first-class targetable terminal entities, not just data hanging off a session. Block selectors should support the same addressing primitives as terminal sessions where meaningful: active/current block, opaque block ID, and block index scoped to the resolved session. Block reads can expose command text, output, status, timing, exit code, and metadata, so block content reads are underlying-data reads while block listing that returns only IDs/status/timestamps may be metadata reads. Stale, missing, or ambiguous block selectors must fail rather than selecting a neighboring block. +**Files.** Warp already supports file opening via deep links and the built-in editor. The `file` namespace is limited to app-state and metadata behaviors that operate Warp's visible UI: +- `warpctrl file open <path>` — app-state mutation that opens a file in a Warp editor tab, equivalent to clicking a file link. +- `warpctrl file open <path> --line <n>` — app-state mutation that opens at a specific line. +- `warpctrl file list` — metadata read that lists files currently open in editor tabs across the instance. +- `warpctrl project open <path>` — app-state mutation that opens or focuses a project/workspace in Warp where that matches existing user-visible behavior. +File selectors use filesystem paths (absolute or relative to the working directory of the target pane/session when the command defines that behavior). Unlike window/tab/pane selectors, file selectors are not opaque IDs — they are user-visible paths. The protocol should support a `file` field in the target selector that accepts a path string, distinct from the opaque ID selectors used for windows, tabs, and panes. `warpctrl` must not expose file content reads or filesystem-content mutations; agents and scripts should use native file tools for those operations. +**Warp Drive objects.** Warp Drive stores typed objects that users can reference, execute, edit, and share. The object taxonomy should include, at minimum, spaces, folders, notebooks, workflows, agent-mode workflows/prompts, environment variable collections, AI facts/rules, MCP servers, MCP server collections, and trash entries where trash operations are exposed. A future `drive` namespace could support: +- `warpctrl drive list --type workflow` — authenticated metadata read that lists Warp Drive objects by type. +- `warpctrl drive inspect <id>` — authenticated underlying data read when it returns object content. +- `warpctrl drive workflow run <workflow-id>` — authenticated underlying data mutation that executes a typed workflow in a target session, implemented only in the execution-underlying branch with authenticated scripting identity and audit coverage. +- `warpctrl drive object create|update|trash|restore <id>` — authenticated underlying data mutations that change cloud-backed user content. +- `warpctrl drive object share open <id>` — app-state mutation that opens the sharing dialog for user review without changing sharing state. +- `warpctrl drive object share-to-team <id>` — authenticated underlying data mutation that makes a personal object available to the user's current team using the app's standard team-sharing behavior. This is the only direct sharing mutation in the v0 product scope. +- `warpctrl drive notebook open <notebook-id>` — app-state mutation that opens a view of an existing notebook without modifying it. +Drive object selectors should support both opaque IDs (for automation stability) and human-friendly name/path lookups (for interactive use). The type field (`workflow`, `notebook`, `env_var_collection`, `prompt`, `folder`, `ai_fact`, `mcp_server`, etc.) acts as a namespace filter. Drive actions that execute content in a terminal session (e.g., running a workflow) inherit the underlying-data-mutation permission from the action classification model and are implemented only in the execution-underlying branch after authenticated scripting identity and audit coverage are in place. +**Design constraints for these future entity families:** +- File and Drive selectors are orthogonal to the window/tab/pane hierarchy — a file open action targets an instance (which window to open in), not a specific pane. A Drive workflow execution targets a session (which pane to run in). +- The `TargetSelector` type in the protocol should be extensible with optional fields for these new selector families without breaking existing requests that omit them. +- The action classification categories apply, and Drive actions require authenticated-user grants by default: listing Drive objects is metadata plus authenticated user, reading Drive object content is underlying-data-read plus authenticated user, opening an existing Drive object or its sharing dialog in the app is app-state mutation plus authenticated user, and executing, sharing, or changing a Drive object is underlying-data-mutation plus authenticated user. +### Settings: protocol-first +Settings reads and writes should go through the local-control protocol like other actions, not bypass it via direct file manipulation. +- `warpctrl setting get <key>`, `warpctrl setting set <key> <value>`, and `warpctrl setting toggle <key>` send requests to the running Warp instance through the standard authenticated control endpoint. +- The app bridge validates the key against the allowlist and the value against the expected type before applying the change. +- This keeps authorization enforcement consistent: the same permission category, execution-context, and authenticated-user policies apply to settings mutations as to any other action, rather than creating a second unguarded path through the filesystem. +- The app owns the write to the settings file and any side effects (e.g., theme reload, layout reflow) as a single atomic operation, avoiding races between a direct settings-file edit and the app's file watcher. +- If a future need arises for offline settings manipulation (no running Warp process), a separate file-based path can be added later with its own validation, but it should not be the default. +- The action classification still applies: settings reads are metadata reads, and settings writes are metadata/configuration mutations. Settings writes must not be authorized by app-state mutation permission alone. diff --git a/specs/warp-control-cli/README.md b/specs/warp-control-cli/README.md index b02e888556..6bf563e15c 100644 --- a/specs/warp-control-cli/README.md +++ b/specs/warp-control-cli/README.md @@ -1,37 +1,93 @@ # warpctrl operator README -`warpctrl` is the standalone CLI for controlling already-running local Warp app instances. It is intended for scripts, demos, agent workflows, and developer automation that need allowlisted Warp UI actions without launching the GUI executable in CLI mode. -# Implementation status -The protocol catalog is broader than the set of handlers implemented by any one branch. Use `warpctrl capability list`, `warpctrl capability inspect <action>`, `warpctrl action list`, or `warpctrl action inspect <action>` when supported by the selected app to distinguish implemented actions from catalog stubs. -The current foundation path supports external logged-out-safe local control only. Authenticated actions require verified Warp-managed terminal invocation and are rejected until that proof path is implemented by the selected app. -# Packaging model -`warpctrl` is packaged as a separate CLI artifact from the Warp GUI app while reusing shared repository code: -- `crates/local_control` owns discovery records, authentication material, client transport, protocol envelopes, action names, and error types. +`warpctrl` is the provisional standalone CLI for controlling an already-running local Warp app instance. It is intended for scripts, demos, agent workflows, and developer automation that need to perform allowlisted Warp UI actions without launching the GUI executable in CLI mode. +The first implementation slice is intentionally narrow: +- discover compatible running Warp instances; +- select one instance implicitly when unambiguous or explicitly with `--instance`; +- send authenticated local-control requests through the per-instance discovery record; +- create a new terminal tab with `warpctrl tab create`. +The local-control protocol and catalog are broader than this slice, but commands outside the implemented capability set should fail with structured unsupported-action errors until their handlers land. +## Packaging model +`warpctrl` should be packaged as a separate CLI artifact from the Warp GUI app while reusing shared repository code: +- `crates/local_control` owns discovery records, local authentication material, client transport, protocol envelopes, action names, and error types. - `crates/warp_cli` owns command parsing conventions for local-control subcommands. -- the app-side bridge owns the per-process local listener and dispatches supported actions onto the live Warp UI context. -The binary initializes only CLI parsing, instance discovery, credential loading, request serialization, transport, and output formatting. It should not initialize GUI state, rendering, workspaces, or main-app startup paths. -# Local test flow -Use matching app and CLI bits from the same branch or artifact so protocol version and catalog agree. +- the app-side bridge owns the per-process loopback listener and dispatches supported actions onto the live Warp UI context. +The binary should initialize only CLI parsing, instance discovery, local authentication loading, request serialization, HTTP transport, and output formatting. It should not initialize GUI state, terminal models, rendering, workspaces, or main-app startup paths. +During the provisional naming period, release artifacts and helper names may be channelized, but operator docs and examples should use `warpctrl` unless an integration branch explicitly documents a channel-specific alias. +This branch wires the standalone binary target and the macOS/Linux bundle-script artifact selectors: +- `cargo build -p warp --bin warpctrl` +- `script/macos/bundle --artifact warpctrl ...` +- `script/linux/bundle --artifact warpctrl ...` +Windows has the native Rust binary target, but installer/release helper exposure remains follow-up packaging work. +## Install and invocation guidance +### macOS +Build locally with `cargo build -p warp --bin warpctrl`, then run `target/debug/warpctrl` or copy/symlink that binary onto `PATH`. +For distributable standalone artifact checks, use `script/macos/bundle --artifact warpctrl` with the desired channel/signing flags. The bundle script writes a standalone `warpctrl` binary into its macOS artifact output directory instead of embedding it in the GUI app bundle. +### Linux +Build locally with `cargo build -p warp --bin warpctrl`, then run `target/debug/warpctrl` or copy/symlink that binary onto `PATH`. +For distributable standalone artifact checks, use `script/linux/bundle --artifact warpctrl` with the desired channel/package selection. The Linux bundle script routes packaging through the standalone control-binary artifact path; downstream package installation should place the emitted `warpctrl` binary according to that package format. +Run `warpctrl --version` after installation to confirm the shell is resolving the expected build. +### Windows +Build locally with `cargo build -p warp --bin warpctrl`, then run `target\debug\warpctrl.exe` or copy that binary onto `PATH`. +The Windows-native binary target exists in this slice. Installer helper creation and release-artifact wiring still need a later packaging change before docs can promise an installer-provided `warpctrl` command. +## End-to-end local test flow +Use matching app and CLI bits from the same branch or release artifact so the protocol version and action catalog agree. 1. Start Warp and leave at least one window open. -2. Confirm the app registered a local-control instance: `warpctrl instance list`. -3. If exactly one compatible instance is listed, run `warpctrl tab create`. -4. If multiple compatible instances are listed, pass `--instance <instance_id>`. -5. Verify the selected app creates a new terminal tab according to Warp's normal behavior. +2. Confirm that the local-control server registered the running process: + ```bash + warpctrl instance list + ``` +3. If exactly one compatible instance is listed, create a new terminal tab: + ```bash + warpctrl tab create + ``` +4. If multiple compatible instances are listed, copy the desired `instance_id` and target it explicitly: + ```bash + warpctrl tab create --instance <instance_id> + ``` +5. Verify the running app receives focus for the selected instance and a new terminal tab appears according to Warp's normal new-tab placement behavior. +6. In a future slice that implements `tab list`, inspect state before and after the mutation: + ```bash + warpctrl tab list --instance <instance_id> + ``` Expected failures: -- no running compatible app: `no_instance`; -- multiple ambiguous instances: `ambiguous_instance`; -- disabled outside-Warp control: `local_control_disabled`; -- unsupported app build or stale discovery record: protocol, stale-target, or transport error; -- catalog entry without handler support: `unsupported_action`. -# Security model -The protocol is local same-user scripting, not cross-user or network control. -- Each Warp process exposes local control through loopback or an owner-only local socket. -- Control requests require scoped credentials. -- Discovery metadata is per user and does not grant broad authority by itself. -- Browser-origin JavaScript must not receive a permissive CORS path to control endpoints. -- External invocations are limited to logged-out-safe local-control actions. -- Authenticated actions require verified Warp-managed terminal invocation and the selected app's logged-in Warp user. -- `warpctrl` does not provide standalone secret-based authenticated external scripting. -# Documentation notes -- Use `warpctrl` as the executable name. -- Keep operator examples tied to implemented commands or mark catalog entries as stubs. -- Do not document excluded surfaces as usable commands. +- no running compatible app: exits non-zero with a no-instance error; +- multiple ambiguous instances: exits non-zero and asks for `--instance`; +- unsupported app build or stale discovery record: exits non-zero with a protocol, stale-target, or transport error; +- `tab.create` not yet implemented by the running app bridge: exits non-zero with an unsupported-action error. +## Security model +The local-control protocol is designed for same-user scripting, not cross-user or network access. The trust boundary is the local user account. +- **Loopback-only listener.** Each Warp process binds its control server to `127.0.0.1` on an ephemeral port. The listener is not reachable from the network. +- **Per-instance bearer token.** A random token is generated at startup and written into the discovery record. Every control request must present this token in the `Authorization` header; missing or invalid tokens are rejected with HTTP 401. +- **File-permission-gated discovery.** Discovery records are stored in a per-user local-control directory. On POSIX platforms, files must be created with `0600` permissions (owner read/write only). On Windows, records must be stored under the current user's app data directory with an ACL that grants access only to the current user, Administrators, and SYSTEM. Any same-user process that can read the credential can authenticate, so the baseline security boundary is same-user process isolation. +- **Stale-record pruning.** On each `instance list` or implicit discovery call, records whose PID is no longer alive are deleted automatically, preventing stale tokens from lingering on disk. +- **No CORS.** The control endpoints do not set permissive CORS headers, so browser-origin JavaScript cannot read responses even if it guesses the port. The bearer token requirement provides a second layer since browsers cannot read the discovery file. +```mermaid +sequenceDiagram + participant CLI as warpctrl + participant FS as ~/.warp/local-control/ + participant HTTP as Warp loopback server<br/>(127.0.0.1:ephemeral) + participant Bridge as App bridge + + CLI->>FS: Read discovery records (user-only permissions / ACL) + FS-->>CLI: instance_id, endpoint, auth_token + CLI->>CLI: Prune stale PIDs, select instance + CLI->>HTTP: POST /v1/control<br/>Authorization: Bearer <token> + HTTP->>HTTP: Verify token matches instance + alt Invalid or missing token + HTTP-->>CLI: 401 Unauthorized + else Valid token + HTTP->>Bridge: Dispatch action to app context + Bridge-->>HTTP: Structured result or error + HTTP-->>CLI: JSON response envelope + end +``` +**Known limitations and future hardening:** +- The token is stored in plaintext in the discovery JSON file. Any compromised process running as the same user can extract it. +- Tokens do not rotate or expire during a Warp session. A leaked token is valid until the process exits. +- Windows local-control authentication is not complete until discovery-record ACL creation and validation are implemented. +- Once higher-risk handlers land (e.g. `input.insert`, command execution), the same-user boundary becomes a code-execution trust boundary. Consider separating the token from the discovery metadata, adding per-request nonces, or switching to a Unix domain socket with `SO_PEERCRED` for kernel-verified caller identity. +## Documentation review notes +- Treat `warpctrl` as provisional executable naming until packaging signs off on final artifact aliases. +- Keep examples scoped to discovery and `tab create` until additional app-side handlers are implemented. +- Do not document catalog commands as usable just because they exist in protocol enums or parser scaffolding; operator docs should distinguish implemented commands from planned allowlist entries. +- Windows packaging may initially follow the existing helper-wrapper pattern rather than shipping a native standalone executable. Update this README when that decision is final. diff --git a/specs/warp-control-cli/SECURITY.md b/specs/warp-control-cli/SECURITY.md index 9b9c949c35..8922516064 100644 --- a/specs/warp-control-cli/SECURITY.md +++ b/specs/warp-control-cli/SECURITY.md @@ -1,125 +1,508 @@ # warpctrl security architecture -`warpctrl` is a local same-machine control CLI for already-running Warp app instances. This document is the normative security policy for the feature. PRODUCT.md defines user-facing scope; TECH.md defines implementation mechanics. If either conflicts with this document, update that document before implementation. -# Current stance -- `warpctrl` is the decided binary name. -- The full allowlisted catalog is the public contract, with per-action implementation status advertised by the running app. -- Authenticated actions require verified Warp-managed terminal invocation and a true logged-in Warp user in the selected app. -- External invocations are limited to logged-out-safe local-control actions. -- There is no standalone secret-based authenticated external scripting path. -- Accepted command submission, agent prompt submission, local filesystem data mutation, arbitrary internal dispatch, arbitrary ACL/public/external sharing, and network control transports are out of scope. -# Security goals -- Prevent unauthenticated localhost clients from invoking control actions. +`warpctrl` is a local-control CLI for an already-running Warp app instance. Its security architecture is designed to support the control catalog: discovery, structural metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, underlying data mutations, input-buffer staging, file/path app-state intents, Warp Drive operations, and explicitly authenticated execution-underlying actions. Accepted-command submission and agent-prompt submission remain future high-risk capabilities that require separate product/security review. Local file content operations are intentionally excluded from the public `warpctrl` catalog because native agent file tools are the preferred surface for file content reads and writes. +The correct architecture is not a single shared localhost bearer token with client-side conventions. The CLI, app bridge, and protocol must treat security as a local app-enforced capability system: discovery finds compatible instances, secure storage protects raw credential material, broker-issued credentials identify the granted scopes, the running Warp app's local-control bridge enforces action categories before dispatch, and target resolution never silently retargets a request. +The action-category model is primarily a safety and intent mechanism, not a hard security boundary against malicious same-user software. It lets a user, script, or agent intentionally request metadata-only, data-read, app-state mutation, metadata/configuration mutation, or underlying-data mutation access so it does not accidentally mutate state, expose sensitive content, or execute commands. It should not be described as strong access control against a process that can already run arbitrary commands as the user. +`warpctrl` has two distinct authorization dimensions: local-control authority and authenticated scripting authority. Local-control authority proves the request is allowed to control the local app. Authenticated scripting authority proves the Warp user or automation identity that is allowed to act on user-authenticated data such as Warp Drive objects, AI conversation traces, synced settings, cloud-backed user state, and execution-underlying actions. Logged-out users should retain a smaller local-only control surface, but authenticated-user and high-risk underlying-data mutation actions require either a verified Warp-terminal grant tied to the selected app's logged-in user or an external Warp-issued API-key grant. +## Current foundation status +The current foundation implementation supports outside-Warp local-control requests only. Verified inside-Warp invocation is specified as future work because the app-issued terminal-session proof broker, proof injection path, and session registry do not exist yet. Until those pieces land, `InvocationContext::InsideWarp` requests must be rejected with `execution_context_not_allowed`, implemented action metadata must not advertise inside-Warp support, and Settings > Scripting must not expose inside-Warp enablement or permission toggles. +## Security goals +- Allow trusted local users and approved automation to control a running Warp instance through a stable, scriptable interface. +- Prevent unauthenticated localhost clients from invoking read or mutating control actions. - Prevent browser-origin JavaScript from becoming an ambient localhost control client. -- Support multiple running Warp app instances without a shared global mutating port or global credential. -- Separate discovery metadata from control authority. -- Require explicit in-app enablement before outside-Warp local control can issue credentials or accept requests. -- Distinguish verified Warp-managed terminal invocation from external same-user invocation using app-issued proof, not caller-declared labels. -- Classify every action by state/data category, permission category, authenticated-user requirement, target scope, and allowed invocation context. -- Enforce those classifications in the selected Warp app before selector resolution or handler dispatch. -- Preserve deterministic targeting and fail on ambiguous, missing, stale, malformed, unsupported, or unallowlisted requests. -- Keep raw credential material, app auth tokens, terminal output, input text, Drive contents, and other sensitive data out of logs, errors, discovery records, completions, and generated docs. -# Trust boundaries -## OS-user boundary -Discovery records, sockets or endpoints, and credential references must be readable only by the owning OS user. This protects against other local users and network peers. It is not a complete defense against compromised same-user software. -## Invocation boundary -Same-user invocation does not imply the same authority. External clients receive only logged-out-safe grants. Verified Warp-managed terminal clients can request broader grants only after presenting app-issued proof and satisfying Scripting settings. -## Authenticated-user boundary -Actions that touch Warp-user-backed state require authenticated authority tied to the selected app's current logged-in user. The CLI never receives raw Firebase, OAuth, server, or cloud service tokens. If the app logs out or switches users, authenticated grants fail. -## Action boundary -Every action maps to one permission category. The bridge compares requested action metadata against the presented grant before resolving selectors. -## Target boundary -Credentials may be scoped to an instance, action family, target family, or resource. A grant for one instance or target must not authorize another. -# Invocation contexts -## Verified Warp-managed terminal -A `warpctrl` process started inside a Warp-managed terminal may present an app-issued execution proof. The proof must be bound to a live terminal/session, selected app instance, expiry, and revocation state. Environment variables may carry handles or hints, but caller-set variables are not authority by themselves. -Verified terminal context can raise the maximum eligible grant set. It does not bypass Scripting settings, Agent Profile policy, authenticated-user requirements, action categories, or target restrictions. -## External client -A `warpctrl` process started outside Warp, such as another terminal app, IDE, launch agent, or background script, is external. External control defaults off. When enabled, external clients can receive only logged-out-safe local-control grants and cannot receive authenticated-user grants. -# Enablement and settings -Warp owns a Settings > Scripting surface for local scripting controls. -Required settings behavior: -- Inside-Warp control is enabled only for verified Warp-managed terminal invocations. -- Outside-Warp control defaults off and requires an explicit user gesture. -- Enablement states are local-only and must not sync through Settings Sync, Warp Drive, or server-backed preferences. -- Public `warpctrl` commands, direct protocol requests, scripts, ordinary settings files, registry/plist/defaults edits, and cloud preferences must not be able to enable local control. -- Disabling a context invalidates existing credentials for that context. -- Granular permissions are independent for metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, and underlying data mutations. -The foundation implementation may use private local-only settings as an interim storage mechanism only when those settings are excluded from user-visible settings files, generated schemas, Settings Sync, Warp Drive, and local-control settings read/write actions. -# Credential model -The credential model is scoped and action-aware. A credential or grant records: -- issuing instance; -- protocol version; -- action or action family; -- permission category; -- invocation context; -- authenticated-user subject when present; -- optional target/resource restrictions; -- issue time, expiry, and revocation identity; -- integrity protection against widening. -Credential issuance is app-owned. The CLI can request, load, and present credentials, but it cannot mint authority. The bridge validates credentials again on every request. -# Discovery -Each participating Warp process publishes per-user discovery metadata for compatible local instances. Discovery records contain instance identity, PID, build/channel metadata, protocol version, and only the endpoint/credential-reference data allowed by the selected invocation context. -Discovery requirements: -- owner-only file or socket permissions; -- no raw broad credential in plaintext discovery records; -- stale record pruning by PID and health checks; -- no terminal contents, environment values, auth tokens, Drive contents, or sensitive target state; -- no actionable outside-Warp endpoint or credential reference when outside-Warp control is disabled. -# Transport protections -- Bind local control listeners to loopback or an owner-only local socket. +- Support multiple running Warp processes without a shared global mutating port or global credential. +- Separate discovery metadata from control authority so enumerating an instance does not automatically grant full control. +- Require explicit in-app user enablement before local control scripting from outside Warp can issue credentials or accept control requests. +- Allow local control scripting from verified Warp-managed terminal sessions by default, subject to granular permission settings. +- Store the authoritative enablement states in protected local storage so external apps cannot enable outside-Warp control by editing ordinary settings. +- Keep raw credential material out of plaintext discovery records and protect it with platform secure storage where available. +- Distinguish verified `warpctrl` invocations that originate from a Warp-managed terminal session from external same-user invocations. +- When outside-Warp control is enabled, allow external invocations only for a smaller local-only action set by default that does not touch user-authenticated data. +- Allow in-Warp invocations to receive authenticated-user grants when the selected Warp app has a true logged-in user and the user's local-control settings permit that grant. +- Support least-privilege safety modes for automation and interactive use without relying on an unenforceable identity label. +- Classify every action by state/data category and enforce the required permission category in the local app bridge, not in the CLI frontend. +- Classify every action by whether it requires an authenticated Warp user. New actions should default to requiring an authenticated user unless they are deliberately reviewed as safe for logged-out or external use. +- Prevent `warpctrl` from becoming an ambient full-power confused deputy that any same-user process can invoke for high-risk actions. +- Require authenticated scripting identity, underlying-data-mutation permission, explicit target resolution, and audit records before terminal command execution or typed workflow execution can ship; accepted-command submission and agent-prompt submission remain prohibited until separately reviewed. +- Preserve deterministic targeting so a request never silently mutates or reads the wrong window, tab, pane, session, file/path intent, or Warp Drive object. +- Keep the action surface allowlisted and typed rather than exposing arbitrary internal app dispatch. +- Make high-risk operations auditable and configurable without logging sensitive terminal contents or credentials. +## Meaningful security boundaries +The most important security boundary is preventing control from places that should have no ambient authority over the user's Warp instance: +- arbitrary web apps running in a browser; +- other OS users on the same machine; +- unauthenticated clients that discover or guess the localhost control port; +- stale discovery records from exited Warp processes; +- malformed or unallowlisted direct protocol calls. +The local-control design can provide meaningful protection for those cases by binding only to loopback, avoiding permissive CORS, requiring local credentials, keeping credentials out of browser-readable and world-readable locations, pruning stale records, and validating every request in the local Warp app process. +The boundary is much weaker for a different local app running as the same OS user. Same-user local apps may already have access to user-owned files such as logs, may be able to observe the screen or UI through OS permissions such as Accessibility or Screen Recording, and can often invoke user-installed command-line tools. `warpctrl` should not imply strong isolation from such software. +For same-user local apps, the realistic goal is narrower: +- do not leave a raw bearer token in plaintext discovery records; +- prevent arbitrary direct HTTP calls to the localhost control listener by requiring a credential those apps cannot simply read; +- use platform secure storage, such as macOS Keychain, so raw credentials are accessible only to Warp-owned signed code where practical; +- make high-risk operations go through `warpctrl` or a Warp-owned helper where user approval, configured policy, and safety grants can be applied; +- avoid giving `warpctrl` ambient non-interactive full-control authority. +In other words, the security model can make arbitrary direct localhost protocol calls fail, and it can make direct credential theft harder. It cannot make a same-user malicious app safe if that app can invoke `warpctrl`, automate the user's desktop, read other local state, or wait for the user to approve prompts. +## Comparison with other local scripting models +Other developer tools expose local automation through a few recurring patterns. The `warpctrl` design should borrow the parts that match Warp's needs while avoiding designs that assume localhost or same-user access is enough by itself. +### VS Code +VS Code's `code` command is primarily a launch and routing CLI: it opens files, folders, diffs, merge views, chat sessions, extension-management commands, and remote/tunnel workflows. It is not a general unauthenticated localhost API for arbitrary UI control of an already-running desktop app. +VS Code's richer local automation runs through extension APIs and extension hosts. Extensions are installed into a trusted editor environment and run with broad access to the workspace or UI side depending on extension kind. Workspace Trust and remote extension placement help users reason about whether code should run locally, remotely, or in a browser sandbox, but they do not create a fine-grained same-user security boundary against arbitrary local software. +Lessons for `warpctrl`: +- a narrow, typed CLI command surface is safer to reason about than exposing arbitrary internal app commands; +- agent and script workflows should request explicit capabilities instead of inheriting ambient full-control authority; +- local UI control should remain distinct from remote/tunnel control because remote transports need stronger identity, approval, and network-security semantics. +### Chrome DevTools Protocol +Chrome DevTools Protocol is a powerful debugging and automation API. When Chrome is launched with remote debugging enabled, clients can discover targets over local HTTP endpoints and then control the browser over WebSocket. That protocol is intentionally high-power: it can inspect pages, navigate, execute JavaScript, observe network state, and interact with browser storage. +Chrome's security history is a useful warning for `warpctrl`: a local debugging port is dangerous if it becomes reachable by unexpected clients. Recent Chrome versions restrict remote debugging against the default user data directory and recommend isolated user data directories for automation, because debugging a real browser profile can expose sensitive cookies and credentials. Chrome also distinguishes command-line remote debugging from user-confirmed debugging flows. +Lessons for `warpctrl`: +- loopback binding is necessary but not sufficient; +- unauthenticated localhost endpoints should not expose powerful state or mutation; +- browser-origin protections matter because web pages can attempt localhost requests; +- high-power automation should prefer explicit, isolated, user-approved, or short-lived authority over a reusable full-profile control channel. +### Ghostty and macOS AppleScript +Ghostty exposes platform-native scripting on macOS through AppleScript. That model relies on macOS Automation/TCC prompts to decide whether one app may control another app, and Ghostty can disable AppleScript entirely with configuration. This is a good fit for macOS-native scripting, but it is platform-specific and inherits the limits of OS automation permission: once an app is allowed to automate another app, the boundary is not a per-action capability system. +Ghostty also supports terminal-oriented features such as shell integration and command-line window creation flows. Those are useful local automation conveniences, but they are not a general cross-platform authenticated control protocol with scoped credentials. +Lessons for `warpctrl`: +- use platform security mechanisms where they exist, such as macOS Keychain and Automation prompts; +- keep a user-visible kill switch or policy path for scripting/control surfaces; +- do not rely only on platform automation permission if Warp needs cross-platform, action-scoped safety grants. +### iTerm2 Python API +iTerm2's Python API is a close comparison for terminal automation. The API is disabled by default. When enabled, iTerm2 listens on a Unix domain socket and requires authentication by default. Scripts launched by iTerm2 receive a random cookie in the environment, while external programs can request a cookie through AppleScript so macOS Automation permission mediates access. iTerm2 also documents an administrator-gated escape hatch to allow unauthenticated local apps. +This model directly acknowledges that terminal contents are sensitive and that any local automation API can affect local and remote hosts connected through terminal sessions. +Lessons for `warpctrl`: +- default-off or policy-controlled high-power automation is reasonable for sensitive capabilities; +- random local credentials are useful, but the path that grants or unwraps them is just as important as the token itself; +- underlying data reads and input/command execution should be treated as higher-risk than structural metadata reads; +- macOS Automation can be part of the approval path, but Warp still needs local app-side enforcement because direct protocol clients can bypass the official CLI. +### tmux +tmux is a useful lower-level comparison because its clients and server communicate through local sockets. The default socket lives in a per-user directory under `/tmp`, and that directory must not be world readable, writable, or executable. tmux control mode then exposes a text protocol where clients can issue normal tmux commands and receive asynchronous pane/session notifications. Newer tmux versions also have explicit server-access controls for sharing across users. +tmux's model is mostly an OS-user and socket-permission model. Once a client can access the socket with write authority, it can generally control the session. Read-only modes are useful operational guardrails but are not a reason to trust untrusted users or processes with the socket. +Lessons for `warpctrl`: +- per-user discovery directories and sockets protect meaningfully against other OS users; +- structured control protocols are scriptable and durable, but broad socket access quickly becomes broad control access; +- read-only and low-risk modes are valuable “do not accidentally interfere” controls, not a complete hostile-client sandbox. +### Overall direction for `warpctrl` +Compared with these systems, `warpctrl` should combine: +- tmux-style local filesystem/socket hygiene for protecting against other OS users; +- Chrome's lesson that local debugging/control endpoints need authentication and browser-origin hardening; +- iTerm2's use of explicit local credentials and macOS Automation-style approval for external control; +- Ghostty's use of platform-native scripting controls where available; +- VS Code's preference for typed public commands and separate treatment of remote control. +The resulting architecture should not claim to solve arbitrary same-user malicious-app isolation. Its real value is preventing web-origin and other-user access, preventing unauthenticated direct localhost calls, keeping raw credentials out of plaintext discovery, giving honest scripts and agents narrow safety grants, and routing high-risk operations through local Warp app validation and user/policy approval. +## Authoritative enablement model +This section describes the long-term model. The current foundation branch implements only the outside-Warp half of this model and rejects inside-Warp requests until app-issued Warp-terminal proofs are implemented. +Warp control has two top-level enablement states based on invocation context: +- **Allow scripting from inside Warp:** controls `warpctrl` invocations from verified Warp-managed terminal sessions. This should default to on so commands run inside Warp can use local control subject to granular permissions. +- **Allow scripting from outside Warp:** controls `warpctrl` invocations from external terminals, scripts, launch agents, IDEs, or other same-user processes. This must default to off. +Both controls should live in a new top-level Settings pane page named **Scripting**. The Scripting page owns the user-facing controls for local scripting surfaces, including Warp control, and should explain the difference between commands run inside Warp and commands run from other apps. +The visible UI settings are not enough by themselves. The authoritative enablement states must be stored in protected local storage that ordinary same-user apps cannot update by writing a plist, settings database row, registry key, JSON file, or synced cloud preference. This avoids turning outside-Warp control into a feature that any process can silently enable before invoking `warpctrl`. +Current foundation implementation note: outside-Warp enablement and granular permission bits are represented in the typed `LocalControlSettings` group as private, local-only settings. Each implemented setting must use `private: true`, `SyncToCloud::Never`, an explicit private storage key, and no `toml_path`, so it is excluded from `settings.toml`, the generated settings schema, Settings Sync, Warp Drive, and user-editable or server-backed settings surfaces. This private-settings path is an interim storage boundary, not the final protected-storage requirement; before public shipment, these authoritative bits must move to platform protected storage where available. +Enablement requirements: +- The settings are local-only and must not sync through Settings Sync, Warp Drive, or server-backed user preferences. +- The implemented foundation settings must remain private and absent from user-visible settings files, generated schemas, local-control settings read/write commands, and any allowlisted settings mutation catalog. +- Only the running Warp app, through the Settings > Scripting UI, should be able to enable or disable the authoritative states. +- `warpctrl`, shell scripts, config files, command-line flags, registry edits, defaults writes, and direct local-control protocol requests must not be able to enable either setting. +- The in-Warp setting may default to enabled, but turning it off should prevent verified Warp-terminal invocations from receiving local-control grants. +- The outside-Warp setting defaults to disabled and should require an intentional user gesture before enabling; the UI should explain that it allows scripts and automation from other apps to control Warp. +- The Scripting page should expose granular local-control permission settings for implemented invocation contexts rather than a single all-powerful switch. +- Each setting should be easy to disable from the same UI, and disabling either setting should revoke or invalidate active local-control credentials for that invocation context. +- If enterprise or managed-device policy is added later, policy may force-disable either setting or allow an administrator-controlled default, but policy should be separate from user-editable local settings. +Disabled-state behavior: +- Warp should not mint scoped local-control credentials for a request whose invocation context is disabled. +- The control listener should reject requests from disabled contexts with a structured disabled-state error before authentication, selector resolution, or handler dispatch. +- Discovery records should avoid publishing actionable endpoint or credential-reference metadata for disabled outside-Warp control. If a minimal record is needed for UX, it should expose only non-sensitive status such as `outside_warp_control_enabled: false`. +- `warpctrl` may detect a disabled context and print instructions to enable it in Settings > Scripting, but it must not offer a command that flips the setting. +- Previously issued credentials must become unusable when their invocation context is disabled, even if their original expiry has not elapsed. +These enablement gates do not create perfect same-user malicious-app isolation. A hostile process with Accessibility or Screen Recording permission might still try to automate the Warp UI. The outside-Warp gate is still important because it closes the much easier paths where external apps silently edit local preferences, call a config CLI, or write synced settings to enable a powerful control surface. +### Granular permission settings +Once the relevant inside-Warp or outside-Warp enablement setting allows a request context, users should control which categories of `warpctrl` authority can be granted. These permissions should appear under Settings > Scripting. Recommended independent permissions: +- **Metadata reads:** permit external and in-Warp clients to inspect non-sensitive local app structure and configuration metadata such as instances, windows, tabs, panes, app version, theme names, setting keys, action metadata, and Drive object IDs/names/types without content. +- **Underlying data reads:** permit reads of terminal output, scrollback, input buffers, command history, session traces, Warp Drive object contents, AI conversation content, and other content-bearing state. +- **App-state mutations:** permit local UI/layout/focus changes such as opening windows, creating tabs, closing tabs, focusing panes, splitting panes, opening panels, opening files/projects/views, and staging text in the input buffer without executing it. +- **Metadata/configuration mutations:** permit persistent metadata or configuration changes such as tab/pane names, tab colors, themes, font size, zoom, allowlisted settings, and keybindings. +- **Underlying data mutations:** permit Warp Drive object CRUD and personal-to-team sharing, AI conversation mutations, and any other allowlisted action that can change user data or cause external side effects. Terminal command execution and Warp Drive workflow execution belong in this category when their later authenticated branches implement them. Accepted-command submission and agent-prompt submission remain unavailable until separately reviewed. Local file content operations are intentionally excluded from the public `warpctrl` catalog and should use native file tools instead. +- **Authenticated-user actions from Warp terminals:** permit `warpctrl` invocations that originate from a verified Warp-managed terminal session to receive grants backed by the currently logged-in Warp user, enabling actions that read or mutate Warp Drive, AI conversation traces, synced settings, or other user-authenticated state. +- **Authenticated-user actions from external clients:** default off. If supported, this must be a separate explicit permission from in-Warp authenticated actions because external same-user processes are a weaker context than a Warp-managed terminal session. +Granular permissions should be independently configurable for inside-Warp and outside-Warp contexts where the distinction matters. Disabling any category should invalidate active credentials that include that category. The broker and app bridge must enforce these settings locally for every credential request and every presented credential. App-state mutation permission must not imply metadata/configuration mutation or underlying data mutation permission. +## Trust boundaries +`warpctrl` has several distinct trust boundaries. +### Operating-system user boundary +The baseline local trust boundary is the OS user account. Discovery records and local credential material must be readable only by the owning user. This protects against other local users and network peers, but it does not protect against an already-compromised same-user process. +### Invocation boundary +Same-user does not mean same authority. Interactive use and unattended automation may both run commands under the same user account, but they should be able to intentionally request narrower capabilities. The protocol needs scoped credentials that encode concrete grants, target scopes, and lifetimes rather than an abstract caller type that the bridge cannot reliably verify. +These scoped credentials are guardrails for well-behaved clients. They prevent accidental overreach and make user intent explicit, but they are not a defense against malicious same-user code that can automate the CLI, inspect the user's environment, or wait for user approvals. +### Warp-terminal execution context boundary +`warpctrl` should be able to receive special grants when it is invoked from within a Warp-managed terminal session, but the bridge must not trust a caller-supplied string such as `caller_class=warp_terminal`. The app should issue a session-bound execution-context proof to Warp-managed terminal sessions and have the broker verify that proof before minting in-Warp-only grants. +Acceptable designs include a short-lived per-session capability, an app-owned broker handshake tied to the terminal session, or an equivalent proof that arbitrary external processes cannot mint by setting an environment variable. Plain environment variables may be used as handles or hints, but they must not be the sole authority for in-Warp privileges because external processes can spoof them. +Verified in-Warp context can raise the maximum eligible grant set, especially for authenticated-user actions. It does not by itself bypass the user's granular local-control settings, action categories, target scopes, or logged-in-user requirements. +### Authenticated scripting boundary +Actions that touch user-authenticated Warp data or perform high-risk underlying-data mutations require authenticated scripting authority. This includes Warp Drive object contents, object mutation, the v0 personal-to-team sharing path, AI conversation traces, cloud-backed user settings, team/account data, typed workflow execution, terminal command execution, and any other surface whose normal app access depends on the user's Warp account or can cause external side effects. +There are two supported authenticated scripting modes: +- **Verified Warp-terminal mode:** `warpctrl` presents an app-issued terminal-session proof. If the selected app is logged into Warp and Settings > Scripting permits authenticated actions from verified Warp terminals, the broker may mint an authenticated-user grant tied to the selected app's current user subject. +- **External API-key mode:** `warpctrl` presents a Warp-issued scripting API key or a short-lived token exchanged from that key. If outside-Warp scripting and external authenticated grants are enabled, the broker verifies the key, scopes, expiry, revocation state, and user subject before minting a local authenticated-user grant. +For app-backed authenticated actions, the app bridge should execute on behalf of the selected app's logged-in user through existing app auth state. For explicitly API-key-backed actions, the API key subject and scopes must be recorded in the local grant and the handler must not export raw Firebase, server, OAuth, or cloud API tokens to shell scripts. If the selected app logs out, switches users, or no longer matches a grant that requires app-user identity, authenticated actions fail with structured errors rather than falling back to logged-out behavior. +Logged-out users may still use the smaller local-only action set explicitly marked as not requiring authenticated scripting authority. +### Authenticated scripting protocol +`warpctrl` should provide auth/status flows for both interactive app login and external API-key automation. The CLI must not collect Warp passwords and must not print or persist raw API keys outside approved secret storage. +Requirements: +- `warpctrl auth status [selectors]` reports whether the selected app instance is logged in, whether verified Warp-terminal authenticated grants are available, and whether an external API-key identity is configured. It may return stable, non-secret subject/scope metadata when the caller has the required grant. +- `warpctrl auth login [selectors]` focuses or opens the selected Warp app's normal sign-in UI and waits, or exits with actionable instructions, until the user signs in through Warp itself. +- `warpctrl auth api-key set --key-env <env_var>|--key-stdin [selectors]` stores or references a Warp-issued scripting API key in platform secure storage. Non-interactive scripts may provide the key through a secret-manager-injected environment variable. +- `warpctrl auth api-key status [selectors]` reports non-secret subject, expiry, and scope metadata for the configured API key. +- `warpctrl auth api-key revoke [selectors]` removes the local key reference and revokes the server-side key where supported. +- The credential broker may mint an app-user authenticated grant only after confirming the selected app has a true logged-in Warp user and the requested authenticated-user setting is enabled for the verified invocation context. +- The credential broker may mint an external API-key grant only after validating the key or exchanging it for a short-lived assertion, confirming that external authenticated grants are enabled, and checking that the key scope covers the requested action family and permission category. +- Authenticated credentials are bound to the selected instance, subject, grant mode, scopes, expiry, and optional target/resource restrictions. If the app logs out, switches users, loses authenticated state, or the presented credential subject no longer matches a grant that requires app-user identity, authenticated actions fail with `authenticated_user_mismatch` or another structured authenticated-user error. +- Raw Firebase, server, OAuth, cloud API tokens, and raw API keys must not be exported to `warpctrl` output, shell completions, generated docs, logs, discovery records, or local-control JSON responses. +Logged-out-safe actions continue to use local-control credentials without requiring authenticated scripting identity. +### Application identity boundary +On platforms with secure credential storage, especially macOS, the raw local-control credential should be readable only by Warp-owned, correctly signed code. On macOS this means storing raw credential material in Keychain with access constrained by Warp's signing identity, designated requirement, Keychain access group, or equivalent platform mechanism. This narrows token extraction from “any same-user process can read a file” to “only trusted Warp-signed code can unwrap the secret.” +This boundary protects the credential from direct theft and prevents arbitrary apps from making authenticated raw HTTP requests to the local-control listener. It also lets the authoritative enablement state be stored somewhere harder to modify than ordinary user preferences. It does not prove that the user personally intended the specific action. Any same-user process may still be able to invoke the trusted `warpctrl` binary or automate the Warp UI. That confused-deputy risk is reduced by explicit in-app enablement, scoped credential issuance, action-category policy, and local app-side bridge enforcement, but it is not eliminated as a hard same-user security boundary. +### Action boundary +Every action belongs to a state/data category. The bridge must map the requested action to a required permission category and compare that category to the presented credential before selector resolution or handler dispatch. +### Target boundary +A valid credential for one instance or target must not imply authority over another. Credentials should be bound to the issuing Warp instance and may be further scoped to target families such as terminal sessions, files, or Warp Drive objects when those surfaces are exposed. +## Threat model +### In scope +- Other local OS users attempting to control a Warp instance owned by the current user. +- Browser-origin JavaScript attempting to call localhost control endpoints. +- Same-user automation attempting actions without the required scoped grants. +- Same-user processes attempting to extract plaintext credentials from local state. +- Same-user processes invoking `warpctrl` as a confused deputy for actions the process could not authorize directly. +- External same-user processes attempting authenticated-user actions that should be limited to verified Warp-terminal invocations. +- Logged-out requests attempting actions that require a true logged-in Warp user. +- Stale discovery records from exited Warp processes. +- Multiple running Warp instances where ambiguous selection could target the wrong process. +- Malformed clients attempting unknown, unsupported, unallowlisted, or invalid action payloads. +- Valid clients attempting actions above their granted permission category. +- Explicit target IDs that become stale between discovery and execution. +- Future handlers that expose terminal data, settings writes, input mutation, command execution, file intents, or Warp Drive object operations. +### Out of scope +- A malicious process that already has arbitrary same-user filesystem and process access, except that scoped credentials should still reduce accidental over-granting to ordinary automation. +- Kernel, hypervisor, or administrator-level compromise. +- Security semantics for remote URL control endpoints. Remote control requires a separate transport and identity design before it can ship. +## Architecture overview +The full security model has eight layers. The current foundation branch implements the outside-Warp path and keeps the inside-Warp execution-context layer as a rejected future protocol concept until proof verification exists. +The security model has eight layers: +1. **Protected enablement:** Use protected local storage for separate inside-Warp and outside-Warp enablement states, with inside-Warp on by default and outside-Warp off by default. +2. **Discovery:** Find compatible live Warp instances without granting broad authority. +3. **Secure credential storage:** Store raw secrets outside plaintext discovery records and restrict access to trusted Warp-owned code where the platform supports it. +4. **Execution context verification:** Distinguish verified Warp-terminal invocations from external same-user invocations without trusting caller-declared labels. +5. **Credential issuance:** Issue scope-specific credentials with explicit grants and lifetimes only when the request's invocation context is enabled and the user's granular permissions allow the requested category. +6. **Transport authentication:** Reject disabled or unauthenticated requests before reading or mutating app state. +7. **Safety and user-auth policy:** Enforce permission categories, target scopes, execution-context requirements, and authenticated-user requirements locally in the app bridge. +8. **Deterministic dispatch:** Resolve targets exactly and invoke only allowlisted typed handlers. +```mermaid +sequenceDiagram + participant Invoker as User / Automation + participant CLI as warpctrl + participant Registry as Per-user discovery registry + participant Enablement as Protected enablement state + participant Context as Execution context proof + participant Broker as Credential broker + participant Store as Secure credential storage + participant Auth as App auth state + participant HTTP as Warp control listener + participant Bridge as App bridge + safety policy + participant UI as Warp app state + + Invoker->>CLI: Invoke allowlisted command + CLI->>Registry: Read instance metadata + Registry-->>CLI: instance_id, endpoint, protocol version, credential reference + CLI->>Enablement: Check inside/outside context enablement + Enablement-->>CLI: Enabled or disabled + alt Disabled + CLI-->>Invoker: context disabled; enable in Settings > Scripting + else Enabled + CLI->>Broker: Request scoped credential for action + Broker->>Enablement: Verify protected enablement state + Broker->>Context: Verify external vs Warp-terminal context + opt Authenticated-user action + Broker->>Auth: Verify logged-in Warp user + setting + Auth-->>Broker: User subject or unavailable + end + Broker->>Store: Load or unwrap raw secret with Warp-signed access + Store-->>Broker: Raw secret or credential capability + Broker-->>CLI: Scoped credential with grants, context, user scope, expiry + CLI->>HTTP: Authenticated typed request + HTTP->>Bridge: Verify credential and protocol envelope + Bridge->>Bridge: Check permission category + context + authenticated-user + target scope + alt Denied + Bridge-->>CLI: structured safety-policy error + else Allowed + Bridge->>UI: Resolve target exactly and run allowlisted handler + UI-->>Bridge: typed result or structured target error + Bridge-->>CLI: response envelope + end + end +``` +## Discovery registry +Each participating Warp process writes a discovery record in a secure per-user local-control directory. Discovery records are metadata, not a full control-authority model. +A discovery record should contain: +- opaque `instance_id`; +- PID and process start timestamp; +- channel and build metadata; +- protocol version and supported capability summary; +- loopback endpoint for the instance-local control listener; +- credential reference or bootstrap credential metadata, not necessarily the full control credential. +Discovery rules: +- Records must be readable only by the owning user. +- POSIX records must use owner-only permissions such as `0600` for files and a non-world-readable directory. +- Windows records must live under the current user's app data directory with ACLs limited to the current user, Administrators, and SYSTEM. +- When outside-Warp control is disabled, records must not publish actionable control endpoints or credential references for external clients. A minimal disabled-status record is acceptable only if it contains no authority. +- The CLI must prune or ignore stale records whose PID is gone or whose health/protocol check fails. +- If multiple compatible instances are ambiguous, the CLI must require explicit `--instance` selection. +- Discovery metadata must not expose terminal contents, environment variables, auth tokens for cloud services, raw local-control credentials, or mutating capability grants. +## Credential model +The full `warpctrl` catalog requires scoped credentials. A single shared full-power bearer token is not sufficient once automation, underlying data reads, app-state mutations, metadata/configuration mutations, and underlying data mutations are supported. +### Credential properties +A control credential should encode or reference: +- issuing Warp instance; +- protocol version or accepted version range; +- granted permission categories; +- verified execution context, such as external client or Warp-managed terminal session; +- whether the credential may act on behalf of an authenticated Warp user; +- authenticated Warp user subject or stable user reference when an authenticated-user grant is present; +- optional allowed action families; +- optional target restrictions, such as one session, one workspace, one file path, or one Warp Drive object type; +- issued-at time; +- expiry time or process-lifetime binding; +- unique credential ID for revocation and auditing; +- integrity protection so callers cannot forge or widen grants. +### Credential issuance +Warp should issue credentials through an app-owned local broker or equivalent trusted path. The broker decides which grants to issue based on the requested permission category, target scope, user configuration, execution context, and any explicit user approval. +Recommended defaults: +- Credential issuance is unavailable unless the protected enablement state allows the request's invocation context: inside Warp or outside Warp. +- Commands should start from least privilege and request only the grant needed for the requested action. +- External same-user invocations should default to the smaller logged-out-safe local action set unless policy or explicit approval grants more. +- Verified Warp-terminal invocations may receive broader local-control grants when the user's granular settings allow them. +- App-user authenticated grants are available only when the selected Warp app has a true logged-in Warp user and the requested execution context is allowed by local-control settings. External API-key authenticated grants are available only after key validation/exchange and only when external authenticated scripting is enabled. +- Metadata reads require an explicit `read_metadata` grant. +- Underlying data reads require an explicit `read_underlying_data` grant. +- App-state mutations require an explicit `mutate_app_state` grant. +- Metadata/configuration mutations require an explicit `mutate_metadata` or `mutate_configuration` grant. +- Underlying data mutations require an explicit `mutate_underlying_data` grant and should require approval or policy for unattended automation. +- User-authenticated data reads or mutations require an explicit `authenticated_user` grant and an allowed authenticated action family in addition to the data-category grant. +- Integrations should receive the narrowest grant needed for the configured workflow. +The broker must not issue broad authority merely because the request came from the signed `warpctrl` binary. It should evaluate the requested permission category, target scope, configured policy, execution context, and whether user approval is required. The CLI must not mint its own authority. It can request, load, and present credentials, but the app bridge remains the enforcement point for these safety grants. +### Safety grants, not strong access control +The category system should be understood as a user-intent and accident-prevention mechanism: +- A user can ask an agent or script to operate with metadata-read grants so it can inspect structure but cannot read terminal content or mutate state. +- A workflow can request underlying-data reads separately from structural metadata reads because terminal output, files, Drive object content, and AI conversations can contain sensitive data. +- A script can request app-state mutation without also receiving permission to change persistent settings, execute commands, mutate Warp Drive objects, or perform local file content operations. +- Metadata/configuration mutations can be allowed without granting underlying data mutation. +- Underlying data mutations can require explicit approval or configured policy so surprising operations pause before they execute commands or change user data. +This model does not make untrusted same-user software safe. A malicious local process may invoke `warpctrl`, simulate user workflows, or use other OS-level capabilities outside `warpctrl`. The category model is still valuable because it lets honest clients, agents, and scripts constrain themselves and gives Warp a structured point to prompt, deny, or audit risky actions. +### Credential storage +Credential storage should be platform-appropriate: +- Local discovery may store a credential reference rather than the credential itself. +- The authoritative local-control enablement states for inside-Warp and outside-Warp scripting should use the same class of protected local storage as raw credential material, but they should be accessible to the Warp app for the Settings > Scripting UI and not writable by `warpctrl` or arbitrary external apps. +- Raw long-lived credentials should prefer platform-secure storage such as macOS Keychain or Windows Credential Manager when practical. +- On macOS, raw control secrets should be stored in Keychain and restricted to trusted Warp-signed code using a designated requirement, Keychain access group, trusted-application ACL, or equivalent code-signing based mechanism. Restricting by filesystem path alone is insufficient because paths can be replaced or wrapped. +- Keychain item access should include the Warp app, the signed `warpctrl` binary, and any signed Warp-owned local broker/helper that needs to unwrap raw secrets. It should exclude arbitrary same-user applications. +- Short-lived credentials may be stored in owner-only local state if their lifetime and scope are narrow. +- Credentials must never be printed in human-readable output, JSON output, logs, errors, or shell completion data. +### Confused-deputy mitigation +Secure storage prevents arbitrary apps from reading the token; it does not prevent arbitrary apps from asking trusted Warp code to use the token on their behalf. +For example, if `warpctrl` can silently unwrap a full-power credential and execute any action, another same-user process can invoke `warpctrl input run ...` without reading the credential directly. That makes `warpctrl` a confused deputy. +Mitigations: +- Do not give `warpctrl` ambient non-interactive access to an unrestricted full-control credential. +- Prefer action-scoped or session-scoped credentials minted just in time by the broker. +- Require explicit user approval or preconfigured policy for underlying data mutations and other sensitive grants. +- Distinguish user-approved credential requests from ambient unattended invocations through explicit approval prompts, configured policy, terminal/session context, or narrow credential request flows. +- Bind issued credentials to the requested instance, permission category, optional action family, optional target scope, and short expiry. +- Let `warpctrl` preflight and request credentials, but require the local app bridge to enforce scopes because direct protocol clients can bypass the CLI. +- Make denials structured and non-fatal for automation so callers can request narrower or user-approved grants rather than falling back to unsafe behavior. +These mitigations are about routing high-risk operations through intentional `warpctrl` flows rather than exposing a reusable localhost token to any process. They should not be documented as a guarantee that arbitrary same-user applications cannot cause Warp-visible actions. +## Transport authentication +The default transport is an instance-local loopback listener bound to `127.0.0.1` on an ephemeral per-process port. +Transport requirements: +- Bind only to loopback for local control. - Do not set permissive CORS headers. -- Require valid scoped credentials for control requests. -- Use non-GET request methods for mutations. -- Keep unauthenticated health metadata minimal. -- Preserve structured error envelopes for security failures. -# Permission categories -## Metadata reads -Return local structure or non-content metadata, such as instances, app version, active chain, windows, tabs, panes, sessions, themes, setting keys, keybindings, action metadata, capability metadata, project identity, and Drive object IDs/names/types. -## Underlying data reads -Return user data without changing it, such as block output, terminal history, input buffer contents, Drive object contents, AI conversation content, and similar content-bearing state. -## App-state mutations -Change visible local Warp UI state without changing underlying user data, such as creating tabs, splitting panes, focusing targets, opening panels, opening files/projects/views, and staging input text without execution. -## Metadata/configuration mutations -Change persistent metadata or configuration, such as names, colors, themes, font size, zoom, keybindings, or allowlisted settings. -## Underlying data mutations -Can change user data, execute code, run approved workflows, or cause external side effects. This category includes `input.run`, Drive object create/update/delete/insert/share-to-team, and `drive.workflow.run`. It requires authenticated Warp-terminal authority plus explicit underlying-data-mutation permission. -# Authenticated-user requirements -New catalog actions default to authenticated-user required unless deliberately reviewed as logged-out-safe. Logged-out-safe actions are limited to local app structure, local appearance metadata, and UI/app-state operations that do not expose or mutate Warp-user-backed state. -Actions require authenticated user state when they read or mutate Warp Drive, AI conversation traces, synced settings, team/account data, cloud-backed user state, or user-authored runnable content. -# Deterministic target resolution -- Instance selection occurs before request dispatch. -- Multiple compatible instances require explicit selection unless there is one unambiguous active instance. -- Active selectors are allowed only when unambiguous. -- Explicit IDs resolve exactly or return `stale_target`. -- Missing targets return `missing_target`. -- Ambiguous selectors return `ambiguous_target`. -- Session-scoped actions against non-terminal panes return `target_state_conflict`. -- The bridge must not fall back to neighboring targets. -# Structured errors -Security- and safety-relevant errors include: -- `local_control_disabled` -- `unauthorized_local_client` -- `insufficient_permissions` -- `authenticated_user_required` -- `authenticated_user_unavailable` -- `execution_context_not_allowed` -- `ambiguous_instance` -- `ambiguous_target` -- `stale_target` -- `invalid_selector` -- `unsupported_action` -- `not_allowlisted` -- `invalid_params` -- `target_state_conflict` -- `missing_target` -- `no_instance` -The CLI must preserve these errors in human-readable and JSON output. -# Required controls for action families -Before an action family is advertised as implemented: -- The action exists in the typed catalog. -- Metadata declares state/data category, permission category, authenticated-user requirement, allowed invocation contexts, target scope, parameter spec, and result spec. -- The bridge enforces permission category and authenticated-user policy before selector resolution. -- Invalid, expired, revoked, insufficient, disabled, unsupported, and unallowlisted requests fail closed. -- Tests cover allowed and denied credential paths, authenticated-user denial, selector failure, and success behavior. -- Logs and errors avoid credentials and sensitive user data. -- Operator docs distinguish implemented actions from catalog stubs. +- Reject control requests when their inside-Warp or outside-Warp invocation context is disabled, even if the request presents an otherwise valid credential. +- Authenticate every control request locally in the selected Warp app process before selector resolution or action dispatch. +- Reject missing, malformed, expired, revoked, or invalid credentials with structured authentication errors. +- Keep unauthenticated health metadata minimal and non-sensitive. +- Preserve structured error envelopes so the CLI does not collapse security failures into generic transport errors. +Remote URL support is a separate future transport mode. It should not reuse the local same-user credential model without additional identity, encryption, replay protection, and remote approval/policy design. +## Logged-in user requirements +Local-control validation always begins with local protocol state: discovery records, secure local credential references, scoped safety grants, execution-context proof, protocol version, request shape, allowlisted actions, typed parameters, and deterministic target selectors. +Some actions additionally require authenticated scripting authority: either a true logged-in Warp user in the selected app or an external API-key-backed subject with sufficient scopes. The action allowlist must declare this explicitly with a `requires_authenticated_user` or equivalent authenticated-scripting requirement field. +Default rule for new actions: +- New actions require an authenticated Warp user unless the implementer deliberately classifies them as logged-out-safe. +- The logged-out-safe set should remain meaningfully smaller and limited to local app structure, local appearance metadata, and other surfaces that do not depend on the user's cloud-backed Warp identity. +- Actions that read or mutate Warp Drive, AI conversation traces, synced settings, team/account data, or other user-authenticated state must require an authenticated user. +- Actions that execute user-authored cloud-backed content, such as running typed Warp Drive workflows, require both authenticated scripting authority and the appropriate high-risk action category. Agent-prompt submission remains excluded until separately reviewed. +When an authenticated-user or authenticated-scripting action is requested: +- app-user mode requires the selected app to have an active logged-in Warp user; +- API-key mode requires a validated key or exchanged assertion with sufficient scopes, subject, expiry, and revocation state; +- the presented local-control credential must include an authenticated grant for that user or API-key-backed subject; +- the user's granular settings must allow authenticated actions for the verified execution context or external API-key mode; +- the app bridge should execute app-user actions through the app's existing authenticated state rather than exporting raw cloud auth credentials to `warpctrl`. +If these conditions are not met, the app returns a structured error. It must not fall back to logged-out behavior or silently omit user-authenticated data from a result that claims success. +## Safety policy model +Safety grants are enforced in the app bridge after transport authentication and before target resolution or handler dispatch. This provides consistent “do not accidentally do more than requested” behavior for honest clients, not a sandbox for hostile same-user code. +The bridge must: +1. Parse the typed request envelope. +2. Verify protocol version compatibility. +3. Authenticate the credential. +4. Determine granted permission categories, execution context, target scopes, and authenticated-user grants. +5. Map the requested action to a required permission category, action family, execution-context requirement, and authenticated-user requirement. +6. Check optional target-family restrictions. +7. Reject requests that exceed the credential's grants with `insufficient_permissions`. +8. Reject authenticated-user or API-key-backed actions without the required app-user login, API-key validation, scopes, or authenticated grant with a structured authenticated-user/API-key error. +9. Only then resolve selectors and invoke the allowlisted handler. +The CLI frontend may provide helpful preflight errors, but those checks are advisory. Local app-side bridge enforcement is mandatory because other tools can bypass the official CLI and speak the protocol directly. +## Action permission categories +Every action belongs to exactly one state/data category for permission enforcement. These categories describe risk and intended safety prompts; they are not a sandbox or a complete OS-level access-control model. +### Metadata reads +Return app structure, app state, or configuration metadata without exposing terminal content, file content, Warp Drive object content, AI conversation content, or other user data. +Examples: +- `instance list`, `app active`, `app version`, `app ping`; +- `window list`, `tab list`, `pane list`, `session list`; +- `theme list`, `setting list`, `keybinding list`, and action/capability metadata; +- Drive object listing that returns object IDs, names, and types but not content. +Default unattended credentials may include this category. +### Underlying data reads +Return user content or data-bearing state without mutating state. +Examples: +- pane output, scrollback, current input buffer, command history, session replay, or transcript reads; +- Warp Drive object content reads; +- AI conversation content reads. +This category is separate from metadata because content often contains secrets, source code, file paths, command output, customer data, and other sensitive information. +### App-state mutations +Change visible local Warp UI state without directly changing underlying user data. +Examples: +- creating, focusing, activating, moving, or closing windows, tabs, panes, or sessions; +- splitting, navigating, maximizing, or resizing panes; +- opening panels, palettes, files, projects, notebooks, and other user-facing surfaces; +- inserting, replacing, or clearing staged input buffer text without submitting or executing it. +### Metadata/configuration mutations +Change persistent metadata or configuration without directly mutating primary user content. +Examples: +- renaming tabs or panes; +- changing tab colors; +- theme, font, zoom, keybinding, and allowlisted settings writes. +This category should not authorize terminal command execution, Warp Drive CRUD, Warp Drive sharing, or local file content operations. +### Underlying data mutations +Can change user data, execute code, submit prompts, or cause external side effects. +Examples: +- terminal command execution through the explicit `input.run` action; +- typed Warp Drive workflow execution or other approved user-authored runnable content; +- Warp Drive object create/update/delete/insert operations; +- Warp Drive object sharing, limited in v0 to making a personal object available to the user's current team through an explicit `share-to-team` command; +- AI conversation history mutation or other cloud-backed content mutation. +This category requires authenticated scripting identity plus explicit user or policy approval for unattended automation and integrations. It must remain separate from app-state mutation so a client that can open or focus Warp UI cannot automatically execute commands, submit prompts, mutate Warp Drive content, share Drive objects, or perform local file content operations. Accepted-command submission and agent-prompt submission remain unavailable until separately reviewed even if future protocol names are reserved for them. +## Target scoping and deterministic resolution +Targeting is part of security. The protocol must not convert ambiguous or stale selectors into best-effort mutations. +Rules: +- Instance selection happens before request dispatch and must be explicit when ambiguous. +- `active` selectors may be ergonomic defaults only when the active target is unambiguous. +- If no active target exists for a mutating request, return `missing_target` or `invalid_selector`. +- Explicit opaque IDs must resolve exactly or return `stale_target`. +- Index selectors must resolve to concrete IDs before execution and must not race into a different target silently. +- Session-scoped requests against non-terminal panes return `target_state_conflict`. +- File selectors use paths and must remain distinct from opaque UI IDs. +- Warp Drive selectors must include object type and resolve by opaque ID for automation stability, with name/path lookup only as an interactive convenience. +Target restrictions in credentials should be checked before invoking handlers. For example, a credential scoped to one session must not read another session's output even if the CLI can discover that session ID. +## Allowlisted handlers +The protocol must not expose arbitrary internal app actions by string. +Each supported command requires: +- a typed protocol action; +- typed parameters; +- validation rules; +- a documented state/data category and permission category; +- a documented `requires_authenticated_user` value; +- a documented allowed execution context, including whether external clients can run it or whether it is limited to verified Warp-terminal invocations; +- local app-side safety-grant checks; +- deterministic target resolution; +- a handler that reuses existing user-visible app behavior where possible; +- typed success and error responses. +Adding a new action should be additive and reviewable: extend the protocol enum, implement validation, map the action to a state/data category, declare whether it requires an authenticated user, declare its allowed execution contexts, add a handler, and add tests for authentication, safety-policy denial, authenticated-user denial, selector failure, and success behavior. +## Browser and localhost protections +Loopback is not sufficient by itself because browsers can send requests to localhost. +Required protections: +- No permissive CORS on control endpoints. +- No JSONP or browser-readable fallback formats. +- Valid scoped credentials required for all sensitive endpoints. +- Credentials stored outside browser-readable locations. +- Preflight and error responses must not reveal credentials or sensitive target state. +- The protocol should avoid GET endpoints for mutating actions. +The control plane should assume a malicious webpage can guess common localhost ports and send blind requests. It should not be able to read discovery records or obtain credentials. +## Auditing and logging +High-risk action support should include auditability without leaking sensitive data. +Recommended audit fields: +- timestamp; +- instance ID; +- credential ID or grant profile; +- action name, state/data category, and permission category; +- target type and opaque target ID when safe; +- success or structured error code. +Avoid logging: +- bearer tokens or scoped credentials; +- terminal output; +- command text for command execution unless explicitly approved by policy in a future version that supports execution; +- agent prompt text; +- input buffer contents; +- Warp Drive object contents; +- environment variable values. +Error-level logs should be used only for conditions that need developer attention, not normal denied requests or user-caused selector failures. +## Security- and safety-relevant errors +Structured errors are part of the security contract. +Important errors include: +- `local_control_disabled` when the relevant inside-Warp or outside-Warp scripting context is disabled in Settings > Scripting or has been disabled after credentials were issued; +- `unauthorized_local_client` for missing, malformed, expired, revoked, or invalid credentials; +- `insufficient_permissions` for valid credentials that lack the requested permission category or target scope; +- `authenticated_user_required` when an action requires authenticated scripting authority but the credential lacks an authenticated-user or API-key-backed grant; +- `api_key_required`, `api_key_invalid`, `api_key_expired`, `api_key_revoked`, and `api_key_insufficient_scope` for external API-key scripting failures, or equivalent structured variants if consolidated under existing authenticated-user errors; +- `authenticated_user_unavailable` when the selected Warp app has no logged-in Warp user or cannot access the required authenticated user state; +- `authenticated_user_mismatch` when an authenticated-user credential is bound to a different user subject than the user currently logged in to the selected Warp app; +- `execution_context_not_allowed` when the action or requested grant is not allowed from the verified invocation context, such as an external client attempting an in-Warp-only authenticated-user action; +- `ambiguous_instance` when multiple compatible instances cannot be resolved safely; +- `invalid_selector` for malformed or unsupported selector syntax; +- `missing_target` when an active/default target does not exist; +- `stale_target` when an explicit target ID no longer exists; +- `unsupported_action` for actions not implemented by the selected instance; +- `not_allowlisted` for actions intentionally excluded from the public control surface; +- `invalid_params` for malformed parameters; +- `target_state_conflict` when the target exists but cannot support the requested action. +The app must not downgrade these failures into broader default actions, and the CLI must preserve structured server errors in both human-readable and JSON output. +## Required controls before full catalog expansion +Before shipping each action family, verify that these controls are implemented for that family: +- Local control scripting must be enabled for the request's invocation context before the action family can run; inside-Warp control defaults on and outside-Warp control defaults off. +- The authoritative enablement states live under Settings > Scripting, are protected from external writes, and are local-only rather than synced. +- The action has a documented state/data category and required permission category. +- The action has a documented `requires_authenticated_user` value. New actions default to `true` unless explicitly reviewed as logged-out-safe. +- The action documents allowed execution contexts and whether external clients may run it. +- The bridge maps the action to that permission category locally in the selected Warp app process. +- The credential model can express the required grant. +- The credential model can express authenticated-user grants and verified execution context requirements when needed. +- The handler checks optional target restrictions where relevant. +- Requests with invalid credentials or insufficient safety grants fail before selector resolution or mutation. +- Requests that require authenticated-user access fail unless the selected app has a true logged-in Warp user and the credential includes an authenticated-user grant. +- Ambiguous, missing, and stale targets return structured errors. +- Tests cover allowed, insufficient-permission, and denied credential paths. +- Logs and errors do not expose credentials, terminal contents, command text, or sensitive settings. +- Operator docs distinguish available commands from planned catalog entries. +- Initial public action-family docs and tests prove terminal command execution, workflow execution, accepted-command submission, and agent-prompt submission are not allowlisted; input-buffer staging never submits the buffer. +- Initial public action-family docs and tests prove local file content reads, writes, appends, deletes, and filesystem-content mutations are not allowlisted; file/path support is limited to opening visible Warp UI surfaces and listing files already open in Warp. +## Platform requirements +### macOS and Linux +Discovery files must be stored in a per-user directory with owner-only permissions. +On macOS, raw credential material and the authoritative local-control enablement states should live in Keychain, not in the discovery record or an ordinary preferences file. Keychain access should be constrained to Warp-owned signed binaries or helpers using code-signing based access control. The enablement states should be writable by the Warp app's Settings > Scripting flow and not writable by `warpctrl`. The discovery record should hold only metadata and a credential reference when the relevant inside-Warp or outside-Warp context is enabled. +On Linux, raw credentials and the authoritative enablement states should prefer platform-secure storage where available; otherwise short-lived scoped credentials may live in owner-only local state with strict file and directory permissions. If an enablement state falls back to owner-only local state, the weaker same-user protection should be documented. +Unix domain sockets with peer credential checks may be considered for stronger same-machine identity than bearer tokens alone. +### Windows +Discovery records and credential material must live under the current user's app data directory with ACLs restricted to the current user, Administrators, and SYSTEM. +The authoritative enablement states should use Credential Manager, DPAPI-backed protected storage, or an equivalent app-controlled protected store rather than normal registry settings that arbitrary same-user processes can write. +Windows support for authenticated local control should not be considered complete until the implementation creates, validates, and tests those ACLs and the protected enablement-state behavior for both inside-Warp and outside-Warp settings. +## Remote control is separate +The local architecture intentionally assumes same-machine, same-user control over a loopback listener. Future remote URLs must use a different security design that includes: +- transport encryption; +- remote identity and authentication; +- replay protection; +- explicit user or admin approval/policy; +- network exposure review; +- separate credential issuance from local discovery; +- remote-safe auditing and revocation. +Remote support should not be enabled by simply allowing `warpctrl` to point the existing local credential at an arbitrary URL. diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index 07c1f3a1a7..311ff9a29c 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -1,88 +1,611 @@ # Context -PRODUCT.md defines the public `warpctrl` product contract. SECURITY.md is the normative security policy. This document describes implementation mechanics for the shared protocol, catalog, app bridge, CLI, and validation flow. -# Current implementation baseline -The repository already has these local-control building blocks: -- `crates/local_control/src/catalog.rs` owns public action metadata. -- `crates/local_control/src/protocol.rs` owns wire envelopes, typed parameter/result payloads, and structured errors. -- `crates/local_control/src/selectors.rs` owns current window/tab/pane selector shapes. -- `crates/local_control/src/auth.rs` owns scoped credential request and grant types. -- `crates/local_control/src/discovery.rs` owns per-instance discovery records. -- `app/src/local_control/mod.rs` owns the app-side bridge/server skeleton. -- `crates/warp_cli/src/bin/warpctrl.rs` and `app/src/bin/warpctrl.rs` own standalone CLI entry points. -# Contract rules -- `ActionKind` serialized names are the canonical public protocol names. -- `ActionKind::ALL` contains only approved public actions. -- Excluded local filesystem mutation names and standalone secret-auth names may be listed as excluded constants, but they must not deserialize into `ActionKind`. -- `ActionMetadata` must include implementation status, state/data category, permission category, authenticated-user requirement, allowed invocation contexts, target scope, parameter spec, and result spec. -- Implemented foundation actions can advertise `OutsideWarp` only when logged-out-safe. -- Authenticated actions advertise `InsideWarp` only and require a verified Warp-managed terminal grant before execution. -# Canonical action name changes -Use these public names when porting handlers and tests: -- `app.inspect` -> `instance.inspect` -- `app.settings.open` -> `surface.settings.open` -- `app.command_palette.open` -> `surface.command_palette.open` -- `app.command_search.open` -> `surface.command_search.open` -- `app.warp_drive.open` -> `surface.warp_drive.open` -- `app.warp_drive.toggle` -> `surface.warp_drive.toggle` -- `app.resource_center.toggle` -> `surface.resource_center.toggle` -- `app.ai_assistant.toggle` -> `surface.ai_assistant.toggle` -- `app.code_review.toggle` -> `surface.code_review.toggle` -- `app.vertical_tabs.toggle` -> `surface.vertical_tabs.toggle` -- `pane.session.previous` -> `session.previous` -- `pane.session.next` -> `session.next` -- `appearance.font_size` -> `appearance.font_size.increase`, `appearance.font_size.decrease`, or `appearance.font_size.reset` -- `appearance.zoom` -> `appearance.zoom.increase`, `appearance.zoom.decrease`, or `appearance.zoom.reset` -- `appearance.set` -> `theme.set`, `theme.system.set`, `theme.light.set`, or `theme.dark.set` -Add these metadata/read names instead of using app-specific aliases: -- `capability.list` -- `capability.inspect` -- `action.list` -- `action.inspect` -- `block.inspect` -- `block.output` -- `drive.inspect` -- `drive.object.create` -- `drive.object.update` -- `drive.object.delete` -- `drive.object.insert` -- `drive.object.share_to_team` -- `drive.workflow.run` -# Shared protocol mechanics -The request envelope contains protocol version, request ID, target selector, action kind, and action parameters. The response envelope contains protocol version, request ID, and either success data or `ControlError`. -Selectors remain extensible. Current compiled selectors cover window, tab, and pane. Protocol payloads add Drive object IDs/types so Drive shards can share canonical parameter and result contracts without changing action names. -# App bridge mechanics -The local-control HTTP or socket handler runs off the UI thread. It must authenticate and deserialize requests, then schedule app-state work onto the main app context using the existing model-spawning bridge. The bridge must revalidate credentials, action metadata, invocation context, authenticated-user requirement, and target scope before resolving selectors or dispatching handlers. -Handler implementation order: -1. Decode request envelope. -2. Verify protocol version. -3. Authenticate credential. -4. Load `ActionMetadata` from the catalog. -5. Verify invocation context, permission category, authenticated-user requirement, and target/resource restrictions. -6. Validate action parameters. -7. Resolve selectors deterministically. -8. Dispatch only typed allowlisted handlers. -9. Return structured result or error. -# CLI mechanics -`warpctrl` should follow existing CLI conventions used by the repository's CLI tooling: -- clap-style noun subcommands; -- JSON and human-readable output modes; -- stable structured errors; -- generated or checked completions and reference docs from the catalog; -- no GUI initialization for ordinary CLI invocation. -CLI parser work must be derived from the catalog so names, help, completions, and docs do not drift from `ActionKind::ALL`. -# Security implementation notes -- Outside-Warp control defaults off. -- Inside-Warp credential requests are rejected until app-issued terminal proof verification is implemented. -- External clients cannot receive authenticated-user grants. -- Public settings read/write actions must not expose or mutate private local-control enablement settings. -- The bridge, not the CLI, is the enforcement point for action metadata and grants. -# Validation plan -Run the narrowest useful checks first: -- `git diff --check -- specs/warp-control-cli/PRODUCT.md specs/warp-control-cli/TECH.md specs/warp-control-cli/SECURITY.md crates/local_control/src/catalog.rs crates/local_control/src/protocol.rs crates/local_control/src/protocol_tests.rs` -- stale-language grep across `specs/warp-control-cli/*.md` for banned framing and auth-scope terms; -- `cargo check -p local_control` when the Rust toolchain is available; -- `cargo nextest run --no-fail-fast --workspace local_control::protocol_tests` when tests are available in the environment. -If a command is unavailable in a cloud shard, report it as skipped with the exact toolchain or environment blocker. -# Fan-out handoff -This shard establishes the dependency gate for other implementation shards. Other shards should port handlers and tests to the canonical names above, use `ActionMetadata` for permission enforcement, and avoid adding handlers for excluded surfaces. -Implementation branches must treat `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and `README.md` as contract-owned after this branch. If any spec correction is needed, land it on the contract/spec branch first, then propagate the resulting spec files forward unchanged. +`PRODUCT.md` defines a standalone local Warp control CLI binary, provisionally named `warpctrl`, with an allowlisted action catalog, deterministic addressing across multiple running Warp app processes, and an incremental implementation plan. +`SECURITY.md` is the normative security architecture for this feature. Implementation work must follow it for the top-level Settings > Scripting surface, protected enablement storage, granular permissions, discovery metadata, credential storage, scoped safety grants, verified execution context, authenticated-user requirements, localhost/browser protections, permission-category enforcement, deterministic target resolution, and local app-side validation. The long-term architecture includes separate verified inside-Warp and outside-Warp invocation contexts, but the current foundation implementation supports outside-Warp requests only and must reject `InvocationContext::InsideWarp` until the verified Warp-terminal proof broker lands. If this technical plan and `SECURITY.md` disagree, update the plan before implementing rather than treating the security architecture as optional follow-up work. +The existing app already has three relevant building blocks: +- `crates/http_server/src/lib.rs (7-61)` runs a native-only loopback Axum server on fixed port `9277`. +- `app/src/lib.rs (1993-2001)` registers that HTTP server in the native app and currently merges only installation-detection and profiling routers. +- `crates/app-installation-detection/src/lib.rs (15-60)` and `app/src/profiling.rs (208-242)` show the current local HTTP routes. They are narrow endpoints, not a general control plane. +Warp also already has the app-side behaviors the control API should reuse rather than reimplement: +- `app/src/terminal/view/action.rs (193-196)` defines split-pane terminal actions. +- `app/src/pane_group/mod.rs (4266-4360, 5377-5414)` shows pane creation/splitting semantics and how split events mutate pane layout. +- `app/src/workspace/action.rs (153-156)` defines the existing tab creation actions, including default and terminal-tab variants. +- `app/src/workspace/view.rs (21203-21244)` shows how user-visible default and terminal-tab actions are dispatched. +- `app/src/settings/theme.rs (9-82)` defines persisted theme settings. +- `app/src/themes/theme_chooser.rs (416-458)` shows persisted theme selection behavior. +- `app/src/workspace/action.rs (95-776)` is the largest existing inventory of user-visible workspace actions and informs the allowlist catalog. +- `app/src/workspace/util.rs (12-18)` defines `PaneViewLocator`, and `app/src/pane_group/pane/mod.rs (84-177)` defines serializable pane identifiers, both useful reference points for selector resolution. +- `app/src/uri/mod.rs (822-1093, 1166-1364)` demonstrates external intents being resolved into active windows/workspaces and dispatched into running app state. +The current Oz CLI build/distribution model is also directly relevant because the control CLI should follow the same standalone-artifact approach rather than relying on the Warp GUI executable to service ordinary shell invocations: +- `crates/warp_cli/src/lib.rs (88-188, 316-418)` defines the existing CLI/parser conventions and channel-specific command naming support. +- `app/src/lib.rs (631-746)` routes CLI invocations into CLI execution rather than GUI launch. +- `script/macos/bundle (353-735)` and `script/linux/bundle (157-294)` build standalone CLI artifacts with the `standalone` feature. +- `.github/workflows/create_release.yml (423-554, 660-858, 992-1276)` publishes macOS/Linux CLI artifacts. +- `script/windows/windows-installer.iss (235-263)` shows the current Windows helper-wrapper pattern for CLI access. +The most important constraint surfaced by this code is that the current fixed-port local HTTP server cannot be the entire solution for a multi-process control API. If multiple local Warp processes attempt to expose mutating routes through the same fixed port, only one can own it. The control design therefore needs explicit per-process discovery and addressing. +## 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: +- Long term, local control scripting has separate inside-Warp and outside-Warp enablement states. Inside-Warp control for verified Warp-managed terminal sessions can default on only after the app-issued proof broker exists; outside-Warp control for external terminals, scripts, IDEs, launch agents, and other same-user processes defaults off. +- In the current foundation slice, only outside-Warp enablement and permissions are implemented. Inside-Warp credential requests must be rejected and inside-Warp settings must not be exposed in the UI. +- In the long-term model, both controls live under a new top-level Settings pane page named **Scripting**. +- The authoritative enablement states are local-only, not Settings Sync'd, and stored in protected local storage rather than ordinary user-editable settings. +- The current foundation branch must mark all implemented outside-Warp local-control settings as `private: true` and `sync_to_cloud: SyncToCloud::Never`. They must not appear in the user-visible `settings.toml` file, generated settings schema, Settings Sync, Warp Drive, server-backed preferences, or any future `warpctrl settings` surface. +- `warpctrl`, direct protocol requests, shell scripts, config files, registry/plist edits, defaults writes, and server-backed preferences must not be able to enable either setting. +- Discovery records do not publish actionable endpoints or credential references for disabled outside-Warp control. +- Credential issuance is unavailable when the request's invocation context is disabled. +- Raw credential material is kept out of plaintext discovery records and stored in platform secure storage where available. +- The broker distinguishes verified Warp-terminal invocations from external invocations using an app-issued execution-context proof, not a caller-declared label. Until that broker exists, `InsideWarp` is a reserved protocol concept that must not receive credentials. +- External invocations default to a smaller logged-out-safe action set that does not touch user-authenticated data. +- Verified Warp-terminal invocations may receive authenticated-user grants only when the selected app has a true logged-in Warp user and local-control settings allow authenticated-user actions from Warp terminals. +- The app rejects disabled, unauthenticated, expired, revoked, insufficient-scope, unsupported, malformed, ambiguous, missing-target, and stale-target requests with structured errors. +- Every action has a documented state/data category and the app bridge enforces the required permission category locally before selector resolution or handler dispatch. +- Every action has a documented `requires_authenticated_user` value and allowed execution contexts. New actions default to requiring an authenticated user unless explicitly reviewed as logged-out-safe. +- Granular local-control settings under Settings > Scripting gate the maximum grants for metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, underlying data mutations, authenticated-user actions from Warp terminals, and authenticated-user actions from external clients. +- Permission categories are treated as user-intent and accident-prevention guardrails, not as strong same-user malicious-app isolation. +- Remote control remains out of scope for the local same-machine credential model. +The first implementation slice should include the protected enablement gate, credential issuance checks, and app-side permission-category enforcement even if the only mutating action initially implemented is `tab.create`. Shipping `tab.create` without the enablement and validation architecture would create the wrong foundation for the full catalog. +### 1. Protocol crate and stable envelope +Create a small shared protocol crate or equivalent shared module used by both the app server and standalone CLI client. It should define: +- Protocol version metadata. +- Discovery/health response types. +- Execution-context proof/request types for verified Warp-terminal invocations versus external invocations. +- Action metadata describing state/data category, required permission grant, `requires_authenticated_user`, allowed execution contexts, and target families. +- Selector types: + - `InstanceSelector` + - `WindowSelector` + - `TabSelector` + - `PaneSelector` + - `SessionSelector` + - `BlockSelector` + - `FileSelector` + - `DriveObjectSelector` +- Opaque protocol-facing ID newtypes for instance/window/tab/pane/session identifiers. +- Allowlisted `ControlAction` variants and typed parameter payloads. +- Success/error envelopes with stable machine-readable error codes. +The protocol should treat target IDs as opaque. The app may encode existing runtime identifiers internally, but the public wire contract should not require callers to understand `EntityId`, `PaneId`, or other implementation types. +Recommended selector variants: +- `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)`, `Index(u32)`. +- `BlockSelector`: `Id(BlockId)`. +- `FileSelector`: `Path { path, line, column }`. +- `DriveObjectSelector`: `Id(DriveObjectId)` or `Lookup { object_type, name_or_path }`. +Index selectors are resolved only within their parent selector context, so tab index resolution requires a resolved window and pane/session index resolution requires a resolved tab or pane. Title and name/path lookup selectors are ergonomic helpers for interactive use and must fail on ambiguity rather than choosing the first match. +Recommended top-level request shape for `tab.create`: +```json +{ + "protocol_version": 1, + "request_id": "client-generated-id", + "action": "tab.create", + "target": { + "window": "active" + }, + "params": {} +} +``` +Recommended response shape: +```json +{ + "ok": true, + "protocol_version": 1, + "request_id": "client-generated-id", + "instance_id": "opaque-instance-id", + "resolved_target": { + "window_id": "opaque-window-id", + "tab_id": "opaque-tab-id" + }, + "result": {} +} +``` +Error payloads should include stable codes defined in `SECURITY.md`, including `local_control_disabled`, `unauthorized_local_client`, `insufficient_permissions`, `authenticated_user_required`, `authenticated_user_unavailable`, `execution_context_not_allowed`, `ambiguous_instance`, `ambiguous_target`, `stale_target`, `invalid_selector`, `unsupported_action`, `not_allowlisted`, `invalid_params`, `target_state_conflict`, `missing_target`, and `no_instance`. +### 2. Per-process discovery instead of fixed-port-only routing +Keep the existing fixed-port HTTP behavior intact for installation detection/profiling compatibility. Add a separate local-control listener that follows the same native Axum/Tokio pattern but supports multiple local Warp app processes. +Recommended design: +- Each participating Warp process creates a random opaque `instance_id` at startup. +- Each process binds a loopback control listener on an ephemeral port or an app-managed available port. +- Each process writes a discovery record into a secure per-user Warp state directory. The record should contain: + - `instance_id` + - PID + - channel/build metadata + - control-listener endpoint + - protocol version + - start timestamp + - credential metadata or secure-storage references only when the relevant inside-Warp or outside-Warp context is enabled +- The CLI loads discovery records, removes or ignores stale records after health checks, and chooses an instance using the product selector rules. +- `warpctrl instance list` is a CLI-first projection of this discovery registry plus health responses. +When outside-Warp control is disabled, discovery must follow `SECURITY.md`: either publish no actionable local-control record for external clients or publish only a minimal disabled-status record with no endpoint authority or credential reference. +This design preserves the current `9277` behavior while avoiding cross-process port contention for the new control API. +### 3. Local authentication, enablement, and safety boundary +Mutating localhost routes should not copy the permissive CORS posture of `/install_detection`. +Recommended local trust model: +- No browser-readable CORS allowance on control endpoints. +- The relevant implemented Scripting setting must allow the request context before credentials are minted or sensitive control requests are accepted. In the current foundation branch that means outside-Warp only; future inside-Warp support must add its own verified setting gate. +- The authoritative enablement bit must live in protected local storage and must not be writable by `warpctrl` or ordinary same-user preference/config edits. +- Per-instance raw credential material must be kept out of plaintext discovery records and stored in platform secure storage where practical. +- The CLI may load or request scoped credentials through an app-owned broker/helper, but it must not mint authority itself. +- The broker verifies whether the invocation originated from a Warp-managed terminal session before issuing in-Warp-only grants. +- The broker issues authenticated-user grants only when the selected app has a true logged-in Warp user and the relevant local-control permission is enabled. +- The app rejects disabled-state, missing, malformed, invalid, expired, or revoked credentials before selector resolution or mutation. +- The app maps every action to a state/data category and rejects insufficient grants before selector resolution or mutation. +- The app maps every action to a `requires_authenticated_user` value and allowed execution contexts, rejecting mismatches before selector resolution or mutation. +- Health metadata exposed without credentials, if needed for stale-record pruning, must not reveal mutating capabilities, credentials, or sensitive target state. +This keeps the protocol local and scriptable without creating an ambient browser-to-localhost control surface. +Do not ship the first slice as a plaintext discovery bearer token, even for same-user human CLI use. The first slice is the foundation for underlying data reads, app-state mutations, metadata/configuration mutations, and underlying data mutations, so it must establish the protected enablement, credential storage, scoped grant, and app-side enforcement model from `SECURITY.md`. +### 4. Future verified Warp-terminal invocation context +The current foundation branch does not implement verified inside-Warp invocation. `InvocationContext::InsideWarp` and `ExecutionContextProof::VerifiedWarpTerminal` may remain in the shared protocol as reserved future concepts, but the credential broker must reject them until the proof broker described here exists. +Minimum implementable design: +- When Warp creates or Warpifies a terminal session, the app creates a high-entropy per-session capability and records verifier state in an app-owned terminal-session registry. +- The registry entry is bound to the selected app instance, terminal/session identifier, issuing process generation, expiry, and revocation state. +- The shell receives only proof material needed by `warpctrl`, such as an opaque handle plus a short-lived token or challenge-response input. Plain environment variables may carry handles or hints, but a caller-set variable must not be sufficient authority. +- `warpctrl` invoked from that terminal sends `InvocationContext::InsideWarp` and `ExecutionContextProof::VerifiedWarpTerminal` to `/v1/control/credentials` when it has proof material. Without proof material it must use `OutsideWarp`. +- The broker verifies the proof against the app-owned registry, including app instance, session liveness, expiry, revocation, and nonce or challenge binding before minting any inside-Warp scoped credential. +- The broker then checks Settings > Scripting and permission-category policy for the requested action. A valid proof raises the maximum eligible grant set; it does not bypass user settings, action metadata, authenticated-user requirements, target scopes, or bridge enforcement. +- The minted credential records `invocation_context: InsideWarp`, the granted permission category, expiry, instance, and any authenticated-user subject. The app bridge revalidates the grant and current app policy on every control request. +Hardened follow-ups can strengthen this minimum design by storing secret material in platform secure storage, exposing only opaque handles through the shell, using Unix-domain-socket or named-pipe peer-credential checks, binding proofs to broker challenges, and invalidating proofs on shell/session teardown, app logout, user switch, or Settings > Scripting changes. These hardening layers improve direct-token theft resistance, but they do not create a perfect security boundary against malicious same-user software with process, filesystem, Accessibility, or screen-observation access. +### 5. Authenticated scripting identity and API-key grants +The full control catalog includes Warp Drive data mutation and execution-underlying actions. Those actions require an authenticated scripting layer in addition to local-control credentials. Local-control credentials prove authority to call the local app; authenticated scripting credentials prove the Warp user or automation identity allowed to request user-backed or high-risk actions. +#### Inside-Warp authenticated scripting +For `warpctrl` launched inside a Warp-managed terminal, use the verified terminal proof broker from the previous section. When the proof is valid and the selected app is logged into Warp, the broker may mint an authenticated-user grant bound to the app's current user subject. The grant is available only if Settings > Scripting enables authenticated-user actions for verified Warp terminals and the requested action's permission category is enabled. +The CLI must not receive raw Firebase, OAuth, server, or session tokens. The app bridge executes authenticated actions through the selected app's existing auth state and rejects the grant if the app logs out, switches users, or the grant subject no longer matches the app user. +#### External API-key authenticated scripting +For `warpctrl` launched outside Warp, by cron, or by another pure scripting environment, introduce a separate API-key path. The user creates or supplies a Warp-issued scripting API key with explicit scopes such as local-control authenticated reads, Drive mutation, or execution-underlying actions. The CLI may reference the key from a secret manager or environment variable such as `WARPCTRL_API_KEY`, or store it in platform secure storage through `warpctrl auth api-key set --key-stdin`; it must never print or write the raw key to discovery records, logs, JSON output, shell completions, or repo config. +The local broker exchanges or validates the API key with Warp services, obtains a short-lived signed identity assertion, and mints a local authenticated-user grant only when all of the following hold: +- outside-Warp scripting is enabled; +- external authenticated-user grants are enabled separately from logged-out outside-Warp control; +- the API key is valid, unexpired, unrevoked, and scoped for the requested permission category and action family; +- the selected app is logged into the same Warp user subject, or the action is explicitly designed to use API-key-backed identity without exporting app cloud tokens; +- the requested local-control permission category is enabled; +- any resource or target restrictions in the key and grant are satisfied. +The grant should record the API-key subject, scopes, credential ID, expiry, invocation context, permission category, and optional target/resource restrictions. The app bridge revalidates the grant before selector resolution and handler dispatch. +#### Auth command surface and storage +Add CLI and broker support for: +- `warpctrl auth status [selectors]` to report selected app login state, configured API-key subject metadata, and available authenticated grant modes without exposing secrets. +- `warpctrl auth login [selectors]` to focus the selected app's normal sign-in UI for interactive app login. +- `warpctrl auth api-key set --key-env <env_var>|--key-stdin [selectors]` to store or reference an external scripting key. +- `warpctrl auth api-key status [selectors]` to show key subject/scope metadata. +- `warpctrl auth api-key revoke [selectors]` to delete the local reference and revoke the server-side key where supported. +Store raw API keys only in platform secure storage where available. Environment-variable use is allowed for non-interactive automation, but commands and docs should prefer secret-manager injection over plaintext shell profiles. +### 6. App-side request bridge onto the UI/application context +The HTTP handler runs on a Tokio runtime thread owned by the local-control server. It cannot directly access or mutate Warp's UI models, views, or app context because all WarpUI state is single-threaded and owned by the main app event loop. The bridge solves this by sending a closure from the Tokio handler thread to the main thread, executing it in the model's context, and returning the result to the waiting HTTP handler. +#### Thread model +- **Tokio runtime thread (HTTP handler):** Owns the Axum router, receives HTTP requests, authenticates, deserializes the `RequestEnvelope`. Cannot touch `AppContext`, views, or models. +- **Main app thread:** Owns all WarpUI entities (`App`, `AppContext`, views, models). All UI state reads and mutations must happen here. +- **Bridge:** Transfers a typed closure from the Tokio thread to the main thread, executes it with `&mut ModelContext`, and sends the return value back. +#### Implementation: `ModelSpawner` +The bridge uses WarpUI's `ModelSpawner<T>` mechanism, which is the standard way for background threads to schedule work on a model's main-thread context: +1. During app initialization, a `LocalControlBridge` singleton model is created. The model's `ModelContext::spawner()` method returns a `ModelSpawner<LocalControlBridge>` — a cloneable, `Send` handle that can enqueue closures from any thread. +2. The `ModelSpawner` is stored in the Axum router's shared state (`ControlServerState`), making it available to every HTTP handler. +3. When an HTTP request arrives, the handler calls `spawner.spawn(|bridge, ctx| { ... }).await`: + - `spawn` sends a boxed `FnOnce(&mut LocalControlBridge, &mut ModelContext<LocalControlBridge>) -> R` closure through an `async_channel` to the main thread's task-callback loop. + - The main thread dequeues the closure, constructs a fresh `ModelContext` for the bridge model, and calls the closure. + - Inside the closure, the bridge has full access to `ModelContext`, which derefs to `AppContext`. This means it can call `ctx.windows()`, `ctx.views_of_type::<Workspace>(window_id)`, `workspace.update(ctx, ...)`, and any other main-thread API. + - The closure returns a typed result (e.g., `ResponseEnvelope`), which is sent back to the Tokio thread via a `oneshot` channel. +4. The HTTP handler awaits the oneshot result and serializes it as the HTTP response. +#### Concrete flow for `tab.create` +``` +HTTP handler (Tokio thread) + │ + ├─ verify inside-Warp or outside-Warp context is enabled + ├─ verify credential, execution context, safety grant, and authenticated-user grant + ├─ deserialize RequestEnvelope + ├─ call bridge_spawner.spawn(move |bridge, ctx| { + │ bridge.handle_request(request, ctx) // runs on main thread + │ }).await + │ + └─ serialize ResponseEnvelope as JSON + +LocalControlBridge::handle_request (main thread) + │ + ├─ verify protected context-specific enablement state is still enabled + ├─ map action to required permission category + ├─ map action to authenticated-user and execution-context requirements + ├─ verify presented credential grants that category, target family, execution context, and authenticated-user access + ├─ match request.action.kind + │ └─ ActionKind::TabCreate + │ ├─ validate_tab_create_target(&request.target) + │ ├─ ctx.windows().active_window() + │ │ └─ if none: return invalid_selector / missing_target + │ ├─ ctx.views_of_type::<Workspace>(window_id) + │ └─ workspace.update(ctx, |workspace, ctx| { + │ workspace.handle_action( + │ &WorkspaceAction::AddTerminalTab { hide_homepage: false }, + │ ctx, + │ ) + │ }) + │ + └─ return ResponseEnvelope::ok(request_id, json!({ ... })) +``` +#### Why this pattern +- **Thread safety.** WarpUI's entity/view system is not `Send` or `Sync`. The only safe way to interact with it from a background thread is through `ModelSpawner`, which serializes access through the main event loop. +- **Synchronous result.** Unlike fire-and-forget patterns (e.g., URI intent dispatch in `app/src/uri/mod.rs`), the `spawn` call returns a concrete `Result<R, ModelDropped>`, so the HTTP handler can produce a structured success or error response. +- **Reuses existing infrastructure.** `ModelSpawner` is already used throughout the codebase for background-to-main-thread communication (e.g., async file I/O results, network responses). No new concurrency primitive is needed. +- **Action dispatch reuses existing app behavior.** The bridge calls `workspace.handle_action(&WorkspaceAction::AddTerminalTab { ... }, ctx)` — the exact same method the UI keybinding system uses. This ensures the control CLI produces identical behavior to the corresponding user action, including side effects like tab count updates, focus changes, and event emissions. +- **Deterministic targeting.** The bridge must not silently fall back from the active window to an arbitrary ordered window for mutating actions. If the caller relies on the default active selector and no active window exists, return a structured missing-target or invalid-selector error. If future command forms allow explicit window IDs, resolve the explicit ID exactly or return `stale_target`. +#### Adding new action handlers +To add a new action to the bridge: +1. Add a variant to `ActionKind` in `crates/local_control/src/protocol.rs`. +2. Document its `SECURITY.md` state/data category, required permission grant, `requires_authenticated_user` value, and allowed execution contexts. +3. Add a match arm in `LocalControlBridge::handle_request` in `app/src/local_control/mod.rs`. +4. Before selector resolution or dispatch, verify local control is enabled and the presented credential grants the action category, target family, execution context, and authenticated-user access if required. +5. Inside the match arm, use `ctx` (which is a `&mut ModelContext<LocalControlBridge>` that derefs to `&mut AppContext`) to resolve selectors and dispatch the action onto existing app types. +6. Return a `ResponseEnvelope::ok(...)` or `ResponseEnvelope::error(...)` with the result. +The bridge closure has access to the full `AppContext` API surface, including `ctx.windows()`, `ctx.window_ids()`, `ctx.views_of_type::<T>(window_id)`, `handle.update(ctx, ...)`, and `handle.read(ctx, ...)`. This makes it straightforward to wire new actions to existing UI behavior without introducing new concurrency concerns. +### 7. Target resolution model +Implement target resolution as a reusable component rather than scattering lookup logic across handlers. +Recommended resolution order: +1. Select instance in the CLI/discovery layer. +2. Resolve window inside the target process. +3. Resolve tab within the window. +4. Resolve pane within the tab/pane-group context. +5. Resolve session only for session-scoped commands. +6. Resolve block/file/Drive selectors only for commands whose action metadata declares that target family. +Selector behavior: +- `active` resolves from current app focus/selection state. +- Explicit opaque IDs must resolve exactly or return `stale_target`. +- Index selectors are allowed only for user-visible indexed concepts and should resolve to a concrete opaque ID before execution. +- Title, name, and path selectors are convenience selectors. They must be exact by default, document any future fuzzy behavior explicitly, and return `ambiguous_target` when more than one target matches. +- A session-scoped request against a non-terminal pane returns `target_state_conflict`. +Target resolution must happen after protected enablement, authentication, and safety-grant checks. This prevents denied requests from learning more target state than necessary and keeps enforcement centralized. +Implementation references: +- Window-level active selection already exists inside the app through `WindowManager`. +- Pane scoping can build on the conceptual model of `PaneViewLocator` in `app/src/workspace/util.rs (12-18)`. +- Existing URI intent routing in `app/src/uri/mod.rs (895-1093)` shows how to locate workspaces/windows and avoid silently acting in the wrong place. +#### CLI selector grammar +`crates/warp_cli/src/local_control.rs` should expose a shared selector argument group that is flattened into every command that accepts app targets. The parser must support: +- Instance selectors: `--instance <instance_id>` and `--pid <pid>`, with clap conflicts. +- Window selectors: `--window <active|id:<id>|index:<n>|title:<title>>`, `--window-id <id>`, `--window-index <n>`, and `--window-title <title>`, with one form allowed. +- Tab selectors: `--tab <active|id:<id>|index:<n>|title:<title>>`, `--tab-id <id>`, `--tab-index <n>`, and `--tab-title <title>`, with one form allowed. +- Pane selectors: `--pane <active|id:<id>|index:<n>>`, `--pane-id <id>`, and `--pane-index <n>`, with one form allowed. +- Session selectors: `--session <active|id:<id>|index:<n>>`, `--session-id <id>`, and `--session-index <n>`, with one form allowed. +- Block/file/Drive selectors only on commands that need them: `--block-id <id>`, path arguments or `--path <path>` plus `--line`/`--column`, and Drive object ID arguments or `--drive-id <id>`. +The CLI converts these flags into the protocol `TargetSelector` before sending the request. It must not rely on positional entity IDs for commands like `window close 1`; target entities are selected through the shared selector flags so command arguments remain reserved for action parameters. +### 8. Allowlisted handler families +Use one handler module per action family. The protocol layer owns parsing/validation; handler modules own target resolution and delegation to existing app logic. +Recommended modules/families: +- Discovery/state: + - instances, version, active chain, windows/tabs/panes/sessions listings. +- Window/tab: + - new, focus, close, activate, move, rename, color, close variants. +- Pane: + - split, focus, navigate, close, maximize, resize. +- Input/session: + - insert, replace, clear, run command, cycle session, mode switch where supported. +- Appearance/settings: + - theme list/set, system-theme controls, font/zoom actions, allowlisted settings reads/writes/toggles. +- Panels/surfaces: + - settings/page/search, palettes, left/right panels, Drive, resource center, code review, vertical tabs, AI assistant. +- Files/projects: + - app-state-only path opening, project opening, and metadata reads for files already open in Warp. File content reads and filesystem-content mutations are intentionally excluded from the public `warpctrl` catalog. +- Warp Drive: + - object listing/inspection/opening, object creation/update/delete/insert, opening the share dialog, the v0 personal-to-team share mutation, and typed workflow execution where supported. +Do not use a generic “dispatch action by string” endpoint. Every handler should be reachable only through an explicit `ControlAction` variant. +#### WarpCtrlBehavior review gate +The public `ControlAction` catalog remains the source of truth for the wire protocol, CLI parser, permission metadata, generated documentation, and app bridge handlers. Internal app actions such as `WorkspaceAction`, `TerminalAction`, `PaneGroupAction`, settings actions, and future user-visible action enums must not become the public protocol directly because they can contain transient view locators, indices, debug-only variants, implementation-specific payloads, and unstable internal semantics. +To prevent drift between user-visible Warp behavior and the `warpctrl` catalog, every user-visible app action enum should implement a `WarpCtrlBehavior` review mapping. The mapping is a code-level forcing function, not an automatic exposure mechanism. It answers whether each internal app action is: +- `Exposed` through a specific public `ControlAction` kind. +- `CoveredBy` an existing public `ControlAction` kind because several internal actions map to one stable CLI behavior. +- `Excluded` with an explicit reason such as debug-only, unsafe/privileged, internal implementation detail, not user-visible, no deterministic targeting model, no stable public semantics, or prohibited in the initial public version. +- `Deferred` with an explicit reason and tracking issue when the action might belong in `warpctrl` later but needs additional product, security, selector, or protocol design. +`WarpCtrlBehavior` implementations must use exhaustive matches without wildcard arms. Adding a new variant to a reviewed action enum should fail compilation until the developer or agent deliberately classifies its relationship to `warpctrl`. This mirrors the existing exhaustive-action-review style used by app-state saving decisions and makes “should this exist in Warp Control?” part of the ordinary code path for adding new user-visible actions. +Recommended shape: +- Define a shared `WarpCtrlBehavior` trait in the local-control integration layer or another app-visible module that does not force the core `warpui::Action` blanket implementation to change. +- Define review enums such as `WarpCtrlActionReview`, `WarpCtrlExclusionReason`, and `WarpCtrlDeferredReason`. +- Implement `WarpCtrlBehavior` for the major user-visible action enums, starting with `WorkspaceAction` and `TerminalAction`. +- Keep the mapping one-way from internal behavior to public catalog metadata. `WarpCtrlBehavior::Exposed(ControlActionKind::TabCreate)` means the action is represented by the public `tab.create` command; it does not mean raw `WorkspaceAction::AddTerminalTab` is serializable or dispatchable over the protocol. +- Add tests that collect reviewed action kinds and verify every `ControlActionKind` has protocol metadata, permission metadata, CLI parser coverage, generated-doc coverage, and an app-side handler before it can be advertised as supported. +The `warpui::Action` trait should not be extended for this purpose because it currently has a blanket implementation for any `Any + Debug + Send + Sync` type. The enforcement point is the concrete user-visible action enums and binding/action registration surfaces, where exhaustive review can be required without weakening the allowlisted protocol boundary. +### 9. First slice: prove discovery and `tab.create` +The first `warpctrl` implementation slice should land the minimum cross-cutting architecture plus a single representative tab mutation: +- Shared protocol types and error envelopes. +- `FeatureFlag::WarpControlCli` and Cargo feature `warp_control_cli`, with app-side runtime gating for settings, discovery, bridge registration, and local-control endpoints. +- New top-level Settings > Scripting page rendered only while `FeatureFlag::WarpControlCli` is enabled. The current foundation exposes outside-Warp local-control settings only; verified inside-Warp controls are deferred until the proof broker exists. +- Protected local-only enablement storage where outside-Warp control defaults off. Future inside-Warp enablement must use the same protected-storage class before it is exposed. +- As an interim foundation step, the outside-Warp top-level enablement and granular permission bits live in the typed `LocalControlSettings` group as private settings with `SyncToCloud::Never`, explicit private storage keys, and no `toml_path`. This keeps them out of the user-visible settings file and generated settings schema while leaving the protected-storage migration as a required pre-ship hardening step. +- Granular outside-Warp local-control permission storage under Settings > Scripting for metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, and underlying data mutations. Future inside-Warp permissions should be added only with verified terminal proof support. +- Discovery registry and CLI instance selection. +- A standalone `warpctrl` binary or artifact path that runs control commands without starting the GUI app runtime. +- Per-process authenticated local-control server that refuses sensitive work when outside-Warp control is disabled and rejects inside-Warp credential requests until verified terminal proof support is implemented. +- Scoped credential issuance/storage with no raw credentials in plaintext discovery records, including execution-context fields and authenticated-user grant fields. +- App-side request bridge and selector resolver. +- Action-category mapping and app-side safety-grant enforcement. +- Action metadata for `tab.create` that deliberately classifies it as a logged-out-safe app-state mutation only when the user's granular local-control settings allow app-state mutation. +- Read-only `ping/version` plus `warpctrl instance list` or equivalent minimal discovery command. +- End-to-end `warpctrl tab create` for the selected instance, reusing the same app behavior as the user-visible new-terminal-tab action. +Why `tab.create` first: +- It proves a UI/layout action can be targeted and executed against live app state. +- It exercises process discovery, local authentication, request bridging, selector defaults, app-context dispatch, and structured success/error output without introducing higher-risk terminal input execution. +- It exercises the protected enablement and scoped-grant model before higher-risk action families depend on it. +- It gives operators a concise end-to-end smoke test: discover a running instance, create a tab, and confirm the live app changed. +The PR should also introduce the shell-facing CLI command grammar that the remainder of the protocol will reuse and establish a lightweight CLI startup path distinct from GUI startup. +### 10. Follow-up slices: fill out the remaining protocol in parallel +After the first slice validates discovery, auth, selector resolution, CLI syntax, and server-to-app execution, follow-up slices can add the remaining allowlisted catalog in parallelized action-family groups. The baseline code should make new action additions mostly additive: +- Extend `ControlAction`. +- Update the relevant `WarpCtrlBehavior` mappings for the internal app actions that implement, overlap with, exclude, or defer the behavior. +- Add typed params/results. +- Add a handler. +- Add validation/tests. +- Add CLI surface/tests. +### 11. CLI parsing and output libraries +The `warpctrl` CLI must use the same argument parsing and output libraries as the existing Oz CLI so that conventions, derive patterns, and shell-completion generation remain consistent across both binaries. +- **clap** (with the `derive` feature) for argument parsing, subcommand trees, and help generation. Both binaries share the `warp_cli` crate, so parser types defined there are reused directly. +- **serde** / **serde_json** for JSON request/response serialization and for `--output-format json` output. +- **clap_complete** for shell completion generation, reusing the same infrastructure the Oz CLI uses. +- The `OutputFormat` enum (`Pretty`, `Json`, `Ndjson`, `Text`) is shared from `warp_cli::agent::OutputFormat` so human-readable vs. machine-readable output follows the same conventions. +- New subcommand types for `warpctrl` live in `warp_cli::local_control` and follow the same `#[derive(Parser)]` / `#[derive(Subcommand)]` / `#[derive(Args)]` patterns used by the Oz CLI's top-level `Args` and `CliCommand` types. +Do not introduce alternative parsing libraries (e.g., `structopt`, `argh`) or alternative serialization approaches. Keeping one set of libraries across both CLIs reduces dependency weight, ensures consistent `--help` formatting, and lets contributors move between the two surfaces without learning a different stack. +### 12. CLI packaging and release shape +The shipped product shape should be a separate bundled `warpctrl` CLI binary that reuses shared CLI/protocol crates but does not depend on launching the GUI binary in command mode. Follow the Oz CLI release model as closely as practical: +- macOS: + - Add a standalone control CLI artifact path next to the existing Oz standalone CLI artifact flow. + - If the app bundle also exposes a wrapper/install flow, keep channelized naming consistent with the final product name decision. +- Linux: + - Extend bundle/release scripts to emit control CLI standalone artifacts and packages in the same broad pattern as the current Oz CLI tarball/deb/rpm/Arch package flow. +- Windows: + - Mirror the existing installer-generated helper-wrapper pattern first if that remains the canonical Oz behavior on Windows. + - If the product decision is to ship a true standalone Windows control CLI binary, add a dedicated release path in follow-up work rather than silently diverging from existing Oz precedent. +Startup and dependency expectations: +- The CLI process should initialize only command parsing, discovery, authentication material loading, protocol serialization, HTTP transport, and output formatting needed for the requested command. +- The CLI should not initialize GUI state, rendering, terminal session models, app workspaces, or other main-app-only subsystems. +- Startup cost should be treated as part of the product contract because control commands are expected to compose naturally in scripts and repeated interactive shell usage. +Naming decision: +- Product examples use provisional `warpctrl ...` command lines for the standalone local-control binary. +- Final artifact filenames, channelized aliases, and installer exposure should be chosen before broad rollout to avoid churn in bundle scripts, docs, shell completions, and release workflow files. +## Implementation Plan +### Branch stack +Use raw git for the stack; do not use Graphite for these branches. +The durable review stack should optimize for reviewability rather than mirroring only broad product phases. The bottom review branch now combines specs and the shared foundation so reviewers can see the product/security contract next to the protocol, settings, bridge, and CLI scaffolding that enforce it. The intended stack is: +1. `zach/warp-cli-core-foundation` — create this branch from `master`. It owns the specs in `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs, plus the shared implementation foundation: protocol crate shape, discovery/auth scaffolding, selector and error types, action metadata/catalog structure, Scripting settings surface, protected local-control settings, local-control server/bridge skeleton, standalone `warpctrl` binary skeleton, packaging hooks, module split, `WarpCtrlBehavior` scaffolding, and the minimum `tab.create` smoke path if needed to prove the end-to-end architecture. +2. `zach/warp-cli-readonly-metadata` — create this branch from `zach/warp-cli-core-foundation`. It implements structural metadata reads: instance/app health and active-chain commands, windows, tabs, panes, sessions, capability/action metadata, opaque IDs, metadata target shapes, and metadata-read permission enforcement. It must not expose terminal output, input buffers, history, Drive object contents, or other underlying user data. +3. `zach/warp-cli-readonly-data-settings` — create this branch from `zach/warp-cli-readonly-metadata`. It implements underlying-data reads and read-only settings/appearance/docs: block listing/inspection/output, input-buffer reads, history reads, theme/settings/keybinding/action reads, project/file app-state reads, authenticated Drive metadata/content reads where present, read-only docs, and the read-only `warpctrl` Agent skill. +4. `zach/warp-cli-authenticated-scripting` — create this branch from `zach/warp-cli-readonly-data-settings`. It implements authenticated-user grant plumbing, the verified Warp-terminal proof broker, external API-key scripting identity, auth command surface, Settings > Scripting controls for authenticated grants, and tests proving high-risk actions cannot run without authenticated grants. It should not implement broad new action families by itself. +5. `zach/warp-cli-mutating-layout` — create this branch from `zach/warp-cli-authenticated-scripting`. It implements layout and app-state mutations for app/window/tab/pane behavior: window create/focus/close, tab create/activate/move/close, tab metadata that belongs with layout review if appropriate, pane split/focus/navigate/resize/maximize/close, app-state mutation permission checks, and layout mutation tests. +6. `zach/warp-cli-mutating-input-settings-surfaces` — create this branch from `zach/warp-cli-mutating-layout`. It implements session activation/cycling/reopen, input insert/replace/clear, input mode switching, theme/system-theme/font/zoom changes, allowlisted setting set/toggle, settings/palette/search/panel/surface commands, file/project/Drive open commands that are app-state-only, mutating docs, and the mutating-command Agent skill updates. It must preserve the prohibition on accepted-command submission and agent-prompt submission. +7. `zach/warp-cli-mutating-drive-data` — create this branch from `zach/warp-cli-mutating-input-settings-surfaces`. It implements authenticated underlying-data mutations for Warp Drive objects: typed Drive object create/update/delete/insert, the v0 personal-to-team sharing path, permission enforcement, authenticated-user/API-key enforcement, and tests using disposable resources. It must not implement local file content reads, writes, appends, deletes, or other filesystem-content mutations. +8. `zach/warp-cli-mutating-execution-underlying` — create this branch from `zach/warp-cli-mutating-drive-data`. It implements authenticated execution-underlying actions such as `input.run` and typed workflow execution where supported. It must require underlying-data-mutation permission plus authenticated scripting identity, audit records, explicit target resolution, and tests proving accepted-command submission and agent-prompt submission remain unavailable. +The previous `zach/warp-cli-specs` branch is retained only as migration-source/history material. It is no longer a separate review PR or an authoritative branch in the active stack. +The goal is to keep durable review branches close to roughly 2,000 lines of incremental changes where practical while avoiding a one-branch-per-command maintenance burden. Product phases still matter, but they are not the primary PR boundary. The durable branches are the review spine; short-lived shard branches can feed into them during implementation. +Spec changes are an important part of the stacking strategy. All new spec changes must originate on `zach/warp-cli-core-foundation`, which is the authoritative source for `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs. After a spec change lands there, propagate it upward through every stacked branch with raw git so the spec files stay synchronized across `zach/warp-cli-readonly-metadata`, `zach/warp-cli-readonly-data-settings`, `zach/warp-cli-authenticated-scripting`, `zach/warp-cli-mutating-layout`, `zach/warp-cli-mutating-input-settings-surfaces`, `zach/warp-cli-mutating-drive-data`, and `zach/warp-cli-mutating-execution-underlying`. Do not make independent spec edits directly on higher implementation branches except as part of resolving a propagation conflict in a way that preserves the authoritative bottom-branch content. +Recommended raw-git setup after `zach/warp-cli-core-foundation` is ready: +```bash +git fetch origin +git checkout -b zach/warp-cli-core-foundation origin/master +git checkout -b zach/warp-cli-readonly-metadata +git checkout -b zach/warp-cli-readonly-data-settings +git checkout -b zach/warp-cli-authenticated-scripting +git checkout -b zach/warp-cli-mutating-layout +git checkout -b zach/warp-cli-mutating-input-settings-surfaces +git checkout -b zach/warp-cli-mutating-drive-data +git checkout -b zach/warp-cli-mutating-execution-underlying +``` +If a lower branch changes after higher branches exist, rebase each higher branch onto its immediate lower branch with raw git and resolve conflicts by preserving both the lower branch's stable API/permission model and the higher branch's owned behavior. +### Migrating from the earlier four-branch stack +The earlier four-branch stack (`zach/warp-cli-specs`, `zach/warp-cli`, `zach/warp-cli-readonly`, `zach/warp-cli-read-write`) should be treated as source material for the expanded eight-PR review stack, not as the final review structure. +Recommended migration: +1. Create backup refs before rewriting or replacing anything: + - `backup/warp-cli-specs` from `zach/warp-cli-specs`. + - `backup/warp-cli` from `zach/warp-cli`. + - `backup/warp-cli-readonly` from `zach/warp-cli-readonly`. + - `backup/warp-cli-read-write` from `zach/warp-cli-read-write`. +2. Create `zach/warp-cli-core-foundation` from latest `origin/master` and bring over both the specs from `zach/warp-cli-specs` and only the foundation pieces from `zach/warp-cli`. Prefer path-level checkout followed by selective editing or `git add -p`; do not preserve every old commit if that makes review boundaries worse. +3. Create `zach/warp-cli-readonly-metadata` from `zach/warp-cli-core-foundation` and bring over only metadata-read pieces from `zach/warp-cli-readonly`. +4. Create `zach/warp-cli-readonly-data-settings` from `zach/warp-cli-readonly-metadata` and bring over the remaining read-only underlying-data, settings, docs, and skill pieces from `zach/warp-cli-readonly`. +5. Create `zach/warp-cli-authenticated-scripting` from `zach/warp-cli-readonly-data-settings` and bring over or implement the verified terminal proof broker, external API-key scripting identity, authenticated-user grant plumbing, auth command surface, and related Settings > Scripting controls. +6. Create `zach/warp-cli-mutating-layout` from `zach/warp-cli-authenticated-scripting` and bring over only layout/app-state mutations from `zach/warp-cli-read-write` and its layout shards. +7. Create `zach/warp-cli-mutating-input-settings-surfaces` from `zach/warp-cli-mutating-layout` and bring over mutating input/session/settings/surface pieces from `zach/warp-cli-read-write`. +8. Create `zach/warp-cli-mutating-drive-data` from `zach/warp-cli-mutating-input-settings-surfaces` and bring over the approved `zach/warp-cli-read-write-drive-data` functionality. Do not bring over local file content read/write/delete functionality because it is no longer part of the public catalog. +9. Create `zach/warp-cli-mutating-execution-underlying` from `zach/warp-cli-mutating-drive-data` and bring over `zach/warp-cli-read-write-execution-underlying` functionality while keeping accepted-command and agent-prompt submission excluded. +10. Recompute incremental diff sizes, validate compilation/tests for each branch, push the new stack, and retire or close the old broad branches once the new review stack is accepted. +Before redistributing feature work, prefer landing a mechanical module-split commit in `zach/warp-cli-core-foundation` so later branches do not all expand the same large files. The app-side target should be: +- `app/src/local_control/mod.rs` for registration and top-level exports. +- `app/src/local_control/bridge.rs` for the app request bridge. +- `app/src/local_control/resolver.rs` for target resolution. +- `app/src/local_control/permissions.rs` for app-side permission/auth checks. +- `app/src/local_control/handlers/metadata.rs`. +- `app/src/local_control/handlers/data.rs`. +- `app/src/local_control/handlers/layout.rs`. +- `app/src/local_control/handlers/input.rs`. +- `app/src/local_control/handlers/settings_surfaces.rs`. +Likewise, split CLI and protocol code if they become review bottlenecks: +- `crates/warp_cli/src/local_control/mod.rs`. +- `crates/warp_cli/src/local_control/selectors.rs`. +- `crates/warp_cli/src/local_control/output.rs`. +- `crates/warp_cli/src/local_control/commands/{metadata,data,layout,input,settings_surfaces}.rs`. +- `crates/local_control/src/{protocol,catalog,selectors}.rs`. +- `crates/local_control/src/actions/{metadata,data,layout,input,settings_surfaces}.rs`. +### Feature flag and rollout gate +The entire feature should be gated behind a Warp feature flag, proposed as `FeatureFlag::WarpControlCli` with Cargo feature `warp_control_cli`. +Implementation should follow the existing runtime feature-flag conventions: +- Add `warp_control_cli = []` under `[features]` in `app/Cargo.toml`, not under the default feature set until launch. +- Add `WarpControlCli` to the `FeatureFlag` enum in `crates/warp_features/src/lib.rs`. +- Add the `#[cfg(feature = "warp_control_cli")] FeatureFlag::WarpControlCli` entry in `app/src/features.rs` so the compile-time feature initializes the runtime flag. +- Enable the flag for dogfood or preview by adding it to `DOGFOOD_FLAGS` or `PREVIEW_FLAGS` only when the rollout plan calls for that exposure. +- Prefer runtime checks with `FeatureFlag::WarpControlCli.is_enabled()` over broad `#[cfg]` gates except where code cannot compile without the Cargo feature. +When `FeatureFlag::WarpControlCli` is disabled in the Warp app: +- the Scripting settings page should not expose Warp control settings; +- `LocalControlSettings` should not register user-visible controls for Warp control; +- the app should not create `LocalControlBridge` or `LocalControlServer`; +- no local-control discovery record should be written; +- no `/v1/control` or `/v1/control/credentials` local server endpoints should be exposed; +- command-palette/keybinding entries related specifically to installing, configuring, or using `warpctrl` should be hidden; +- tests should cover both enabled and disabled flag states with the repo's normal feature-flag override helpers. +The standalone `warpctrl` binary can still exist in a build where the app feature is disabled, but it should find no compatible enabled app instance and should return a structured no-instance or feature-disabled error rather than relying on hidden server state. +### Merge and review strategy +Keep PR boundaries aligned with the stack: +- PR1: `zach/warp-cli-core-foundation` into `master` for the combined specs, shared protocol, CLI, settings, bridge, and module scaffolding. +- PR2: `zach/warp-cli-readonly-metadata` into `zach/warp-cli-core-foundation` or its merged successor for metadata reads. +- PR3: `zach/warp-cli-readonly-data-settings` into `zach/warp-cli-readonly-metadata` or its merged successor for underlying-data reads, settings reads, docs, and skill updates. +- PR4: `zach/warp-cli-authenticated-scripting` into `zach/warp-cli-readonly-data-settings` or its merged successor for verified terminal proofs, external API-key scripting auth, and authenticated-user grants. +- PR5: `zach/warp-cli-mutating-layout` into `zach/warp-cli-authenticated-scripting` or its merged successor for app/window/tab/pane layout mutations. +- PR6: `zach/warp-cli-mutating-input-settings-surfaces` into `zach/warp-cli-mutating-layout` or its merged successor for input/session/settings/surface mutations. +- PR7: `zach/warp-cli-mutating-drive-data` into `zach/warp-cli-mutating-input-settings-surfaces` or its merged successor for authenticated Warp Drive underlying-data mutations. +- PR8: `zach/warp-cli-mutating-execution-underlying` into `zach/warp-cli-mutating-drive-data` or its merged successor for authenticated execution-underlying actions. +If a lower PR merges before higher branches are ready, rebase the next branch onto the merged successor of its base and continue upward through the stack. Use raw git for all rebases, conflict resolution, cherry-picks, and pushes. +## End-to-end flow +```mermaid +sequenceDiagram + participant CLI as Warp control CLI + participant REG as Local discovery registry + participant PROC as Selected Warp process + participant BROKER as Credential broker + participant HTTP as Local control listener + participant BRIDGE as App request bridge + participant RES as Target resolver + participant ACT as Allowlisted action handler + participant UI as Live Warp app state + + CLI->>REG: Read local instance records + CLI->>PROC: Health/protocol check for candidates + PROC-->>CLI: Instance metadata + compatibility + CLI->>CLI: Resolve instance selector + CLI->>BROKER: Request scoped credential for action + execution context + BROKER-->>CLI: Grant or structured denial + CLI->>HTTP: Authenticated POST tab.create request + HTTP->>HTTP: Verify context-specific enablement + credential + execution context + HTTP->>BRIDGE: Typed request + response channel + BRIDGE->>BRIDGE: Recheck enablement + permission + auth-user policy + BRIDGE->>RES: Resolve window/tab/pane/session selectors + RES-->>BRIDGE: Concrete target handles or typed error + BRIDGE->>ACT: Execute allowlisted ControlAction + ACT->>UI: Reuse existing tab creation behavior + UI-->>ACT: Mutation/read result + ACT-->>BRIDGE: Typed result + BRIDGE-->>HTTP: Response envelope + HTTP-->>CLI: JSON success/error response + CLI-->>CLI: Pretty or JSON output +``` +## Testing and validation +Map tests directly to `PRODUCT.md` behavior. +- Security architecture: + - Protected enablement tests proving outside-Warp control defaults off, disabled outside-Warp context rejects credential issuance, sensitive discovery, and mutating requests with `local_control_disabled`, and current inside-Warp credential requests are rejected with `execution_context_not_allowed`. + - Tests proving discovery in disabled state exposes no actionable endpoint authority or credential reference. + - Credential-storage tests proving raw credentials are not written into plaintext discovery records. + - Execution-context tests proving external clients cannot receive grants reserved for verified Warp-terminal invocations. + - Permission-category enforcement tests proving insufficient grants fail with `insufficient_permissions` before selector resolution or handler dispatch, including separate denial cases for app-state mutation, metadata/configuration mutation, and underlying-data mutation. + - Authenticated-user tests proving user-authenticated actions fail without a logged-in app user or authenticated-user grant. + - External API-key tests proving missing, invalid, expired, revoked, wrong-subject, and insufficient-scope keys fail before selector resolution or handler dispatch. + - Settings > Scripting tests proving both top-level toggles and granular disabled categories invalidate credentials and prevent new grants. + - Structured-error tests for disabled, unauthenticated, expired, revoked, insufficient-scope, execution-context-denied, authenticated-user-required, authenticated-user-unavailable, unsupported, malformed, ambiguous, missing-target, stale-target, and invalid-selector requests. +- Behavior 1-6, 29-31: + - Protocol version/unit tests. + - Discovery-registry tests with zero, one, multiple, stale, and incompatible instance records. + - Local-auth tests for missing, invalid, expired, revoked, and valid credentials. +- Behavior 7-13: + - Selector-resolution unit tests for active, explicit ID, index, stale target, ambiguous target, and non-terminal session target. + - Tests that no lower-level selector silently retargets after an explicit stale selector fails. + - CLI selector parsing tests for every generic and explicit alias form: `--window`, `--window-id`, `--window-index`, `--window-title`, `--tab`, `--tab-id`, `--tab-index`, `--tab-title`, `--pane`, `--pane-id`, `--pane-index`, `--session`, `--session-id`, and `--session-index`. + - CLI conflict tests proving only one selector form per entity family is accepted and that positional target IDs are rejected where the command expects selector flags. +- Behavior 15-28: + - Parser/serde tests for every first-slice `ControlAction` variant. + - Router tests proving unknown/unallowlisted actions are rejected. + - CLI parse/output tests for pretty and JSON rendering. +- Behavior 18 and 33: + - App-side tests for `tab.create` using existing workspace/tab helpers or a narrow extracted helper. + - Manual local verification that `warpctrl tab create` creates a terminal tab in a running app. +- Behavior 30: + - Multi-process integration-style coverage using two synthetic discovery records and mock health responders, plus manual testing with multiple channel builds where practical. +- Packaging: + - `--artifact cli`-style bundle smoke tests or script-level checks for each supported platform path touched by the first slice. + - Startup-path tests or focused checks confirming `warpctrl` dispatches commands without entering GUI-app launch code. + - Shell completions/help output checks once final command naming is selected. +### Computer-use CLI verification +Before any stacked PR is considered ready for review, run an end-to-end computer-use verification pass against a cloud built validation artifact from that branch. The validation artifact is a Warp app build and standalone `warpctrl` binary built by the validation agent from the exact commit under review, with `warp_control_cli` compiled in and `FeatureFlag::WarpControlCli` enabled at runtime. +The verifier must launch the built Warp app, enable the Scripting settings needed for the command category under test, and capture screenshots or screen recordings that prove both the terminal invocation and the user-visible result of each basic command family. Every `warpctrl` invocation executed during verification must have a durable screenshot captured immediately after the command runs, showing the exact command line and full terminal output in either pretty or JSON mode. Screenshots should be saved as durable artifacts and named by branch, invocation context, command family, command name, and whether the image shows terminal output or app UI state. +The verifier must exercise every invocation context implemented by the branch under review. For the current foundation branch, inside-Warp verification means proving `InsideWarp` requests are rejected and no inside-Warp Settings > Scripting controls are exposed. Once verified Warp-terminal support is implemented, the verifier must also run `warpctrl` from a terminal session inside the feature-flag-enabled Warp app and prove the app-issued execution-context proof is accepted and inside-Warp settings gate command categories. The outside-Warp path must run the same `warpctrl` binary from an external terminal or automation shell outside the built Warp app and prove outside-Warp control is default-off, then works only after enabling outside-Warp scripting and the required granular permissions in Settings > Scripting. +The verification matrix should cover every implemented command in `PRODUCT.md` at least once in JSON output mode. The verifier must capture a terminal-output screenshot for every command invocation, including successful calls, expected denials, unsupported stubs, and fail-closed policy gates. Where there is a visible UI effect, the verifier must also capture app UI screenshots after the command runs, and before the command when that is needed to prove a before/after state transition. At minimum: +- read-only metadata commands show successful CLI output in a terminal screenshot and, for active/focus/list commands, a visible target that matches the output; +- underlying data read commands show successful CLI output only when the underlying-data-read permission is enabled, plus terminal screenshots for disabled-permission denials; +- app-state mutation commands show terminal-output screenshots plus before/after app UI screenshots proving the visible Warp UI changed; +- metadata/configuration mutation commands show terminal-output screenshots plus before/after app UI screenshots proving the persisted setting or label changed; +- underlying data mutation commands run only in a disposable test workspace/session with test Warp Drive objects, show terminal screenshots for denial without the underlying-data-mutation permission, then show terminal screenshots and any relevant app/file/Drive state evidence for success with the permission enabled; +- authenticated-user commands show terminal screenshots for both the logged-out or missing-grant denial path and the enabled authenticated path when a test account/environment is available. +The verifier should produce a verification manifest checked into or attached to the PR artifacts. The manifest should list each command, branch under test, invocation context, required permission category, expected result, actual result, terminal screenshot artifact path, UI screenshot artifact path when applicable, and any skipped case with a reason. Missing terminal screenshots for any executed `warpctrl` invocation block review readiness. Missing UI screenshots for visible commands also block review readiness. +## Parallelization +The durable review stack should remain linear, but implementation can still happen in parallel with Oz cloud agents. The pattern is contract-first fan-out: land the shared contracts and module boundaries in `zach/warp-cli-core-foundation`, then let cloud agents work on short-lived shard branches that feed the durable review branches. +Wave 0: foundation: +- Keep `zach/warp-cli-core-foundation` mostly sequential or use at most one or two tightly scoped agents because protocol envelope, discovery, authentication, feature-flag gating, selector resolution, module boundaries, and `tab.create` smoke behavior need one coherent architecture. +- Acceptable foundation shards are `core-protocol-cli` for shared protocol/CLI skeleton and `core-app-foundation` for settings, bridge, resolver, permissions, and handler skeletons. These shards should merge into the single durable `zach/warp-cli-core-foundation` branch before feature fan-out begins. +Wave 1: read-only fan-out: +- Launch short-lived Oz cloud shard branches from `zach/warp-cli-core-foundation` once the contracts compile. +- Suggested shards: + - `zach/warp-cli-shard/readonly-metadata` owns structural metadata commands and feeds `zach/warp-cli-readonly-metadata`. + - `zach/warp-cli-shard/readonly-data` owns block output, input-buffer reads, history reads, and other underlying-data reads, then feeds `zach/warp-cli-readonly-data-settings`. + - `zach/warp-cli-shard/readonly-settings-docs` owns theme/settings/keybinding/action reads, docs, and read-only skill updates, then feeds `zach/warp-cli-readonly-data-settings`. +Wave 2: mutating fan-out: +- Launch mutating shards only after read-only target resolution and result shapes are stable. +- Suggested shards: + - `zach/warp-cli-shard/mutating-window-tab-pane` owns window/tab/pane layout mutations and feeds `zach/warp-cli-mutating-layout`. + - `zach/warp-cli-shard/mutating-input-session` owns session activation/cycling/reopen, input insert/replace/clear, and input mode switching, then feeds `zach/warp-cli-mutating-input-settings-surfaces`. + - `zach/warp-cli-shard/authenticated-scripting` owns verified terminal proofs, external API-key auth, authenticated-user grants, and auth command tests, then feeds `zach/warp-cli-authenticated-scripting`. + - `zach/warp-cli-shard/mutating-settings-surfaces` owns theme/font/zoom/setting mutations and settings/palette/panel/surface commands, then feeds `zach/warp-cli-mutating-input-settings-surfaces`. + - `zach/warp-cli-shard/mutating-drive-data` owns Drive object data mutations, including the v0 personal-to-team sharing path, then feeds `zach/warp-cli-mutating-drive-data`. + - `zach/warp-cli-shard/mutating-execution-underlying` owns `input.run` and typed workflow execution, then feeds `zach/warp-cli-mutating-execution-underlying`. +Each cloud shard prompt should include: +- The exact base branch and shard branch name. +- Owned command families. +- Owned files/modules. +- Files/modules the shard must not edit without calling out the need for integration. +- Required permission categories and authenticated-user behavior. +- Selector resolution requirements. +- Validation commands and expected tests. +- A handoff requirement: report branch name, changed files, implemented commands, permission decisions, validation results, and any conflicts or follow-ups. +Default file ownership for shards: +- Metadata shards own metadata handler/protocol/CLI modules and metadata tests. +- Data shards own data handler/protocol/CLI modules and underlying-data permission tests. +- Layout shards own layout handler/protocol/CLI modules and app-state mutation tests. +- Authenticated-scripting shards own auth broker/protocol/CLI modules, Settings > Scripting authenticated grant controls, API-key storage/exchange tests, and authenticated-user denial tests. +- Input/session shards own input/session handler/protocol/CLI modules and tests proving staging does not submit or execute unless the branch explicitly owns `input.run`. +- Settings/surface shards own settings/surface handler/protocol/CLI modules and metadata/configuration mutation tests. +- Drive data shards own Drive underlying-data handler/protocol/CLI modules, authenticated-user/API-key enforcement tests, personal-to-team sharing tests, and disposable-resource tests. +- Execution-underlying shards own `input.run` and typed workflow execution handler/protocol/CLI modules, audit tests, and denial tests proving accepted-command and agent-prompt submission remain unavailable. +The lead integrator merges or cherry-picks accepted shard work into the durable stack with raw git, in review order. Shard branches should not become independent long-lived PRs unless the lead intentionally splits review further; their default purpose is to feed the durable stack while preserving parallel implementation and focused context windows. +```mermaid +flowchart LR + Core["zach/warp-cli-core-foundation<br/>specs + contracts + bridge"] --> ROMeta["zach/warp-cli-readonly-metadata<br/>structural reads"] + ROMeta --> ROData["zach/warp-cli-readonly-data-settings<br/>data/settings reads"] + ROData --> Auth["zach/warp-cli-authenticated-scripting<br/>terminal proof + API key auth"] + Auth --> MutLayout["zach/warp-cli-mutating-layout<br/>layout mutations"] + MutLayout --> MutInput["zach/warp-cli-mutating-input-settings-surfaces<br/>input/settings/surfaces"] + MutInput --> MutData["zach/warp-cli-mutating-drive-data<br/>Drive data"] + MutData --> MutExec["zach/warp-cli-mutating-execution-underlying<br/>execution actions"] + ROMetaShard["shard/readonly-metadata"] --> ROMeta + RODataShard["shard/readonly-data"] --> ROData + ROSettingsShard["shard/readonly-settings-docs"] --> ROData + MutLayoutShard["shard/mutating-window-tab-pane"] --> MutLayout + MutInputShard["shard/mutating-input-session"] --> MutInput + MutSettingsShard["shard/mutating-settings-surfaces"] --> MutInput + AuthShard["shard/authenticated-scripting"] --> Auth + MutDataShard["shard/mutating-drive-data"] --> MutData + MutExecShard["shard/mutating-execution-underlying"] --> MutExec +``` +## Risks and mitigations +- Fixed-port server assumptions: + - Mitigation: leave current `9277` endpoints undisturbed and use a per-process control listener plus discovery registry. +- Browser-to-localhost abuse: + - Mitigation: no permissive CORS, protected in-app enablement, explicit local auth, scoped grants, and mutating routes gated before selector resolution. +- External apps silently enabling outside-Warp local control: + - Mitigation: the outside-Warp enablement state defaults off, lives in protected local storage behind Settings > Scripting, is local-only, is not Settings Sync'd, and is not writable through `warpctrl`, config files, registry/plist preference edits, defaults writes, or server-backed settings. +- External apps obtaining in-Warp authenticated-user grants: + - Mitigation: require an app-issued execution-context proof for Warp-terminal-only grants, do not trust caller-declared labels or plain environment variables as sole authority, and keep external authenticated-user grants behind a separate default-off permission. +- Logged-out requests touching user-authenticated data: + - Mitigation: every action declares `requires_authenticated_user`, new actions default to true, and the bridge returns authenticated-user errors before selector resolution or dispatch. +- Implementation drift from `SECURITY.md`: + - Mitigation: treat `SECURITY.md` as normative for security behavior; update this technical plan before implementation when there is disagreement, and include tests for the security architecture in the first slice. +- Action catalog drift from real UI behavior: + - Mitigation: each control action reuses or factors existing UI action paths rather than duplicating behavior, and user-visible app action enums implement exhaustive `WarpCtrlBehavior` mappings so new internal actions cannot be added without an explicit expose/cover/exclude/defer decision. +- Leaking internal unstable identifiers: + - Mitigation: public protocol exposes opaque IDs and selectors; internal runtime IDs stay implementation details. +- Over-broad settings mutation: + - Mitigation: allowlisted setting keys only, with private/debug/derived settings rejected. +- Command execution risk: + - Mitigation: keep `input.run`/typed workflow execution in the catalog but implement it only in `zach/warp-cli-mutating-execution-underlying` after authenticated scripting identity, underlying-data-mutation permission, deterministic target resolution, and audit coverage are in place. +- Packaging churn due to provisional executable naming: + - Mitigation: document `warpctrl` as provisional and settle final aliases before broad release workflow rollout. +- Heavyweight CLI startup caused by sharing the GUI binary's launch path: + - Mitigation: ship a separate control CLI artifact with a narrow initialization path and keep GUI-only subsystems out of ordinary CLI command execution. +## Follow-ups +- Decide the final artifact filename/channel alias scheme around the provisional `warpctrl ...` public command surface. +- Decide whether Windows should follow the current Oz wrapper pattern indefinitely or gain standalone control CLI artifacts. +- Decide whether a future subscription/watch protocol is useful for scripts that want live state changes, rather than single request/response calls only. From 4d6d80ccede01507dd8f1ace6a39e18a43e3a20f Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Tue, 26 May 2026 13:29:41 -0600 Subject: [PATCH 27/48] Update warpctrl v2 branch architecture Co-Authored-By: Oz <oz-agent@warp.dev> --- specs/warp-control-cli/TECH.md | 182 ++++++++++++--------------------- 1 file changed, 68 insertions(+), 114 deletions(-) diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index 311ff9a29c..893eb610ba 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -356,65 +356,33 @@ Naming decision: ## Implementation Plan ### Branch stack Use raw git for the stack; do not use Graphite for these branches. -The durable review stack should optimize for reviewability rather than mirroring only broad product phases. The bottom review branch now combines specs and the shared foundation so reviewers can see the product/security contract next to the protocol, settings, bridge, and CLI scaffolding that enforce it. The intended stack is: -1. `zach/warp-cli-core-foundation` — create this branch from `master`. It owns the specs in `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs, plus the shared implementation foundation: protocol crate shape, discovery/auth scaffolding, selector and error types, action metadata/catalog structure, Scripting settings surface, protected local-control settings, local-control server/bridge skeleton, standalone `warpctrl` binary skeleton, packaging hooks, module split, `WarpCtrlBehavior` scaffolding, and the minimum `tab.create` smoke path if needed to prove the end-to-end architecture. -2. `zach/warp-cli-readonly-metadata` — create this branch from `zach/warp-cli-core-foundation`. It implements structural metadata reads: instance/app health and active-chain commands, windows, tabs, panes, sessions, capability/action metadata, opaque IDs, metadata target shapes, and metadata-read permission enforcement. It must not expose terminal output, input buffers, history, Drive object contents, or other underlying user data. -3. `zach/warp-cli-readonly-data-settings` — create this branch from `zach/warp-cli-readonly-metadata`. It implements underlying-data reads and read-only settings/appearance/docs: block listing/inspection/output, input-buffer reads, history reads, theme/settings/keybinding/action reads, project/file app-state reads, authenticated Drive metadata/content reads where present, read-only docs, and the read-only `warpctrl` Agent skill. -4. `zach/warp-cli-authenticated-scripting` — create this branch from `zach/warp-cli-readonly-data-settings`. It implements authenticated-user grant plumbing, the verified Warp-terminal proof broker, external API-key scripting identity, auth command surface, Settings > Scripting controls for authenticated grants, and tests proving high-risk actions cannot run without authenticated grants. It should not implement broad new action families by itself. -5. `zach/warp-cli-mutating-layout` — create this branch from `zach/warp-cli-authenticated-scripting`. It implements layout and app-state mutations for app/window/tab/pane behavior: window create/focus/close, tab create/activate/move/close, tab metadata that belongs with layout review if appropriate, pane split/focus/navigate/resize/maximize/close, app-state mutation permission checks, and layout mutation tests. -6. `zach/warp-cli-mutating-input-settings-surfaces` — create this branch from `zach/warp-cli-mutating-layout`. It implements session activation/cycling/reopen, input insert/replace/clear, input mode switching, theme/system-theme/font/zoom changes, allowlisted setting set/toggle, settings/palette/search/panel/surface commands, file/project/Drive open commands that are app-state-only, mutating docs, and the mutating-command Agent skill updates. It must preserve the prohibition on accepted-command submission and agent-prompt submission. -7. `zach/warp-cli-mutating-drive-data` — create this branch from `zach/warp-cli-mutating-input-settings-surfaces`. It implements authenticated underlying-data mutations for Warp Drive objects: typed Drive object create/update/delete/insert, the v0 personal-to-team sharing path, permission enforcement, authenticated-user/API-key enforcement, and tests using disposable resources. It must not implement local file content reads, writes, appends, deletes, or other filesystem-content mutations. -8. `zach/warp-cli-mutating-execution-underlying` — create this branch from `zach/warp-cli-mutating-drive-data`. It implements authenticated execution-underlying actions such as `input.run` and typed workflow execution where supported. It must require underlying-data-mutation permission plus authenticated scripting identity, audit records, explicit target resolution, and tests proving accepted-command submission and agent-prompt submission remain unavailable. -The previous `zach/warp-cli-specs` branch is retained only as migration-source/history material. It is no longer a separate review PR or an authoritative branch in the active stack. -The goal is to keep durable review branches close to roughly 2,000 lines of incremental changes where practical while avoiding a one-branch-per-command maintenance burden. Product phases still matter, but they are not the primary PR boundary. The durable branches are the review spine; short-lived shard branches can feed into them during implementation. -Spec changes are an important part of the stacking strategy. All new spec changes must originate on `zach/warp-cli-core-foundation`, which is the authoritative source for `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs. After a spec change lands there, propagate it upward through every stacked branch with raw git so the spec files stay synchronized across `zach/warp-cli-readonly-metadata`, `zach/warp-cli-readonly-data-settings`, `zach/warp-cli-authenticated-scripting`, `zach/warp-cli-mutating-layout`, `zach/warp-cli-mutating-input-settings-surfaces`, `zach/warp-cli-mutating-drive-data`, and `zach/warp-cli-mutating-execution-underlying`. Do not make independent spec edits directly on higher implementation branches except as part of resolving a propagation conflict in a way that preserves the authoritative bottom-branch content. -Recommended raw-git setup after `zach/warp-cli-core-foundation` is ready: +The active durable review stack is the recovered `zach/warp-cli-v2/*` stack. This stack is the review architecture for the current implementation because it preserves the fan-in work while slicing it into branch-sized review boundaries. The older branch names in the pre-recovery plan are historical source material only and should not be used as the active PR stack. +Spec ownership is part of the branch architecture. The only v2 branch that may intentionally change `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, or `README.md` is `zach/warp-cli-v2/contract-spec-sync`. After a spec change lands there, propagate it upward through every higher v2 branch with raw git rebases so those files remain byte-identical across the stack. Higher implementation branches must not make independent spec edits except when resolving a propagation conflict in a way that preserves the bottom-branch content. +The intended v2 stack is: +1. `zach/warp-cli-v2/contract-spec-sync` — create from `origin/master`. It owns the product, technical, security, and README specs plus the shared contract/foundation: protocol crate shape, discovery/auth scaffolding, selector and error types, action metadata/catalog structure, Scripting settings surface, protected local-control settings, local-control server/bridge skeleton, standalone `warpctrl` binary skeleton, packaging hooks, module split, `WarpCtrlBehavior` scaffolding, and the minimum first-slice smoke path needed to prove the end-to-end architecture. +2. `zach/warp-cli-v2/auth-security` — create from `zach/warp-cli-v2/contract-spec-sync`. It owns authentication and security enforcement that applies across command families: scoped grants, execution-context policy, authenticated-user/API-key plumbing, denial paths, Settings > Scripting security controls, and tests proving high-risk actions cannot run without the required identity and permission category. +3. `zach/warp-cli-v2/readonly-capability-targets` — create from `zach/warp-cli-v2/auth-security`. It owns structural metadata reads, capability/action metadata, target selector parsing and resolution, opaque IDs, active window/tab/pane/session targeting, metadata-read permission checks, and read-only CLI output for these surfaces. It must not expose terminal output, input buffers, history, Drive object contents, or other underlying user data. +4. `zach/warp-cli-v2/appstate-file-drive-views` — create from `zach/warp-cli-v2/readonly-capability-targets`. It owns read-only app-state, file/project view, Drive view, block/input/history-style underlying-data read surfaces that are approved for the public catalog, along with the required underlying-data-read permission checks. It must keep local filesystem content reads/writes outside the v0 public catalog unless the specs are updated first. +5. `zach/warp-cli-v2/metadata-config-mutations` — create from `zach/warp-cli-v2/appstate-file-drive-views`. It owns metadata/configuration mutations: allowlisted settings changes, labels/titles/appearance/configuration updates, settings or surface-opening commands that are metadata/configuration rather than underlying-data mutations, and tests proving unallowlisted or private settings are rejected. +6. `zach/warp-cli-v2/drive-data-mutations` — create from `zach/warp-cli-v2/metadata-config-mutations`. It owns authenticated underlying-data mutations for Warp Drive objects, including typed object create/update/delete/insert and the approved v0 personal-to-team sharing path. It must use disposable resources in tests and must not implement local file content reads, writes, appends, deletes, or other filesystem-content mutations. +7. `zach/warp-cli-v2/execution-underlying` — create from `zach/warp-cli-v2/drive-data-mutations`. It owns authenticated execution-underlying actions such as `input.run` and typed workflow execution where supported. It must require underlying-data-mutation permission plus authenticated scripting identity, deterministic target resolution, audit records, and tests proving accepted-command submission and agent-prompt submission remain unavailable. +8. `zach/warp-cli-v2/cli-catalog-docs` — create from `zach/warp-cli-v2/execution-underlying`. It owns the final CLI/catalog/docs integration pass: generated or curated command catalog output, help/completion polish, user-facing docs, Agent skill updates, command-family documentation, and consistency checks that every advertised action has protocol metadata, permission metadata, parser coverage, handler coverage, and tests. +9. `zach/warp-cli-v2/fanin-finalize` — create from `zach/warp-cli-v2/cli-catalog-docs`. It owns fan-in cleanup only: conflict-resolution preservation, naming/format consistency, final test fixes, validation matrix updates, and integration fixes required for the recovered stack to compile and pass tests. It should not introduce broad new command families. +Recommended raw-git setup for a clean local reconstruction: ```bash git fetch origin -git checkout -b zach/warp-cli-core-foundation origin/master -git checkout -b zach/warp-cli-readonly-metadata -git checkout -b zach/warp-cli-readonly-data-settings -git checkout -b zach/warp-cli-authenticated-scripting -git checkout -b zach/warp-cli-mutating-layout -git checkout -b zach/warp-cli-mutating-input-settings-surfaces -git checkout -b zach/warp-cli-mutating-drive-data -git checkout -b zach/warp-cli-mutating-execution-underlying +git checkout -b zach/warp-cli-v2/contract-spec-sync origin/master +git checkout -b zach/warp-cli-v2/auth-security +git checkout -b zach/warp-cli-v2/readonly-capability-targets +git checkout -b zach/warp-cli-v2/appstate-file-drive-views +git checkout -b zach/warp-cli-v2/metadata-config-mutations +git checkout -b zach/warp-cli-v2/drive-data-mutations +git checkout -b zach/warp-cli-v2/execution-underlying +git checkout -b zach/warp-cli-v2/cli-catalog-docs +git checkout -b zach/warp-cli-v2/fanin-finalize ``` -If a lower branch changes after higher branches exist, rebase each higher branch onto its immediate lower branch with raw git and resolve conflicts by preserving both the lower branch's stable API/permission model and the higher branch's owned behavior. -### Migrating from the earlier four-branch stack -The earlier four-branch stack (`zach/warp-cli-specs`, `zach/warp-cli`, `zach/warp-cli-readonly`, `zach/warp-cli-read-write`) should be treated as source material for the expanded eight-PR review stack, not as the final review structure. -Recommended migration: -1. Create backup refs before rewriting or replacing anything: - - `backup/warp-cli-specs` from `zach/warp-cli-specs`. - - `backup/warp-cli` from `zach/warp-cli`. - - `backup/warp-cli-readonly` from `zach/warp-cli-readonly`. - - `backup/warp-cli-read-write` from `zach/warp-cli-read-write`. -2. Create `zach/warp-cli-core-foundation` from latest `origin/master` and bring over both the specs from `zach/warp-cli-specs` and only the foundation pieces from `zach/warp-cli`. Prefer path-level checkout followed by selective editing or `git add -p`; do not preserve every old commit if that makes review boundaries worse. -3. Create `zach/warp-cli-readonly-metadata` from `zach/warp-cli-core-foundation` and bring over only metadata-read pieces from `zach/warp-cli-readonly`. -4. Create `zach/warp-cli-readonly-data-settings` from `zach/warp-cli-readonly-metadata` and bring over the remaining read-only underlying-data, settings, docs, and skill pieces from `zach/warp-cli-readonly`. -5. Create `zach/warp-cli-authenticated-scripting` from `zach/warp-cli-readonly-data-settings` and bring over or implement the verified terminal proof broker, external API-key scripting identity, authenticated-user grant plumbing, auth command surface, and related Settings > Scripting controls. -6. Create `zach/warp-cli-mutating-layout` from `zach/warp-cli-authenticated-scripting` and bring over only layout/app-state mutations from `zach/warp-cli-read-write` and its layout shards. -7. Create `zach/warp-cli-mutating-input-settings-surfaces` from `zach/warp-cli-mutating-layout` and bring over mutating input/session/settings/surface pieces from `zach/warp-cli-read-write`. -8. Create `zach/warp-cli-mutating-drive-data` from `zach/warp-cli-mutating-input-settings-surfaces` and bring over the approved `zach/warp-cli-read-write-drive-data` functionality. Do not bring over local file content read/write/delete functionality because it is no longer part of the public catalog. -9. Create `zach/warp-cli-mutating-execution-underlying` from `zach/warp-cli-mutating-drive-data` and bring over `zach/warp-cli-read-write-execution-underlying` functionality while keeping accepted-command and agent-prompt submission excluded. -10. Recompute incremental diff sizes, validate compilation/tests for each branch, push the new stack, and retire or close the old broad branches once the new review stack is accepted. -Before redistributing feature work, prefer landing a mechanical module-split commit in `zach/warp-cli-core-foundation` so later branches do not all expand the same large files. The app-side target should be: -- `app/src/local_control/mod.rs` for registration and top-level exports. -- `app/src/local_control/bridge.rs` for the app request bridge. -- `app/src/local_control/resolver.rs` for target resolution. -- `app/src/local_control/permissions.rs` for app-side permission/auth checks. -- `app/src/local_control/handlers/metadata.rs`. -- `app/src/local_control/handlers/data.rs`. -- `app/src/local_control/handlers/layout.rs`. -- `app/src/local_control/handlers/input.rs`. -- `app/src/local_control/handlers/settings_surfaces.rs`. -Likewise, split CLI and protocol code if they become review bottlenecks: -- `crates/warp_cli/src/local_control/mod.rs`. -- `crates/warp_cli/src/local_control/selectors.rs`. -- `crates/warp_cli/src/local_control/output.rs`. -- `crates/warp_cli/src/local_control/commands/{metadata,data,layout,input,settings_surfaces}.rs`. -- `crates/local_control/src/{protocol,catalog,selectors}.rs`. -- `crates/local_control/src/actions/{metadata,data,layout,input,settings_surfaces}.rs`. +If a lower branch changes after higher branches exist, rebase each higher branch onto its immediate lower branch in stack order. Resolve conflicts by preserving the lower branch's shared contracts, permission model, and spec files while also keeping the higher branch's owned behavior. +The previous `zach/warp-cli-integration-fanin` branch and its backup are preservation/history refs for the integrated implementation work. They are not review branches. Earlier non-v2 proposed branch names and the older broad stacks are migration-source/history material only unless the stack is deliberately renamed in a future explicit reslicing. ### Feature flag and rollout gate The entire feature should be gated behind a Warp feature flag, proposed as `FeatureFlag::WarpControlCli` with Cargo feature `warp_control_cli`. Implementation should follow the existing runtime feature-flag conventions: @@ -433,16 +401,17 @@ When `FeatureFlag::WarpControlCli` is disabled in the Warp app: - tests should cover both enabled and disabled flag states with the repo's normal feature-flag override helpers. The standalone `warpctrl` binary can still exist in a build where the app feature is disabled, but it should find no compatible enabled app instance and should return a structured no-instance or feature-disabled error rather than relying on hidden server state. ### Merge and review strategy -Keep PR boundaries aligned with the stack: -- PR1: `zach/warp-cli-core-foundation` into `master` for the combined specs, shared protocol, CLI, settings, bridge, and module scaffolding. -- PR2: `zach/warp-cli-readonly-metadata` into `zach/warp-cli-core-foundation` or its merged successor for metadata reads. -- PR3: `zach/warp-cli-readonly-data-settings` into `zach/warp-cli-readonly-metadata` or its merged successor for underlying-data reads, settings reads, docs, and skill updates. -- PR4: `zach/warp-cli-authenticated-scripting` into `zach/warp-cli-readonly-data-settings` or its merged successor for verified terminal proofs, external API-key scripting auth, and authenticated-user grants. -- PR5: `zach/warp-cli-mutating-layout` into `zach/warp-cli-authenticated-scripting` or its merged successor for app/window/tab/pane layout mutations. -- PR6: `zach/warp-cli-mutating-input-settings-surfaces` into `zach/warp-cli-mutating-layout` or its merged successor for input/session/settings/surface mutations. -- PR7: `zach/warp-cli-mutating-drive-data` into `zach/warp-cli-mutating-input-settings-surfaces` or its merged successor for authenticated Warp Drive underlying-data mutations. -- PR8: `zach/warp-cli-mutating-execution-underlying` into `zach/warp-cli-mutating-drive-data` or its merged successor for authenticated execution-underlying actions. -If a lower PR merges before higher branches are ready, rebase the next branch onto the merged successor of its base and continue upward through the stack. Use raw git for all rebases, conflict resolution, cherry-picks, and pushes. +Keep PR boundaries aligned with the v2 stack: +- PR1: `zach/warp-cli-v2/contract-spec-sync` into `master` for specs, shared contracts, protocol, CLI skeleton, settings, bridge, module scaffolding, and first-slice smoke behavior. +- PR2: `zach/warp-cli-v2/auth-security` into `zach/warp-cli-v2/contract-spec-sync` or its merged successor for auth/security enforcement, execution-context policy, scoped grants, and Settings > Scripting security controls. +- PR3: `zach/warp-cli-v2/readonly-capability-targets` into `zach/warp-cli-v2/auth-security` or its merged successor for metadata reads, capabilities, target selectors, and read-only structural command output. +- PR4: `zach/warp-cli-v2/appstate-file-drive-views` into `zach/warp-cli-v2/readonly-capability-targets` or its merged successor for approved underlying-data read surfaces, app-state/file/project/Drive views, and underlying-data-read permission tests. +- PR5: `zach/warp-cli-v2/metadata-config-mutations` into `zach/warp-cli-v2/appstate-file-drive-views` or its merged successor for metadata/configuration mutations, allowlisted settings changes, and configuration-denial tests. +- PR6: `zach/warp-cli-v2/drive-data-mutations` into `zach/warp-cli-v2/metadata-config-mutations` or its merged successor for authenticated Warp Drive underlying-data mutations. +- PR7: `zach/warp-cli-v2/execution-underlying` into `zach/warp-cli-v2/drive-data-mutations` or its merged successor for authenticated execution-underlying actions. +- PR8: `zach/warp-cli-v2/cli-catalog-docs` into `zach/warp-cli-v2/execution-underlying` or its merged successor for catalog, docs, help/completion, Agent skill, and advertised-action consistency work. +- PR9: `zach/warp-cli-v2/fanin-finalize` into `zach/warp-cli-v2/cli-catalog-docs` or its merged successor for final cleanup, preserved fan-in conflict resolutions, and validation fixes. +If a lower PR merges before higher branches are ready, rebase the next branch onto the merged successor of its base and continue upward through the stack. Use raw git for all rebases, conflict resolution, cherry-picks, and pushes. Do not open PRs from `zach/warp-cli-integration-fanin` or the old non-v2 branch names unless a future explicit reslicing replaces the v2 architecture. ## End-to-end flow ```mermaid sequenceDiagram @@ -523,62 +492,47 @@ The verification matrix should cover every implemented command in `PRODUCT.md` a - authenticated-user commands show terminal screenshots for both the logged-out or missing-grant denial path and the enabled authenticated path when a test account/environment is available. The verifier should produce a verification manifest checked into or attached to the PR artifacts. The manifest should list each command, branch under test, invocation context, required permission category, expected result, actual result, terminal screenshot artifact path, UI screenshot artifact path when applicable, and any skipped case with a reason. Missing terminal screenshots for any executed `warpctrl` invocation block review readiness. Missing UI screenshots for visible commands also block review readiness. ## Parallelization -The durable review stack should remain linear, but implementation can still happen in parallel with Oz cloud agents. The pattern is contract-first fan-out: land the shared contracts and module boundaries in `zach/warp-cli-core-foundation`, then let cloud agents work on short-lived shard branches that feed the durable review branches. -Wave 0: foundation: -- Keep `zach/warp-cli-core-foundation` mostly sequential or use at most one or two tightly scoped agents because protocol envelope, discovery, authentication, feature-flag gating, selector resolution, module boundaries, and `tab.create` smoke behavior need one coherent architecture. -- Acceptable foundation shards are `core-protocol-cli` for shared protocol/CLI skeleton and `core-app-foundation` for settings, bridge, resolver, permissions, and handler skeletons. These shards should merge into the single durable `zach/warp-cli-core-foundation` branch before feature fan-out begins. -Wave 1: read-only fan-out: -- Launch short-lived Oz cloud shard branches from `zach/warp-cli-core-foundation` once the contracts compile. -- Suggested shards: - - `zach/warp-cli-shard/readonly-metadata` owns structural metadata commands and feeds `zach/warp-cli-readonly-metadata`. - - `zach/warp-cli-shard/readonly-data` owns block output, input-buffer reads, history reads, and other underlying-data reads, then feeds `zach/warp-cli-readonly-data-settings`. - - `zach/warp-cli-shard/readonly-settings-docs` owns theme/settings/keybinding/action reads, docs, and read-only skill updates, then feeds `zach/warp-cli-readonly-data-settings`. -Wave 2: mutating fan-out: -- Launch mutating shards only after read-only target resolution and result shapes are stable. -- Suggested shards: - - `zach/warp-cli-shard/mutating-window-tab-pane` owns window/tab/pane layout mutations and feeds `zach/warp-cli-mutating-layout`. - - `zach/warp-cli-shard/mutating-input-session` owns session activation/cycling/reopen, input insert/replace/clear, and input mode switching, then feeds `zach/warp-cli-mutating-input-settings-surfaces`. - - `zach/warp-cli-shard/authenticated-scripting` owns verified terminal proofs, external API-key auth, authenticated-user grants, and auth command tests, then feeds `zach/warp-cli-authenticated-scripting`. - - `zach/warp-cli-shard/mutating-settings-surfaces` owns theme/font/zoom/setting mutations and settings/palette/panel/surface commands, then feeds `zach/warp-cli-mutating-input-settings-surfaces`. - - `zach/warp-cli-shard/mutating-drive-data` owns Drive object data mutations, including the v0 personal-to-team sharing path, then feeds `zach/warp-cli-mutating-drive-data`. - - `zach/warp-cli-shard/mutating-execution-underlying` owns `input.run` and typed workflow execution, then feeds `zach/warp-cli-mutating-execution-underlying`. +The durable review stack should remain linear, but implementation can still happen in parallel with Oz cloud agents. For the recovered v2 stack, parallel work should feed short-lived shard branches that are merged or cherry-picked into exactly one v2 review branch by the lead integrator. Shard branches should not become long-lived PRs by default. +The completed recovery used fan-in shard work as source material, then sliced it into the v2 stack. Future parallel work should use the same contract-first fan-out pattern: +- Start from the lowest v2 branch that already contains the contracts needed by the shard. +- Give each shard a single owned command family or permission boundary. +- Keep `specs/warp-control-cli/*` unchanged on shards unless the shard explicitly starts from and targets `zach/warp-cli-v2/contract-spec-sync` for a spec update. +- Have the lead integrator merge or cherry-pick shard work into the appropriate v2 branch, then rebase all higher v2 branches upward. +Suggested shard-to-stack ownership: +- `zach/warp-cli-shard/auth-security` feeds `zach/warp-cli-v2/auth-security`. +- `zach/warp-cli-shard/readonly-capability-targets` feeds `zach/warp-cli-v2/readonly-capability-targets`. +- `zach/warp-cli-shard/appstate-file-drive-views` feeds `zach/warp-cli-v2/appstate-file-drive-views`. +- `zach/warp-cli-shard/metadata-config-mutations` feeds `zach/warp-cli-v2/metadata-config-mutations`. +- `zach/warp-cli-shard/drive-data-mutations` feeds `zach/warp-cli-v2/drive-data-mutations`. +- `zach/warp-cli-shard/execution-underlying` feeds `zach/warp-cli-v2/execution-underlying`. +- `zach/warp-cli-shard/cli-catalog-docs` feeds `zach/warp-cli-v2/cli-catalog-docs`. Each cloud shard prompt should include: - The exact base branch and shard branch name. -- Owned command families. +- The v2 review branch it is intended to feed. +- Owned command families and permission categories. - Owned files/modules. - Files/modules the shard must not edit without calling out the need for integration. -- Required permission categories and authenticated-user behavior. - Selector resolution requirements. - Validation commands and expected tests. - A handoff requirement: report branch name, changed files, implemented commands, permission decisions, validation results, and any conflicts or follow-ups. -Default file ownership for shards: -- Metadata shards own metadata handler/protocol/CLI modules and metadata tests. -- Data shards own data handler/protocol/CLI modules and underlying-data permission tests. -- Layout shards own layout handler/protocol/CLI modules and app-state mutation tests. -- Authenticated-scripting shards own auth broker/protocol/CLI modules, Settings > Scripting authenticated grant controls, API-key storage/exchange tests, and authenticated-user denial tests. -- Input/session shards own input/session handler/protocol/CLI modules and tests proving staging does not submit or execute unless the branch explicitly owns `input.run`. -- Settings/surface shards own settings/surface handler/protocol/CLI modules and metadata/configuration mutation tests. -- Drive data shards own Drive underlying-data handler/protocol/CLI modules, authenticated-user/API-key enforcement tests, personal-to-team sharing tests, and disposable-resource tests. -- Execution-underlying shards own `input.run` and typed workflow execution handler/protocol/CLI modules, audit tests, and denial tests proving accepted-command and agent-prompt submission remain unavailable. -The lead integrator merges or cherry-picks accepted shard work into the durable stack with raw git, in review order. Shard branches should not become independent long-lived PRs unless the lead intentionally splits review further; their default purpose is to feed the durable stack while preserving parallel implementation and focused context windows. +The lead integrator owns the final `zach/warp-cli-v2/fanin-finalize` branch, where cross-shard cleanup, conflict-resolution preservation, validation fixes, and stack-wide consistency checks land. ```mermaid flowchart LR - Core["zach/warp-cli-core-foundation<br/>specs + contracts + bridge"] --> ROMeta["zach/warp-cli-readonly-metadata<br/>structural reads"] - ROMeta --> ROData["zach/warp-cli-readonly-data-settings<br/>data/settings reads"] - ROData --> Auth["zach/warp-cli-authenticated-scripting<br/>terminal proof + API key auth"] - Auth --> MutLayout["zach/warp-cli-mutating-layout<br/>layout mutations"] - MutLayout --> MutInput["zach/warp-cli-mutating-input-settings-surfaces<br/>input/settings/surfaces"] - MutInput --> MutData["zach/warp-cli-mutating-drive-data<br/>Drive data"] - MutData --> MutExec["zach/warp-cli-mutating-execution-underlying<br/>execution actions"] - ROMetaShard["shard/readonly-metadata"] --> ROMeta - RODataShard["shard/readonly-data"] --> ROData - ROSettingsShard["shard/readonly-settings-docs"] --> ROData - MutLayoutShard["shard/mutating-window-tab-pane"] --> MutLayout - MutInputShard["shard/mutating-input-session"] --> MutInput - MutSettingsShard["shard/mutating-settings-surfaces"] --> MutInput - AuthShard["shard/authenticated-scripting"] --> Auth - MutDataShard["shard/mutating-drive-data"] --> MutData - MutExecShard["shard/mutating-execution-underlying"] --> MutExec + Contract["zach/warp-cli-v2/contract-spec-sync<br/>specs + contracts + foundation"] --> Auth["zach/warp-cli-v2/auth-security<br/>auth + security gates"] + Auth --> Readonly["zach/warp-cli-v2/readonly-capability-targets<br/>metadata + selectors"] + Readonly --> Views["zach/warp-cli-v2/appstate-file-drive-views<br/>approved read views"] + Views --> MetaMut["zach/warp-cli-v2/metadata-config-mutations<br/>config mutations"] + MetaMut --> DriveMut["zach/warp-cli-v2/drive-data-mutations<br/>Drive data mutations"] + DriveMut --> Exec["zach/warp-cli-v2/execution-underlying<br/>execution actions"] + Exec --> Docs["zach/warp-cli-v2/cli-catalog-docs<br/>CLI catalog + docs"] + Docs --> Final["zach/warp-cli-v2/fanin-finalize<br/>cleanup + validation"] + AuthShard["shard/auth-security"] --> Auth + ReadonlyShard["shard/readonly-capability-targets"] --> Readonly + ViewsShard["shard/appstate-file-drive-views"] --> Views + MetaShard["shard/metadata-config-mutations"] --> MetaMut + DriveShard["shard/drive-data-mutations"] --> DriveMut + ExecShard["shard/execution-underlying"] --> Exec + DocsShard["shard/cli-catalog-docs"] --> Docs ``` ## Risks and mitigations - Fixed-port server assumptions: @@ -600,7 +554,7 @@ flowchart LR - Over-broad settings mutation: - Mitigation: allowlisted setting keys only, with private/debug/derived settings rejected. - Command execution risk: - - Mitigation: keep `input.run`/typed workflow execution in the catalog but implement it only in `zach/warp-cli-mutating-execution-underlying` after authenticated scripting identity, underlying-data-mutation permission, deterministic target resolution, and audit coverage are in place. + - Mitigation: keep `input.run`/typed workflow execution in the catalog but implement it only in `zach/warp-cli-v2/execution-underlying` after authenticated scripting identity, underlying-data-mutation permission, deterministic target resolution, and audit coverage are in place. - Packaging churn due to provisional executable naming: - Mitigation: document `warpctrl` as provisional and settle final aliases before broad release workflow rollout. - Heavyweight CLI startup caused by sharing the GUI binary's launch path: From 21b07e5eaa9c7e2ede3077f2196fb94d9a173f36 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Tue, 26 May 2026 15:34:00 -0600 Subject: [PATCH 28/48] Update warpctrl screenshot verification guidance Co-Authored-By: Oz <oz-agent@warp.dev> --- specs/warp-control-cli/TECH.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index 893eb610ba..8d94d8f7ba 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -482,6 +482,8 @@ Map tests directly to `PRODUCT.md` behavior. ### Computer-use CLI verification Before any stacked PR is considered ready for review, run an end-to-end computer-use verification pass against a cloud built validation artifact from that branch. The validation artifact is a Warp app build and standalone `warpctrl` binary built by the validation agent from the exact commit under review, with `warp_control_cli` compiled in and `FeatureFlag::WarpControlCli` enabled at runtime. The verifier must launch the built Warp app, enable the Scripting settings needed for the command category under test, and capture screenshots or screen recordings that prove both the terminal invocation and the user-visible result of each basic command family. Every `warpctrl` invocation executed during verification must have a durable screenshot captured immediately after the command runs, showing the exact command line and full terminal output in either pretty or JSON mode. Screenshots should be saved as durable artifacts and named by branch, invocation context, command family, command name, and whether the image shows terminal output or app UI state. +Verification screenshots should make the cause and effect visible in a single image whenever possible. The preferred composition is a staggered two-window layout where the terminal running the `warpctrl` command remains visible and unobscured, while the target Warp window or terminal is also visible enough to prove the UI state before or after the command. For outside-Warp invocations, use one external terminal window for the CLI command and one built Warp app window, staggered so the screenshot shows both the command/output and the Warp UI result. For inside-Warp invocations, use two Warp terminal windows or panes when possible: one Warp terminal running `warpctrl` and a second Warp terminal or Warp window showing the target/result state, staggered so both are visible in the same screenshot. Avoid screenshots that show only the CLI terminal or only the Warp UI when a combined view can be captured. +Before/after screenshots for visible mutations should preserve the same staggered layout so reviewers can compare the command context and UI state directly. If a single combined screenshot is not possible because of window-manager, display-size, or focus limitations, the verifier must capture paired screenshots with the same ordinal: one terminal-output screenshot that fully shows the command and output, and one UI screenshot that shows the resulting Warp state. The manifest entry should explain why the combined composition was not possible. Screenshots should not crop out the command, exit status, selected Warp target, or relevant visible UI effect. The verifier must exercise every invocation context implemented by the branch under review. For the current foundation branch, inside-Warp verification means proving `InsideWarp` requests are rejected and no inside-Warp Settings > Scripting controls are exposed. Once verified Warp-terminal support is implemented, the verifier must also run `warpctrl` from a terminal session inside the feature-flag-enabled Warp app and prove the app-issued execution-context proof is accepted and inside-Warp settings gate command categories. The outside-Warp path must run the same `warpctrl` binary from an external terminal or automation shell outside the built Warp app and prove outside-Warp control is default-off, then works only after enabling outside-Warp scripting and the required granular permissions in Settings > Scripting. The verification matrix should cover every implemented command in `PRODUCT.md` at least once in JSON output mode. The verifier must capture a terminal-output screenshot for every command invocation, including successful calls, expected denials, unsupported stubs, and fail-closed policy gates. Where there is a visible UI effect, the verifier must also capture app UI screenshots after the command runs, and before the command when that is needed to prove a before/after state transition. At minimum: - read-only metadata commands show successful CLI output in a terminal screenshot and, for active/focus/list commands, a visible target that matches the output; From d26ed039f4bb8e667acbd799bf8857b34992f42a Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Tue, 26 May 2026 16:34:02 -0600 Subject: [PATCH 29/48] Clarify warpctrl visual validation requirements Co-Authored-By: Oz <oz-agent@warp.dev> --- specs/warp-control-cli/TECH.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index 8d94d8f7ba..1b261a9c0b 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -484,6 +484,8 @@ Before any stacked PR is considered ready for review, run an end-to-end computer The verifier must launch the built Warp app, enable the Scripting settings needed for the command category under test, and capture screenshots or screen recordings that prove both the terminal invocation and the user-visible result of each basic command family. Every `warpctrl` invocation executed during verification must have a durable screenshot captured immediately after the command runs, showing the exact command line and full terminal output in either pretty or JSON mode. Screenshots should be saved as durable artifacts and named by branch, invocation context, command family, command name, and whether the image shows terminal output or app UI state. Verification screenshots should make the cause and effect visible in a single image whenever possible. The preferred composition is a staggered two-window layout where the terminal running the `warpctrl` command remains visible and unobscured, while the target Warp window or terminal is also visible enough to prove the UI state before or after the command. For outside-Warp invocations, use one external terminal window for the CLI command and one built Warp app window, staggered so the screenshot shows both the command/output and the Warp UI result. For inside-Warp invocations, use two Warp terminal windows or panes when possible: one Warp terminal running `warpctrl` and a second Warp terminal or Warp window showing the target/result state, staggered so both are visible in the same screenshot. Avoid screenshots that show only the CLI terminal or only the Warp UI when a combined view can be captured. Before/after screenshots for visible mutations should preserve the same staggered layout so reviewers can compare the command context and UI state directly. If a single combined screenshot is not possible because of window-manager, display-size, or focus limitations, the verifier must capture paired screenshots with the same ordinal: one terminal-output screenshot that fully shows the command and output, and one UI screenshot that shows the resulting Warp state. The manifest entry should explain why the combined composition was not possible. Screenshots should not crop out the command, exit status, selected Warp target, or relevant visible UI effect. +Before every computer-use scenario, the verifier must explicitly ask and answer, "What is the best way to show the impact of this CLI command?" The verifier should then put Warp into a state where the expected effect is clearly visible before running the command. For example, syntax-highlighting changes should start with recognizable text in the input editor that will visibly change; font-size and zoom changes should start with enough terminal text or UI chrome to compare scale; tab or pane rename/color commands should keep the affected tab or pane label visible; app-state mutation commands should make the target workspace, tab, pane, input box, or surface visible; and denial paths should show the relevant Settings > Scripting state or target state that makes the denial meaningful. Each manifest entry for a visible or user-facing command should describe the chosen proof setup, the expected visual effect, and any setup screenshot used to establish the before state. +After each command that has a visible or user-facing result, the verifier must use computer vision on the captured screenshot or screen recording to inspect whether the visible Warp state matches the expected effect. The verifier should record the visual inspection result in the manifest, including unexpected UI changes, missing visual evidence, ambiguous screenshots, focus/onboarding artifacts, or differences between JSON success and the visible app state. JSON success alone is not sufficient for visible-effect validation; if the screenshot does not clearly prove the expected effect, the case should be marked failed or blocked with an explanation, even when the CLI response is successful. The verifier must exercise every invocation context implemented by the branch under review. For the current foundation branch, inside-Warp verification means proving `InsideWarp` requests are rejected and no inside-Warp Settings > Scripting controls are exposed. Once verified Warp-terminal support is implemented, the verifier must also run `warpctrl` from a terminal session inside the feature-flag-enabled Warp app and prove the app-issued execution-context proof is accepted and inside-Warp settings gate command categories. The outside-Warp path must run the same `warpctrl` binary from an external terminal or automation shell outside the built Warp app and prove outside-Warp control is default-off, then works only after enabling outside-Warp scripting and the required granular permissions in Settings > Scripting. The verification matrix should cover every implemented command in `PRODUCT.md` at least once in JSON output mode. The verifier must capture a terminal-output screenshot for every command invocation, including successful calls, expected denials, unsupported stubs, and fail-closed policy gates. Where there is a visible UI effect, the verifier must also capture app UI screenshots after the command runs, and before the command when that is needed to prove a before/after state transition. At minimum: - read-only metadata commands show successful CLI output in a terminal screenshot and, for active/focus/list commands, a visible target that matches the output; From 138ada98e8638fd57cc921c188051b96f942041a Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Tue, 26 May 2026 19:04:13 -0600 Subject: [PATCH 30/48] Clarify warpctrl catalog parser parity Co-Authored-By: Oz <oz-agent@warp.dev> --- specs/warp-control-cli/PRODUCT.md | 1 + specs/warp-control-cli/TECH.md | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/specs/warp-control-cli/PRODUCT.md b/specs/warp-control-cli/PRODUCT.md index 1052c7c38b..725241019b 100644 --- a/specs/warp-control-cli/PRODUCT.md +++ b/specs/warp-control-cli/PRODUCT.md @@ -199,6 +199,7 @@ Persistent settings changes, Warp Drive creation or sharing, cross-app preferenc 35. The protocol transport should be designed so that the default target is localhost but the CLI can be extended in the future to target remote URLs (e.g., a Warp instance on another machine or a hosted control endpoint). This is not in scope for the first implementation but should not be precluded by the architecture. ## API command surface The public `warpctrl` API is organized around nouns that map to stable user-facing entities. Command names are intentionally not a dump of every internal `WorkspaceAction`, `TerminalAction`, keybinding, or command-palette binding. Internal actions inform the catalog, but a command is added only when it has a stable user-facing behavior, typed parameters, deterministic target resolution, and an explicit risk classification. +Catalog support status is part of the public API contract. An action reported as `implemented` by `warpctrl action list --implemented-only`, `warpctrl capability list --implemented-only`, or app discovery metadata must be reachable through a standalone `warpctrl ...` parser route, represented in generated help/completions/docs, and backed by an app-side bridge handler in the selected app build. Planned actions without that complete path must be reported as stubs or planned entries, even if an internal app handler already exists. ### State and data taxonomy The product surface must distinguish what kind of state a command touches. This distinction is part of the public API and the permission model, not just an implementation detail. - **Metadata reads** inspect app structure or configuration metadata without exposing user content: instances, windows, tabs, panes, sessions, capability metadata, action metadata, keybinding metadata, theme names, setting keys, current project identity, and other structural state. diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index 1b261a9c0b..5fa4e459dc 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -447,6 +447,11 @@ sequenceDiagram ``` ## Testing and validation Map tests directly to `PRODUCT.md` behavior. +- Catalog/parser implementation-status invariant: + - `ActionImplementationStatus::Implemented` means the action is complete enough for standalone CLI users in the selected build: it has a parseable `warpctrl ...` command route, generated help/completion/docs coverage, a protocol parameter mapping, and an app-side bridge handler. + - Catalog entries that have only an internal app handler, only protocol metadata, or only a planned product command must remain `Stub` until the standalone CLI route and generated surfaces ship. + - Tests must enumerate the implemented catalog and prove each implemented action has at least one parseable standalone CLI example that maps to the same `ActionKind`. + - Tests must also prove each shipped parser route maps to an allowlisted catalog action and that help/completion generation includes the implemented route. `action list --implemented-only`, `capability list --implemented-only`, discovery metadata, shell completions, generated docs, and app-side bridge support must not drift silently from one another. - Security architecture: - Protected enablement tests proving outside-Warp control defaults off, disabled outside-Warp context rejects credential issuance, sensitive discovery, and mutating requests with `local_control_disabled`, and current inside-Warp credential requests are rejected with `execution_context_not_allowed`. - Tests proving discovery in disabled state exposes no actionable endpoint authority or credential reference. From 8f7bbd1afe6184664bcf7f60c410d65626f9e545 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Wed, 27 May 2026 11:42:52 -0600 Subject: [PATCH 31/48] Support explicit warpctrl window targets for tab create Co-Authored-By: Oz <oz-agent@warp.dev> --- app/src/local_control/handlers/layout.rs | 6 +- app/src/local_control/mod_tests.rs | 37 ++++--- app/src/local_control/resolver.rs | 115 +++++++++++++++++---- crates/local_control/src/protocol_tests.rs | 8 +- 4 files changed, 127 insertions(+), 39 deletions(-) diff --git a/app/src/local_control/handlers/layout.rs b/app/src/local_control/handlers/layout.rs index bcf1d42640..9b72805c37 100644 --- a/app/src/local_control/handlers/layout.rs +++ b/app/src/local_control/handlers/layout.rs @@ -4,7 +4,7 @@ use ::local_control::{ActionKind, ControlError, ErrorCode, InstanceId}; use serde_json::json; use warpui::{ModelContext, TypedActionView}; -use crate::local_control::resolver::{target_window_id, validate_tab_create_target}; +use crate::local_control::resolver::{target_window_id_for_target, validate_tab_create_target}; use crate::local_control::LocalControlBridge; use crate::workspace::{Workspace, WorkspaceAction}; @@ -14,7 +14,7 @@ pub(crate) fn create_terminal_tab( ctx: &mut ModelContext<LocalControlBridge>, ) -> Result<serde_json::Value, ControlError> { validate_tab_create_target(target)?; - let window_id = target_window_id(ctx)?; + let window_id = target_window_id_for_target(ctx, target, ActionKind::TabCreate)?; let workspace = ctx .views_of_type::<Workspace>(window_id) .and_then(|workspaces| workspaces.into_iter().next()) @@ -44,7 +44,7 @@ pub(crate) fn create_terminal_tab( "created": true, "instance_id": instance_id.as_ref().map(|id| id.0.as_str()), "window": { - "selector": "active", + "selector": "target", "id": window_id.to_string(), }, "tab": { diff --git a/app/src/local_control/mod_tests.rs b/app/src/local_control/mod_tests.rs index 3af5d3877f..a394abe1a1 100644 --- a/app/src/local_control/mod_tests.rs +++ b/app/src/local_control/mod_tests.rs @@ -51,7 +51,7 @@ fn settings_with_outside_warp( } #[test] -fn tab_create_accepts_default_and_active_targets() { +fn tab_create_accepts_default_active_and_window_targets() { validate_tab_create_target(&TargetSelector::default()).expect("default target is accepted"); validate_tab_create_target(&TargetSelector { @@ -60,20 +60,35 @@ fn tab_create_accepts_default_and_active_targets() { pane: Some(PaneTarget::Active), }) .expect("active target is accepted"); -} -#[test] -fn tab_create_rejects_concrete_targets() { - let err = validate_tab_create_target(&TargetSelector { + validate_tab_create_target(&TargetSelector { window: Some(WindowTarget::Id { id: WindowSelector("window".to_owned()), }), tab: None, pane: None, }) - .expect_err("concrete window target is rejected"); - assert_eq!(err.code, ErrorCode::StaleTarget); + .expect("window id target is accepted"); + + validate_tab_create_target(&TargetSelector { + window: Some(WindowTarget::Index { index: 0 }), + tab: None, + pane: None, + }) + .expect("window index target is accepted"); + + validate_tab_create_target(&TargetSelector { + window: Some(WindowTarget::Title { + title: "window".to_owned(), + }), + tab: None, + pane: None, + }) + .expect("window title target is accepted"); +} +#[test] +fn tab_create_rejects_concrete_targets() { let err = validate_tab_create_target(&TargetSelector { window: None, tab: Some(TabTarget::Id { @@ -97,14 +112,6 @@ fn tab_create_rejects_concrete_targets() { #[test] fn tab_create_rejects_unsupported_selector_forms() { - let err = validate_tab_create_target(&TargetSelector { - window: Some(WindowTarget::Index { index: 0 }), - tab: None, - pane: None, - }) - .expect_err("indexed window target is rejected"); - assert_eq!(err.code, ErrorCode::InvalidSelector); - let err = validate_tab_create_target(&TargetSelector { window: None, tab: Some(TabTarget::Index { index: 0 }), diff --git a/app/src/local_control/resolver.rs b/app/src/local_control/resolver.rs index 9c5f7ea1a8..97d0ddee96 100644 --- a/app/src/local_control/resolver.rs +++ b/app/src/local_control/resolver.rs @@ -1,23 +1,12 @@ //! Target and parameter validation for the first local-control action slice. use ::local_control::protocol::{PaneTarget, TabTarget, TargetSelector, WindowTarget}; use ::local_control::{ActionKind, ControlError, ErrorCode}; -use warpui::ModelContext; +use warpui::{ModelContext, WindowId}; use crate::local_control::LocalControlBridge; +use crate::workspace::Workspace; pub(crate) fn validate_tab_create_target(target: &TargetSelector) -> Result<(), ControlError> { - if matches!(target.window.as_ref(), Some(WindowTarget::Id { .. })) { - return Err(ControlError::new( - ErrorCode::StaleTarget, - "tab.create cannot resolve the requested window id", - )); - } - if !matches!(target.window.as_ref(), None | Some(WindowTarget::Active)) { - return Err(ControlError::new( - ErrorCode::InvalidSelector, - "tab.create only supports the active window selector", - )); - } if matches!(target.tab.as_ref(), Some(TabTarget::Id { .. })) { return Err(ControlError::new( ErrorCode::StaleTarget, @@ -67,15 +56,41 @@ pub(crate) fn validate_action_params(action: &::local_control::Action) -> Result )) } -pub(super) fn target_window_id( +pub(super) fn target_window_id_for_target( ctx: &mut ModelContext<LocalControlBridge>, -) -> Result<warpui::WindowId, ControlError> { - require_active_window_id(ctx.windows().active_window()) + target: &TargetSelector, + action: ActionKind, +) -> Result<WindowId, ControlError> { + match target.window.as_ref() { + None | Some(WindowTarget::Active) => active_or_single_window_id(ctx, action), + Some(WindowTarget::Id { id }) => ctx + .window_ids() + .find(|window_id| window_id.to_string() == id.0) + .ok_or_else(|| { + ControlError::new( + ErrorCode::StaleTarget, + format!("{} cannot resolve the requested window id", action.as_str()), + ) + }), + Some(WindowTarget::Index { index }) => { + ctx.window_ids().nth(*index as usize).ok_or_else(|| { + ControlError::new( + ErrorCode::StaleTarget, + format!( + "{} cannot resolve the requested window index", + action.as_str() + ), + ) + }) + } + Some(WindowTarget::Title { title }) => target_window_id_by_title(ctx, title, action), + } } +#[cfg(test)] pub(crate) fn require_active_window_id( - active_window: Option<warpui::WindowId>, -) -> Result<warpui::WindowId, ControlError> { + active_window: Option<WindowId>, +) -> Result<WindowId, ControlError> { active_window.ok_or_else(|| { ControlError::new( ErrorCode::MissingTarget, @@ -83,3 +98,67 @@ pub(crate) fn require_active_window_id( ) }) } + +fn active_or_single_window_id( + ctx: &mut ModelContext<LocalControlBridge>, + action: ActionKind, +) -> Result<WindowId, ControlError> { + if let Some(window_id) = ctx.windows().active_window() { + return Ok(window_id); + } + let window_ids = ctx.window_ids().collect::<Vec<_>>(); + match window_ids.as_slice() { + [window_id] => Ok(*window_id), + [] => Err(ControlError::new( + ErrorCode::MissingTarget, + format!("{} requires an active Warp window", action.as_str()), + )), + _ => Err(ControlError::new( + ErrorCode::AmbiguousTarget, + format!( + "{} requires an explicit window selector when no Warp window is active", + action.as_str() + ), + )), + } +} + +fn target_window_id_by_title( + ctx: &mut ModelContext<LocalControlBridge>, + title: &str, + action: ActionKind, +) -> Result<WindowId, ControlError> { + let mut matching = Vec::new(); + for window_id in ctx.window_ids().collect::<Vec<_>>() { + if window_title(window_id, ctx).as_deref() == Some(title) { + matching.push(window_id); + } + } + match matching.as_slice() { + [window_id] => Ok(*window_id), + [] => Err(ControlError::new( + ErrorCode::StaleTarget, + format!( + "{} cannot resolve the requested window title", + action.as_str() + ), + )), + _ => Err(ControlError::new( + ErrorCode::AmbiguousTarget, + format!("{} resolved multiple windows by title", action.as_str()), + )), + } +} + +fn window_title(window_id: WindowId, ctx: &mut ModelContext<LocalControlBridge>) -> Option<String> { + ctx.views_of_type::<Workspace>(window_id) + .and_then(|workspaces| workspaces.into_iter().next()) + .map(|workspace| { + workspace.read(ctx, |workspace, ctx| { + workspace + .active_tab_pane_group() + .as_ref(ctx) + .display_title(ctx) + }) + }) +} diff --git a/crates/local_control/src/protocol_tests.rs b/crates/local_control/src/protocol_tests.rs index 01fb94e1c9..5c8c49bdaf 100644 --- a/crates/local_control/src/protocol_tests.rs +++ b/crates/local_control/src/protocol_tests.rs @@ -137,9 +137,11 @@ fn logged_out_safe_stub_actions_can_advertise_external_context() { ActionImplementationStatus::Stub ); assert!(!metadata.authenticated_user.required); - assert!(metadata - .allowed_invocation_contexts - .contains(&InvocationContext::OutsideWarp)); + assert!( + metadata + .allowed_invocation_contexts + .contains(&InvocationContext::OutsideWarp) + ); } #[test] From cf9c747d402af8b53c958612104f6ed1fc9788f8 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Wed, 27 May 2026 16:34:23 -0600 Subject: [PATCH 32/48] Update Warp Control CLI contract foundation Simplify local control settings, tighten scoped grant metadata, and restructure the local-control action catalog into command-family groups. Update the Warp Control CLI specs to match the current first-slice behavior and validation surface. Co-Authored-By: Oz <oz-agent@warp.dev> --- app/src/local_control/bridge.rs | 15 +- app/src/local_control/handlers/layout.rs | 54 +- app/src/local_control/handlers/metadata.rs | 95 +- app/src/local_control/mod.rs | 32 +- app/src/local_control/mod_tests.rs | 81 +- app/src/local_control/permissions.rs | 90 +- app/src/local_control/resolver.rs | 6 +- app/src/settings/local_control.rs | 152 +- app/src/settings/local_control_tests.rs | 102 +- app/src/settings_view/scripting_page.rs | 339 +--- crates/local_control/src/auth.rs | 19 - crates/local_control/src/auth_tests.rs | 28 +- crates/local_control/src/catalog.rs | 1368 ++++------------- crates/local_control/src/protocol.rs | 8 +- crates/local_control/src/protocol_tests.rs | 2 +- crates/warp_cli/src/local_control/commands.rs | 17 +- crates/warp_features/src/lib.rs | 29 +- specs/warp-control-cli/PRODUCT.md | 54 +- specs/warp-control-cli/SECURITY.md | 99 +- specs/warp-control-cli/TECH.md | 67 +- 20 files changed, 750 insertions(+), 1907 deletions(-) diff --git a/app/src/local_control/bridge.rs b/app/src/local_control/bridge.rs index 022f9b2869..0b97f54255 100644 --- a/app/src/local_control/bridge.rs +++ b/app/src/local_control/bridge.rs @@ -76,7 +76,10 @@ impl LocalControlBridge { { return ResponseEnvelope::error(request.request_id, error); } - ResponseEnvelope::ok(request.request_id, metadata::instance(&self.instance_id)) + match metadata::instance(&self.instance_id) { + Ok(data) => ResponseEnvelope::ok(request.request_id, data), + Err(error) => ResponseEnvelope::error(request.request_id, error), + } } ActionKind::AppPing => { if let Err(error) = @@ -84,7 +87,10 @@ impl LocalControlBridge { { return ResponseEnvelope::error(request.request_id, error); } - ResponseEnvelope::ok(request.request_id, metadata::ping(&self.instance_id)) + match metadata::ping(&self.instance_id) { + Ok(data) => ResponseEnvelope::ok(request.request_id, data), + Err(error) => ResponseEnvelope::error(request.request_id, error), + } } ActionKind::AppVersion => { if let Err(error) = @@ -92,7 +98,10 @@ impl LocalControlBridge { { return ResponseEnvelope::error(request.request_id, error); } - ResponseEnvelope::ok(request.request_id, metadata::version(&self.instance_id)) + match metadata::version(&self.instance_id) { + Ok(data) => ResponseEnvelope::ok(request.request_id, data), + Err(error) => ResponseEnvelope::error(request.request_id, error), + } } ActionKind::TabCreate => { if let Err(error) = diff --git a/app/src/local_control/handlers/layout.rs b/app/src/local_control/handlers/layout.rs index 9b72805c37..8d9cbbf9ac 100644 --- a/app/src/local_control/handlers/layout.rs +++ b/app/src/local_control/handlers/layout.rs @@ -1,12 +1,33 @@ //! Layout mutation handlers for local-control actions. use ::local_control::protocol::TargetSelector; use ::local_control::{ActionKind, ControlError, ErrorCode, InstanceId}; -use serde_json::json; +use serde::Serialize; use warpui::{ModelContext, TypedActionView}; use crate::local_control::resolver::{target_window_id_for_target, validate_tab_create_target}; use crate::local_control::LocalControlBridge; use crate::workspace::{Workspace, WorkspaceAction}; +#[derive(Serialize)] +struct TabCreateResponse<'a> { + action: &'static str, + created: bool, + instance_id: Option<&'a str>, + window: TargetWindowResponse, + tab: TabCountsResponse, +} + +#[derive(Serialize)] +struct TargetWindowResponse { + selector: &'static str, + id: String, +} + +#[derive(Serialize)] +struct TabCountsResponse { + previous_count: usize, + count: usize, + active_index: usize, +} pub(crate) fn create_terminal_tab( instance_id: &Option<InstanceId>, @@ -39,18 +60,25 @@ pub(crate) fn create_terminal_tab( workspace.active_tab_index(), ) }); - Ok(json!({ - "action": ActionKind::TabCreate.as_str(), - "created": true, - "instance_id": instance_id.as_ref().map(|id| id.0.as_str()), - "window": { - "selector": "target", - "id": window_id.to_string(), + serde_json::to_value(TabCreateResponse { + action: ActionKind::TabCreate.as_str(), + created: true, + instance_id: instance_id.as_ref().map(|id| id.0.as_str()), + window: TargetWindowResponse { + selector: "target", + id: window_id.to_string(), }, - "tab": { - "previous_count": previous_tab_count, - "count": tab_count, - "active_index": active_tab_index, + tab: TabCountsResponse { + previous_count: previous_tab_count, + count: tab_count, + active_index: active_tab_index, }, - })) + }) + .map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to serialize local-control tab.create response", + err.to_string(), + ) + }) } diff --git a/app/src/local_control/handlers/metadata.rs b/app/src/local_control/handlers/metadata.rs index ff6270a9df..cd69785238 100644 --- a/app/src/local_control/handlers/metadata.rs +++ b/app/src/local_control/handlers/metadata.rs @@ -1,37 +1,80 @@ //! Metadata response builders for local-control introspection actions. -use ::local_control::{ActionKind, InstanceId, PROTOCOL_VERSION}; -use serde_json::json; +use ::local_control::{ + ActionKind, ActionMetadata, ControlError, ErrorCode, InstanceId, PROTOCOL_VERSION, +}; +use serde::Serialize; use warp_core::channel::ChannelState; +#[derive(Serialize)] +struct InstanceResponse<'a> { + action: &'static str, + instance_id: Option<&'a str>, + pid: u32, + channel: String, + app_id: String, + app_version: Option<&'static str>, + protocol_version: u32, + actions: Vec<ActionMetadata>, +} + +#[derive(Serialize)] +struct PingResponse<'a> { + action: &'static str, + ok: bool, + instance_id: Option<&'a str>, + protocol_version: u32, +} + +#[derive(Serialize)] +struct VersionResponse<'a> { + action: &'static str, + instance_id: Option<&'a str>, + protocol_version: u32, + channel: String, + app_id: String, + app_version: Option<&'static str>, +} + +pub(crate) fn instance( + instance_id: &Option<InstanceId>, +) -> Result<serde_json::Value, ControlError> { + to_json_value(InstanceResponse { + action: ActionKind::InstanceList.as_str(), + instance_id: instance_id.as_ref().map(|id| id.0.as_str()), + pid: std::process::id(), + channel: ChannelState::channel().to_string(), + app_id: ChannelState::app_id().to_string(), + app_version: ChannelState::app_version(), + protocol_version: PROTOCOL_VERSION, + actions: ActionKind::implemented_metadata(), + }) +} -pub(crate) fn instance(instance_id: &Option<InstanceId>) -> serde_json::Value { - json!({ - "action": ActionKind::InstanceList.as_str(), - "instance_id": instance_id.as_ref().map(|id| id.0.as_str()), - "pid": std::process::id(), - "channel": ChannelState::channel().to_string(), - "app_id": ChannelState::app_id().to_string(), - "app_version": ChannelState::app_version(), - "protocol_version": PROTOCOL_VERSION, - "actions": ActionKind::implemented_metadata(), +pub(crate) fn ping(instance_id: &Option<InstanceId>) -> Result<serde_json::Value, ControlError> { + to_json_value(PingResponse { + action: ActionKind::AppPing.as_str(), + ok: true, + instance_id: instance_id.as_ref().map(|id| id.0.as_str()), + protocol_version: PROTOCOL_VERSION, }) } -pub(crate) fn ping(instance_id: &Option<InstanceId>) -> serde_json::Value { - json!({ - "action": ActionKind::AppPing.as_str(), - "ok": true, - "instance_id": instance_id.as_ref().map(|id| id.0.as_str()), - "protocol_version": PROTOCOL_VERSION, +pub(crate) fn version(instance_id: &Option<InstanceId>) -> Result<serde_json::Value, ControlError> { + to_json_value(VersionResponse { + action: ActionKind::AppVersion.as_str(), + instance_id: instance_id.as_ref().map(|id| id.0.as_str()), + protocol_version: PROTOCOL_VERSION, + channel: ChannelState::channel().to_string(), + app_id: ChannelState::app_id().to_string(), + app_version: ChannelState::app_version(), }) } -pub(crate) fn version(instance_id: &Option<InstanceId>) -> serde_json::Value { - json!({ - "action": ActionKind::AppVersion.as_str(), - "instance_id": instance_id.as_ref().map(|id| id.0.as_str()), - "protocol_version": PROTOCOL_VERSION, - "channel": ChannelState::channel().to_string(), - "app_id": ChannelState::app_id().to_string(), - "app_version": ChannelState::app_version(), +fn to_json_value<T: Serialize>(response: T) -> Result<serde_json::Value, ControlError> { + serde_json::to_value(response).map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to serialize local-control metadata response", + err.to_string(), + ) }) } diff --git a/app/src/local_control/mod.rs b/app/src/local_control/mod.rs index a1fd7b0bf9..2de66b8050 100644 --- a/app/src/local_control/mod.rs +++ b/app/src/local_control/mod.rs @@ -7,23 +7,21 @@ //! short-lived scoped credential from `/v1/control/credentials`; the localhost //! server running inside Warp checks the feature flag, requested invocation //! context, action metadata, execution-context proof, and Settings > Scripting -//! permissions before minting a bearer token. This foundation branch currently -//! supports only outside-Warp credential requests; verified inside-Warp -//! terminal credentials remain future work until the app-issued proof broker is +//! permissions before minting a bearer token. Outside-Warp clients use the +//! localhost credential broker directly; verified inside-Warp terminal +//! credentials remain future work until the app-issued proof broker is //! implemented. The client then presents that bearer token to `/v1/control`, //! where the server looks up the in-memory grant, verifies it still matches the //! requested action, and only then hands the request to the main-thread //! `LocalControlBridge`. //! -//! The Settings > Scripting gates used here are provisional foundation-branch -//! authority. They are private and local-only, but private preferences are not -//! equivalent to tamper-resistant secure storage; before outside-Warp control -//! or broader grants ship, the authoritative enablement bits should move to -//! protected storage where the platform supports it. +//! The Settings > Scripting gates used here are private, local-only settings. +//! Broader grants should keep using private settings unless a future action +//! class requires stronger platform-specific storage guarantees. //! -//! This foundation branch intentionally keeps raw bearer tokens out of -//! discovery records: discovery only exposes endpoint metadata and credential -//! broker references when outside-Warp control is enabled. +//! Discovery records never include raw bearer tokens: discovery only exposes +//! endpoint metadata and credential broker references when outside-Warp control +//! is enabled. mod bridge; mod handlers; mod permissions; @@ -64,7 +62,7 @@ struct ControlServerState { pub struct LocalControlServer { _runtime: Option<tokio::runtime::Runtime>, control_endpoint: Option<ControlEndpoint>, - _registered_instance: Option<RegisteredInstance>, + registered_instance: Option<RegisteredInstance>, } impl Entity for LocalControlServer { @@ -79,7 +77,7 @@ impl LocalControlServer { return Self { _runtime: None, control_endpoint: None, - _registered_instance: None, + registered_instance: None, }; } match Self::start(ctx) { @@ -101,7 +99,7 @@ impl LocalControlServer { Self { _runtime: None, control_endpoint: None, - _registered_instance: None, + registered_instance: None, } } } @@ -164,7 +162,7 @@ impl LocalControlServer { Ok(Self { _runtime: Some(runtime), control_endpoint: Some(control_endpoint), - _registered_instance: Some(registered_instance), + registered_instance: Some(registered_instance), }) } @@ -175,7 +173,7 @@ impl LocalControlServer { let Some(control_endpoint) = self.control_endpoint.clone() else { return Ok(()); }; - let Some(registered_instance) = &mut self._registered_instance else { + let Some(registered_instance) = &mut self.registered_instance else { return Ok(()); }; let mut record = discovery_record_for_settings(ctx, control_endpoint); @@ -414,7 +412,7 @@ async fn handle_control_request( #[cfg(test)] pub(crate) use permissions::{ - capabilities, ensure_settings_allow_action, outside_warp_action_enabled_for_settings, + capabilities, ensure_settings_allow_action, outside_warp_control_enabled_for_settings, }; #[cfg(test)] pub(crate) use resolver::{ diff --git a/app/src/local_control/mod_tests.rs b/app/src/local_control/mod_tests.rs index a394abe1a1..ce5f104243 100644 --- a/app/src/local_control/mod_tests.rs +++ b/app/src/local_control/mod_tests.rs @@ -9,47 +9,17 @@ use warp_core::features::FeatureFlag; use super::{ capabilities, ensure_feature_enabled, ensure_settings_allow_action, - outside_warp_action_enabled_for_settings, require_active_window_id, validate_action_params, + outside_warp_control_enabled_for_settings, require_active_window_id, validate_action_params, validate_tab_create_target, }; -use crate::settings::{ - AllowOutsideWarpAppStateMutations, AllowOutsideWarpControl, - AllowOutsideWarpMetadataConfigurationMutations, AllowOutsideWarpMetadataReads, - AllowOutsideWarpUnderlyingDataMutations, AllowOutsideWarpUnderlyingDataReads, - LocalControlSettings, -}; +use crate::settings::{LocalControlMode, LocalControlModeSetting, LocalControlSettings}; -fn settings_with_values( - outside_enabled: bool, - outside_metadata_reads: bool, - outside_app_state_mutations: bool, -) -> LocalControlSettings { +fn settings_with_mode(mode: LocalControlMode) -> LocalControlSettings { LocalControlSettings { - allow_outside_warp_control: AllowOutsideWarpControl::new(Some(outside_enabled)), - allow_outside_warp_metadata_reads: AllowOutsideWarpMetadataReads::new(Some( - outside_metadata_reads, - )), - allow_outside_warp_underlying_data_reads: AllowOutsideWarpUnderlyingDataReads::new(Some( - false, - )), - allow_outside_warp_app_state_mutations: AllowOutsideWarpAppStateMutations::new(Some( - outside_app_state_mutations, - )), - allow_outside_warp_metadata_configuration_mutations: - AllowOutsideWarpMetadataConfigurationMutations::new(Some(false)), - allow_outside_warp_underlying_data_mutations: AllowOutsideWarpUnderlyingDataMutations::new( - Some(false), - ), + local_control_mode: LocalControlModeSetting::new(Some(mode)), } } -fn settings_with_outside_warp( - outside_control: bool, - outside_app_state_mutations: bool, -) -> LocalControlSettings { - settings_with_values(outside_control, false, outside_app_state_mutations) -} - #[test] fn tab_create_accepts_default_active_and_window_targets() { validate_tab_create_target(&TargetSelector::default()).expect("default target is accepted"); @@ -135,18 +105,15 @@ fn capabilities_advertises_only_first_slice_core_actions() { } #[test] -fn outside_warp_discovery_requires_context_and_action_permission() { - assert!(!outside_warp_action_enabled_for_settings( - &settings_with_outside_warp(false, true), - ActionKind::TabCreate +fn outside_warp_discovery_requires_everywhere_mode() { + assert!(!outside_warp_control_enabled_for_settings( + &settings_with_mode(LocalControlMode::Disabled) )); - assert!(!outside_warp_action_enabled_for_settings( - &settings_with_outside_warp(true, false), - ActionKind::TabCreate + assert!(!outside_warp_control_enabled_for_settings( + &settings_with_mode(LocalControlMode::EnabledWithinWarp) )); - assert!(outside_warp_action_enabled_for_settings( - &settings_with_outside_warp(true, true), - ActionKind::TabCreate + assert!(outside_warp_control_enabled_for_settings( + &settings_with_mode(LocalControlMode::EnabledEverywhere) )); } @@ -170,21 +137,21 @@ fn feature_flag_disabled_denies_local_control() { } #[test] -fn disabled_outside_warp_denies_before_granular_permission() { - let settings = settings_with_values(false, true, true); +fn outside_warp_requires_everywhere_mode() { + let settings = settings_with_mode(LocalControlMode::EnabledWithinWarp); let err = ensure_settings_allow_action( &settings, InvocationContext::OutsideWarp, ActionKind::TabCreate, ) - .expect_err("outside-Warp parent context is disabled"); + .expect_err("outside-Warp local control is disabled"); assert_eq!(err.code, ErrorCode::LocalControlDisabled); } #[test] fn inside_warp_context_is_not_implemented() { - let settings = settings_with_values(true, true, true); + let settings = settings_with_mode(LocalControlMode::EnabledWithinWarp); let err = ensure_settings_allow_action( &settings, @@ -196,16 +163,26 @@ fn inside_warp_context_is_not_implemented() { } #[test] -fn disabled_granular_permission_denies_with_insufficient_permissions() { - let settings = settings_with_values(true, true, false); +fn disabled_mode_denies_inside_warp_context() { + let settings = settings_with_mode(LocalControlMode::Disabled); let err = ensure_settings_allow_action( &settings, + InvocationContext::InsideWarp, + ActionKind::TabCreate, + ) + .expect_err("inside-Warp local control is disabled"); + assert_eq!(err.code, ErrorCode::LocalControlDisabled); +} + +#[test] +fn enabled_everywhere_allows_outside_warp_context() { + ensure_settings_allow_action( + &settings_with_mode(LocalControlMode::EnabledEverywhere), InvocationContext::OutsideWarp, ActionKind::TabCreate, ) - .expect_err("read-write permission is disabled"); - assert_eq!(err.code, ErrorCode::InsufficientPermissions); + .expect("outside-Warp local control is enabled"); } #[test] diff --git a/app/src/local_control/permissions.rs b/app/src/local_control/permissions.rs index 7586c1bf7a..93f9465429 100644 --- a/app/src/local_control/permissions.rs +++ b/app/src/local_control/permissions.rs @@ -1,7 +1,7 @@ -//! Permission checks that map protocol action metadata onto local settings. +//! Permission checks that map invocation context onto local settings. use crate::features::FeatureFlag; -use crate::settings::{LocalControlPermissionCategory, LocalControlSettings}; -use ::local_control::{ActionKind, ControlError, ErrorCode, InvocationContext, PermissionCategory}; +use crate::settings::LocalControlSettings; +use ::local_control::{ActionKind, ControlError, ErrorCode, InvocationContext}; use warpui::{ModelContext, SingletonEntity}; use crate::local_control::LocalControlBridge; @@ -21,19 +21,8 @@ pub(super) fn ensure_feature_enabled() -> Result<(), ControlError> { } #[cfg(test)] -pub(crate) fn outside_warp_action_enabled_for_settings( - settings: &LocalControlSettings, - action: ActionKind, -) -> bool { - outside_warp_permission_enabled_for_settings(settings, action.metadata().permission_category) -} - -#[cfg(test)] -fn outside_warp_permission_enabled_for_settings( - settings: &LocalControlSettings, - permission: PermissionCategory, -) -> bool { - settings.allows_outside_warp(local_permission(permission)) +pub(crate) fn outside_warp_control_enabled_for_settings(settings: &LocalControlSettings) -> bool { + settings.outside_warp_control_enabled() } #[cfg(test)] @@ -44,22 +33,6 @@ pub(crate) fn capabilities() -> Vec<ActionKind> { .collect() } -fn local_permission(permission: PermissionCategory) -> LocalControlPermissionCategory { - match permission { - PermissionCategory::ReadMetadata => LocalControlPermissionCategory::MetadataReads, - PermissionCategory::ReadUnderlyingData => { - LocalControlPermissionCategory::UnderlyingDataReads - } - PermissionCategory::MutateAppState => LocalControlPermissionCategory::AppStateMutations, - PermissionCategory::MutateMetadataConfiguration => { - LocalControlPermissionCategory::MetadataConfigurationMutations - } - PermissionCategory::MutateUnderlyingData => { - LocalControlPermissionCategory::UnderlyingDataMutations - } - } -} - pub(super) fn ensure_action_allowed( context: InvocationContext, action: ActionKind, @@ -74,27 +47,36 @@ pub(crate) fn ensure_settings_allow_action( context: InvocationContext, action: ActionKind, ) -> Result<(), ControlError> { - if context == InvocationContext::InsideWarp { - return Err(ControlError::new( - ErrorCode::ExecutionContextNotAllowed, - "inside-Warp local-control grants are not implemented", - )); - } - if !settings.outside_warp_control_enabled() { - return Err(ControlError::new( - ErrorCode::LocalControlDisabled, - "local control is disabled for this invocation context", - )); - } - let permission = local_permission(action.metadata().permission_category); - if !settings.outside_warp_permission_enabled(permission) { - return Err(ControlError::new( - ErrorCode::InsufficientPermissions, - format!( - "{} requires a local-control permission that is disabled", - action.as_str() - ), - )); + match context { + InvocationContext::InsideWarp => { + if !settings.inside_warp_control_enabled() { + return Err(ControlError::new( + ErrorCode::LocalControlDisabled, + format!( + "{} is disabled for inside-Warp local control", + action.as_str() + ), + )); + } + Err(ControlError::new( + ErrorCode::ExecutionContextNotAllowed, + format!( + "{} cannot run from inside-Warp local control until verified terminal proofs are implemented", + action.as_str() + ), + )) + } + InvocationContext::OutsideWarp => { + if !settings.outside_warp_control_enabled() { + return Err(ControlError::new( + ErrorCode::LocalControlDisabled, + format!( + "{} is disabled for outside-Warp local control", + action.as_str() + ), + )); + } + Ok(()) + } } - Ok(()) } diff --git a/app/src/local_control/resolver.rs b/app/src/local_control/resolver.rs index 97d0ddee96..442720d352 100644 --- a/app/src/local_control/resolver.rs +++ b/app/src/local_control/resolver.rs @@ -36,9 +36,9 @@ pub(crate) fn validate_tab_create_target(target: &TargetSelector) -> Result<(), /// Validates action-specific params implemented by this branch stack layer. /// -/// This is intentionally narrow while `zach/warp-cli-core-foundation` is the -/// bottom branch of the stack: later branches add their own params and expand -/// this validation alongside the corresponding action handlers. +/// This is intentionally narrow for the current implementation slice. Later +/// slices add their own params and expand this validation alongside the +/// corresponding action handlers. pub(crate) fn validate_action_params(action: &::local_control::Action) -> Result<(), ControlError> { if action.kind != ActionKind::TabCreate { return Ok(()); diff --git a/app/src/settings/local_control.rs b/app/src/settings/local_control.rs index aa08766fa5..b2cc36ce50 100644 --- a/app/src/settings/local_control.rs +++ b/app/src/settings/local_control.rs @@ -1,110 +1,82 @@ -//! Private local settings that gate outside-Warp control by risk category. +//! Private local setting that gates local-control invocation contexts. //! -//! These settings are local-only and kept out of the user-visible settings file, -//! but this foundation branch still stores them in the existing private -//! preferences backend. Before outside-Warp control ships, the authoritative -//! enablement bits should move to protected storage where available, such as -//! macOS Keychain or the platform equivalent, so external apps cannot enable -//! local control by editing ordinary preferences. +//! This setting is local-only, kept out of the user-visible settings file, and +//! marked `private: true` in the settings definition. It is the authoritative +//! enablement bit for local control. +use serde::{Deserialize, Serialize}; use settings::{macros::define_settings_group, SupportedPlatforms, SyncToCloud}; -/// Coarse permission buckets used to gate groups of control actions. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum LocalControlPermissionCategory { - MetadataReads, - UnderlyingDataReads, - AppStateMutations, - MetadataConfigurationMutations, - UnderlyingDataMutations, +/// User-selected local-control availability. +#[derive( + Clone, + Copy, + Debug, + Default, + Deserialize, + Eq, + PartialEq, + schemars::JsonSchema, + Serialize, + settings_value::SettingsValue, +)] +#[schemars( + description = "Which local-control invocation contexts are allowed.", + rename_all = "snake_case" +)] +pub enum LocalControlMode { + #[default] + Disabled, + EnabledWithinWarp, + EnabledEverywhere, +} + +impl LocalControlMode { + pub const ALL: [Self; 3] = [ + Self::Disabled, + Self::EnabledWithinWarp, + Self::EnabledEverywhere, + ]; + + pub fn allows_inside_warp(self) -> bool { + matches!(self, Self::EnabledWithinWarp | Self::EnabledEverywhere) + } + + pub fn allows_outside_warp(self) -> bool { + matches!(self, Self::EnabledEverywhere) + } + + pub fn as_dropdown_label(self) -> &'static str { + match self { + Self::Disabled => "Disabled", + Self::EnabledWithinWarp => "Enabled within Warp", + Self::EnabledEverywhere => "Enabled everywhere", + } + } } define_settings_group!(LocalControlSettings, settings: [ - allow_outside_warp_control: AllowOutsideWarpControl { - type: bool, - default: false, - supported_platforms: SupportedPlatforms::DESKTOP, - sync_to_cloud: SyncToCloud::Never, - private: true, - storage_key: "LocalControlAllowOutsideWarp", - description: "Whether Warp control is allowed from external local clients.", - }, - allow_outside_warp_metadata_reads: AllowOutsideWarpMetadataReads { - type: bool, - default: false, - supported_platforms: SupportedPlatforms::DESKTOP, - sync_to_cloud: SyncToCloud::Never, - private: true, - storage_key: "LocalControlOutsideWarpMetadataReads", - description: "Whether external local clients may receive metadata-read local control grants.", - }, - allow_outside_warp_underlying_data_reads: AllowOutsideWarpUnderlyingDataReads { - type: bool, - default: false, - supported_platforms: SupportedPlatforms::DESKTOP, - sync_to_cloud: SyncToCloud::Never, - private: true, - storage_key: "LocalControlOutsideWarpUnderlyingDataReads", - description: "Whether external local clients may receive underlying-data-read local control grants.", - }, - allow_outside_warp_app_state_mutations: AllowOutsideWarpAppStateMutations { - type: bool, - default: false, - supported_platforms: SupportedPlatforms::DESKTOP, - sync_to_cloud: SyncToCloud::Never, - private: true, - storage_key: "LocalControlOutsideWarpAppStateMutations", - description: "Whether external local clients may receive app-state-mutation local control grants.", - }, - allow_outside_warp_metadata_configuration_mutations: AllowOutsideWarpMetadataConfigurationMutations { - type: bool, - default: false, - supported_platforms: SupportedPlatforms::DESKTOP, - sync_to_cloud: SyncToCloud::Never, - private: true, - storage_key: "LocalControlOutsideWarpMetadataConfigurationMutations", - description: "Whether external local clients may receive metadata/configuration-mutation local control grants.", - }, - allow_outside_warp_underlying_data_mutations: AllowOutsideWarpUnderlyingDataMutations { - type: bool, - default: false, + local_control_mode: LocalControlModeSetting { + type: LocalControlMode, + default: LocalControlMode::Disabled, supported_platforms: SupportedPlatforms::DESKTOP, sync_to_cloud: SyncToCloud::Never, private: true, - storage_key: "LocalControlOutsideWarpUnderlyingDataMutations", - description: "Whether external local clients may receive underlying-data-mutation local control grants.", + storage_key: "LocalControlMode", + description: "Whether Warp local control is disabled, enabled within Warp, or enabled everywhere including outside Warp.", }, ]); impl LocalControlSettings { - pub fn outside_warp_control_enabled(&self) -> bool { - *self.allow_outside_warp_control + pub fn mode(&self) -> LocalControlMode { + *self.local_control_mode } - pub fn outside_warp_permission_enabled( - &self, - permission: LocalControlPermissionCategory, - ) -> bool { - match permission { - LocalControlPermissionCategory::MetadataReads => { - *self.allow_outside_warp_metadata_reads - } - LocalControlPermissionCategory::UnderlyingDataReads => { - *self.allow_outside_warp_underlying_data_reads - } - LocalControlPermissionCategory::AppStateMutations => { - *self.allow_outside_warp_app_state_mutations - } - LocalControlPermissionCategory::MetadataConfigurationMutations => { - *self.allow_outside_warp_metadata_configuration_mutations - } - LocalControlPermissionCategory::UnderlyingDataMutations => { - *self.allow_outside_warp_underlying_data_mutations - } - } + pub fn inside_warp_control_enabled(&self) -> bool { + self.mode().allows_inside_warp() } - pub fn allows_outside_warp(&self, permission: LocalControlPermissionCategory) -> bool { - self.outside_warp_control_enabled() && self.outside_warp_permission_enabled(permission) + pub fn outside_warp_control_enabled(&self) -> bool { + self.mode().allows_outside_warp() } } diff --git a/app/src/settings/local_control_tests.rs b/app/src/settings/local_control_tests.rs index b2b6f306e4..f0ef5f3f2e 100644 --- a/app/src/settings/local_control_tests.rs +++ b/app/src/settings/local_control_tests.rs @@ -1,100 +1,18 @@ -use settings::{Setting, SyncToCloud}; +use super::{LocalControlMode, LocalControlModeSetting, LocalControlSettings}; +use settings::Setting as _; -use super::{ - AllowOutsideWarpAppStateMutations, AllowOutsideWarpControl, - AllowOutsideWarpMetadataConfigurationMutations, AllowOutsideWarpMetadataReads, - AllowOutsideWarpUnderlyingDataMutations, AllowOutsideWarpUnderlyingDataReads, - LocalControlPermissionCategory, LocalControlSettings, -}; - -fn settings_with_values(outside_enabled: bool) -> LocalControlSettings { +fn default_settings() -> LocalControlSettings { LocalControlSettings { - allow_outside_warp_control: AllowOutsideWarpControl::new(Some(outside_enabled)), - allow_outside_warp_metadata_reads: AllowOutsideWarpMetadataReads::new(Some(false)), - allow_outside_warp_underlying_data_reads: AllowOutsideWarpUnderlyingDataReads::new(Some( - false, - )), - allow_outside_warp_app_state_mutations: AllowOutsideWarpAppStateMutations::new(Some(false)), - allow_outside_warp_metadata_configuration_mutations: - AllowOutsideWarpMetadataConfigurationMutations::new(Some(false)), - allow_outside_warp_underlying_data_mutations: AllowOutsideWarpUnderlyingDataMutations::new( - Some(false), - ), + local_control_mode: LocalControlModeSetting::new(None), } } #[test] -fn defaults_disable_outside_warp_permissions() { - let settings = settings_with_values(false); - - for permission in [ - LocalControlPermissionCategory::MetadataReads, - LocalControlPermissionCategory::UnderlyingDataReads, - LocalControlPermissionCategory::AppStateMutations, - LocalControlPermissionCategory::MetadataConfigurationMutations, - LocalControlPermissionCategory::UnderlyingDataMutations, - ] { - assert!(!settings.allows_outside_warp(permission)); - } -} - -#[test] -fn generated_settings_are_private_local_only_with_expected_defaults() { - assert!(!*AllowOutsideWarpControl::new(None)); - assert!(!*AllowOutsideWarpMetadataReads::new(None)); - assert!(!*AllowOutsideWarpUnderlyingDataReads::new(None)); - assert!(!*AllowOutsideWarpAppStateMutations::new(None)); - assert!(!*AllowOutsideWarpMetadataConfigurationMutations::new(None)); - assert!(!*AllowOutsideWarpUnderlyingDataMutations::new(None)); - assert_eq!(AllowOutsideWarpControl::sync_to_cloud(), SyncToCloud::Never); - assert_eq!( - AllowOutsideWarpUnderlyingDataMutations::sync_to_cloud(), - SyncToCloud::Never - ); - assert!(AllowOutsideWarpControl::is_private()); - assert!(AllowOutsideWarpMetadataReads::is_private()); - assert!(AllowOutsideWarpUnderlyingDataMutations::is_private()); -} - -#[test] -fn disabled_context_blocks_enabled_granular_permissions() { - let settings = LocalControlSettings { - allow_outside_warp_control: AllowOutsideWarpControl::new(Some(false)), - allow_outside_warp_metadata_reads: AllowOutsideWarpMetadataReads::new(Some(true)), - allow_outside_warp_underlying_data_reads: AllowOutsideWarpUnderlyingDataReads::new(Some( - true, - )), - allow_outside_warp_app_state_mutations: AllowOutsideWarpAppStateMutations::new(Some(true)), - allow_outside_warp_metadata_configuration_mutations: - AllowOutsideWarpMetadataConfigurationMutations::new(Some(true)), - allow_outside_warp_underlying_data_mutations: AllowOutsideWarpUnderlyingDataMutations::new( - Some(true), - ), - }; - - assert!(!settings.allows_outside_warp(LocalControlPermissionCategory::AppStateMutations)); - assert!(!settings.allows_outside_warp(LocalControlPermissionCategory::MetadataReads)); -} - -#[test] -fn granular_permissions_are_independent() { - let settings = LocalControlSettings { - allow_outside_warp_control: AllowOutsideWarpControl::new(Some(true)), - allow_outside_warp_metadata_reads: AllowOutsideWarpMetadataReads::new(Some(true)), - allow_outside_warp_underlying_data_reads: AllowOutsideWarpUnderlyingDataReads::new(Some( - false, - )), - allow_outside_warp_app_state_mutations: AllowOutsideWarpAppStateMutations::new(Some(true)), - allow_outside_warp_metadata_configuration_mutations: - AllowOutsideWarpMetadataConfigurationMutations::new(Some(false)), - allow_outside_warp_underlying_data_mutations: AllowOutsideWarpUnderlyingDataMutations::new( - Some(false), - ), - }; +fn defaults_disable_warp_control() { + let settings = default_settings(); - assert!(settings.allows_outside_warp(LocalControlPermissionCategory::MetadataReads)); - assert!(!settings.allows_outside_warp(LocalControlPermissionCategory::UnderlyingDataReads)); - assert!(settings.allows_outside_warp(LocalControlPermissionCategory::AppStateMutations)); - assert!(!settings - .allows_outside_warp(LocalControlPermissionCategory::MetadataConfigurationMutations)); + assert_eq!(LocalControlMode::default(), LocalControlMode::Disabled); + assert_eq!(settings.mode(), LocalControlMode::Disabled); + assert!(!settings.inside_warp_control_enabled()); + assert!(!settings.outside_warp_control_enabled()); } diff --git a/app/src/settings_view/scripting_page.rs b/app/src/settings_view/scripting_page.rs index e118431a84..d3b3bc35ba 100644 --- a/app/src/settings_view/scripting_page.rs +++ b/app/src/settings_view/scripting_page.rs @@ -1,211 +1,86 @@ //! Settings UI for local scripting and Warp control permissions. use super::{ settings_page::{ - render_body_item, render_settings_info_banner, LocalOnlyIconState, MatchData, PageType, - SettingsPageMeta, SettingsPageViewHandle, SettingsWidget, + render_dropdown_item, LocalOnlyIconState, MatchData, PageType, SettingsPageMeta, + SettingsPageViewHandle, SettingsWidget, }, - SettingsSection, ToggleState, + SettingsSection, }; use crate::appearance::Appearance; use crate::features::FeatureFlag; use crate::report_if_error; -use crate::settings::{ - AllowOutsideWarpAppStateMutations, AllowOutsideWarpControl, - AllowOutsideWarpMetadataConfigurationMutations, AllowOutsideWarpMetadataReads, - AllowOutsideWarpUnderlyingDataMutations, AllowOutsideWarpUnderlyingDataReads, - LocalControlSettings, -}; -use settings::{Setting as _, ToggleableSetting as _}; +use crate::settings::{LocalControlMode, LocalControlModeSetting, LocalControlSettings}; +use crate::view_components::{Dropdown, DropdownItem}; +use settings::Setting as _; use std::cell::RefCell; use std::collections::HashMap; -use warp_core::settings::SyncToCloud; -use warpui::elements::{Container, Element, MouseStateHandle}; -use warpui::ui_components::components::UiComponent; -use warpui::ui_components::switch::SwitchStateHandle; +use warpui::elements::{Element, MouseStateHandle}; use warpui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle}; -/// Toggle rows shown on the Settings > Scripting page for outside-Warp local-control gates. -#[derive(Clone, Copy, Debug)] -pub enum ScriptingToggle { - OutsideWarpControl, - OutsideWarpMetadataReads, - OutsideWarpUnderlyingDataReads, - OutsideWarpAppStateMutations, - OutsideWarpMetadataConfigurationMutations, - OutsideWarpUnderlyingDataMutations, -} - -impl ScriptingToggle { - fn label(self) -> &'static str { - match self { - Self::OutsideWarpControl => "Warp control outside Warp", - Self::OutsideWarpMetadataReads => "Allow metadata reads", - Self::OutsideWarpUnderlyingDataReads => "Allow underlying data reads", - Self::OutsideWarpAppStateMutations => "Allow app-state mutations", - Self::OutsideWarpMetadataConfigurationMutations => { - "Allow metadata/configuration mutations" - } - Self::OutsideWarpUnderlyingDataMutations => "Allow underlying data mutations", - } - } - - fn description(self) -> &'static str { - match self { - Self::OutsideWarpControl => { - "Allows other local apps, terminals, IDEs, launch agents, and scripts to request Warp control." - } - Self::OutsideWarpMetadataReads => { - "Allows external local clients to query app metadata after outside-Warp control is enabled." - } - Self::OutsideWarpUnderlyingDataReads => { - "Allows external local clients to read underlying user data when those commands are implemented." - } - Self::OutsideWarpAppStateMutations => { - "Allows external local clients to mutate Warp app state after outside-Warp control is enabled." - } - Self::OutsideWarpMetadataConfigurationMutations => { - "Allows external local clients to change metadata and configuration when those commands are implemented." - } - Self::OutsideWarpUnderlyingDataMutations => { - "Allows external local clients to mutate underlying user data when those commands are implemented." - } - } - } - - fn search_terms(self) -> &'static str { - match self { - Self::OutsideWarpControl => { - "outside warp control external scripts automation local cli" - } - Self::OutsideWarpMetadataReads => { - "outside warp metadata read query windows tabs panes instances" - } - Self::OutsideWarpUnderlyingDataReads => { - "outside warp underlying data read terminal output input history blocks" - } - Self::OutsideWarpAppStateMutations => { - "outside warp app state mutate change tab create window pane" - } - Self::OutsideWarpMetadataConfigurationMutations => { - "outside warp metadata configuration mutate settings theme labels" - } - Self::OutsideWarpUnderlyingDataMutations => { - "outside warp underlying data mutate input files drive" - } - } - } - - fn value(self, settings: &LocalControlSettings) -> bool { - match self { - Self::OutsideWarpControl => *settings.allow_outside_warp_control, - Self::OutsideWarpMetadataReads => *settings.allow_outside_warp_metadata_reads, - Self::OutsideWarpUnderlyingDataReads => { - *settings.allow_outside_warp_underlying_data_reads - } - Self::OutsideWarpAppStateMutations => *settings.allow_outside_warp_app_state_mutations, - Self::OutsideWarpMetadataConfigurationMutations => { - *settings.allow_outside_warp_metadata_configuration_mutations - } - Self::OutsideWarpUnderlyingDataMutations => { - *settings.allow_outside_warp_underlying_data_mutations - } - } - } - - fn storage_key(self) -> &'static str { - match self { - Self::OutsideWarpControl => AllowOutsideWarpControl::storage_key(), - Self::OutsideWarpMetadataReads => AllowOutsideWarpMetadataReads::storage_key(), - Self::OutsideWarpUnderlyingDataReads => { - AllowOutsideWarpUnderlyingDataReads::storage_key() - } - Self::OutsideWarpAppStateMutations => AllowOutsideWarpAppStateMutations::storage_key(), - Self::OutsideWarpMetadataConfigurationMutations => { - AllowOutsideWarpMetadataConfigurationMutations::storage_key() - } - Self::OutsideWarpUnderlyingDataMutations => { - AllowOutsideWarpUnderlyingDataMutations::storage_key() - } - } - } - - fn sync_to_cloud(self) -> SyncToCloud { - match self { - Self::OutsideWarpControl => AllowOutsideWarpControl::sync_to_cloud(), - Self::OutsideWarpMetadataReads => AllowOutsideWarpMetadataReads::sync_to_cloud(), - Self::OutsideWarpUnderlyingDataReads => { - AllowOutsideWarpUnderlyingDataReads::sync_to_cloud() - } - Self::OutsideWarpAppStateMutations => { - AllowOutsideWarpAppStateMutations::sync_to_cloud() - } - Self::OutsideWarpMetadataConfigurationMutations => { - AllowOutsideWarpMetadataConfigurationMutations::sync_to_cloud() - } - Self::OutsideWarpUnderlyingDataMutations => { - AllowOutsideWarpUnderlyingDataMutations::sync_to_cloud() - } - } - } - - fn requires_outside_control(self) -> bool { - match self { - Self::OutsideWarpControl => false, - Self::OutsideWarpMetadataReads - | Self::OutsideWarpUnderlyingDataReads - | Self::OutsideWarpAppStateMutations - | Self::OutsideWarpMetadataConfigurationMutations - | Self::OutsideWarpUnderlyingDataMutations => true, - } - } -} - -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub enum ScriptingSettingsPageAction { - Toggle(ScriptingToggle), + SetLocalControlMode(LocalControlMode), } pub struct ScriptingSettingsPageView { page: PageType<Self>, local_only_icon_tooltip_states: RefCell<HashMap<String, MouseStateHandle>>, + local_control_mode_dropdown: ViewHandle<Dropdown<ScriptingSettingsPageAction>>, } impl ScriptingSettingsPageView { pub fn new(ctx: &mut ViewContext<Self>) -> Self { + let local_control_mode_dropdown = ctx.add_typed_action_view(|ctx| { + let mut dropdown = Dropdown::new(ctx); + dropdown.set_top_bar_max_width(260.); + dropdown + }); + Self::update_local_control_mode_dropdown(local_control_mode_dropdown.clone(), ctx); + if FeatureFlag::WarpControlCli.is_enabled() { - ctx.subscribe_to_model(&LocalControlSettings::handle(ctx), |_, _, _, ctx| { + ctx.subscribe_to_model(&LocalControlSettings::handle(ctx), |view, _, _, ctx| { + Self::update_local_control_mode_dropdown( + view.local_control_mode_dropdown.clone(), + ctx, + ); ctx.notify(); }); } Self { page: PageType::new_uncategorized( - vec![ - Box::new(ScriptingIntroWidget), - Box::new(ScriptingToggleWidget::new( - ScriptingToggle::OutsideWarpControl, - )), - Box::new(ScriptingToggleWidget::new( - ScriptingToggle::OutsideWarpMetadataReads, - )), - Box::new(ScriptingToggleWidget::new( - ScriptingToggle::OutsideWarpUnderlyingDataReads, - )), - Box::new(ScriptingToggleWidget::new( - ScriptingToggle::OutsideWarpAppStateMutations, - )), - Box::new(ScriptingToggleWidget::new( - ScriptingToggle::OutsideWarpMetadataConfigurationMutations, - )), - Box::new(ScriptingToggleWidget::new( - ScriptingToggle::OutsideWarpUnderlyingDataMutations, - )), - ], + vec![Box::new(LocalControlModeWidget)], Some("Scripting"), ), local_only_icon_tooltip_states: RefCell::new(HashMap::new()), + local_control_mode_dropdown, } } + + fn update_local_control_mode_dropdown( + dropdown: ViewHandle<Dropdown<ScriptingSettingsPageAction>>, + ctx: &mut ViewContext<Self>, + ) { + let current_mode = LocalControlSettings::as_ref(ctx).mode(); + dropdown.update(ctx, |dropdown, ctx| { + dropdown.set_items( + LocalControlMode::ALL + .into_iter() + .map(|mode| { + DropdownItem::new( + mode.as_dropdown_label(), + ScriptingSettingsPageAction::SetLocalControlMode(mode), + ) + }) + .collect(), + ctx, + ); + dropdown.set_selected_by_action( + ScriptingSettingsPageAction::SetLocalControlMode(current_mode), + ctx, + ); + }); + } } impl Entity for ScriptingSettingsPageView { @@ -217,38 +92,9 @@ impl TypedActionView for ScriptingSettingsPageView { fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) { match action { - ScriptingSettingsPageAction::Toggle(toggle) => { - LocalControlSettings::handle(ctx).update(ctx, |settings, ctx| match toggle { - ScriptingToggle::OutsideWarpControl => { - report_if_error!(settings - .allow_outside_warp_control - .toggle_and_save_value(ctx)); - } - ScriptingToggle::OutsideWarpMetadataReads => { - report_if_error!(settings - .allow_outside_warp_metadata_reads - .toggle_and_save_value(ctx)); - } - ScriptingToggle::OutsideWarpUnderlyingDataReads => { - report_if_error!(settings - .allow_outside_warp_underlying_data_reads - .toggle_and_save_value(ctx)); - } - ScriptingToggle::OutsideWarpAppStateMutations => { - report_if_error!(settings - .allow_outside_warp_app_state_mutations - .toggle_and_save_value(ctx)); - } - ScriptingToggle::OutsideWarpMetadataConfigurationMutations => { - report_if_error!(settings - .allow_outside_warp_metadata_configuration_mutations - .toggle_and_save_value(ctx)); - } - ScriptingToggle::OutsideWarpUnderlyingDataMutations => { - report_if_error!(settings - .allow_outside_warp_underlying_data_mutations - .toggle_and_save_value(ctx)); - } + ScriptingSettingsPageAction::SetLocalControlMode(mode) => { + LocalControlSettings::handle(ctx).update(ctx, |settings, ctx| { + report_if_error!(settings.local_control_mode.set_value(*mode, ctx)); }); ctx.notify(); } @@ -294,53 +140,13 @@ impl From<ViewHandle<ScriptingSettingsPageView>> for SettingsPageViewHandle { } } -struct ScriptingIntroWidget; - -impl SettingsWidget for ScriptingIntroWidget { - type View = ScriptingSettingsPageView; - - fn search_terms(&self) -> &str { - "scripting warp control automation warpctrl local cli outside read only read write" - } - - fn render( - &self, - _view: &Self::View, - appearance: &Appearance, - _app: &AppContext, - ) -> Box<dyn Element> { - render_settings_info_banner( - "Warp control lets local scripts automate allowlisted actions in a running Warp app.", - Some("This foundation branch supports outside-Warp local clients only. Verified Warp-managed terminal invocations are planned for a later implementation and are currently rejected by the credential broker."), - appearance, - ) - } -} - -struct ScriptingToggleWidget { - toggle: ScriptingToggle, - switch_state: SwitchStateHandle, -} - -impl ScriptingToggleWidget { - fn new(toggle: ScriptingToggle) -> Self { - Self { - toggle, - switch_state: SwitchStateHandle::default(), - } - } -} +struct LocalControlModeWidget; -impl SettingsWidget for ScriptingToggleWidget { +impl SettingsWidget for LocalControlModeWidget { type View = ScriptingSettingsPageView; fn search_terms(&self) -> &str { - self.toggle.search_terms() - } - - fn should_render(&self, app: &AppContext) -> bool { - let settings = LocalControlSettings::as_ref(app); - !self.toggle.requires_outside_control() || settings.outside_warp_control_enabled() + "scripting warp control automation warpctrl local cli inside warp outside warp external scripts disabled enabled" } fn render( @@ -349,36 +155,19 @@ impl SettingsWidget for ScriptingToggleWidget { appearance: &Appearance, app: &AppContext, ) -> Box<dyn Element> { - let settings = LocalControlSettings::as_ref(app); - let checked = self.toggle.value(settings); - let toggle = self.toggle; - - let item = render_body_item::<ScriptingSettingsPageAction>( - self.toggle.label().to_owned(), + render_dropdown_item( + appearance, + "warpctrl", + Some("warpctrl CLI scripting"), None, LocalOnlyIconState::for_setting( - self.toggle.storage_key(), - self.toggle.sync_to_cloud(), + LocalControlModeSetting::storage_key(), + LocalControlModeSetting::sync_to_cloud(), &mut view.local_only_icon_tooltip_states.borrow_mut(), app, ), - ToggleState::Enabled, - appearance, - appearance - .ui_builder() - .switch(self.switch_state.clone()) - .check(checked) - .build() - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(ScriptingSettingsPageAction::Toggle(toggle)); - }) - .finish(), - Some(self.toggle.description().to_owned()), - ); - if self.toggle.requires_outside_control() { - Container::new(item).with_margin_left(16.).finish() - } else { - item - } + None, + &view.local_control_mode_dropdown, + ) } } diff --git a/crates/local_control/src/auth.rs b/crates/local_control/src/auth.rs index 115f3b0a46..cf51864510 100644 --- a/crates/local_control/src/auth.rs +++ b/crates/local_control/src/auth.rs @@ -8,7 +8,6 @@ use uuid::Uuid; use crate::discovery::InstanceId; use crate::protocol::{ ActionKind, ControlError, ErrorCode, ExecutionContextProof, InvocationContext, - PermissionCategory, RiskTier, StateDataCategory, }; /// Bearer token used to authorize a single scoped local-control credential. @@ -133,9 +132,6 @@ pub struct CredentialGrant { pub credential_id: String, pub instance_id: InstanceId, pub action: ActionKind, - pub risk_tier: RiskTier, - pub state_data_category: StateDataCategory, - pub permission_category: PermissionCategory, pub invocation_context: InvocationContext, pub authenticated_user: AuthenticatedUserGrant, pub issued_at: DateTime<Utc>, @@ -162,9 +158,6 @@ impl CredentialGrant { credential_id: format!("cred_{}", Uuid::new_v4().simple()), instance_id, action, - risk_tier: metadata.risk_tier, - state_data_category: metadata.state_data_category, - permission_category: metadata.permission_category, invocation_context, authenticated_user: AuthenticatedUserGrant { required: metadata.authenticated_user.required, @@ -193,18 +186,6 @@ impl CredentialGrant { )); } let metadata = action.metadata(); - if self.risk_tier != metadata.risk_tier - || self.state_data_category != metadata.state_data_category - || self.permission_category != metadata.permission_category - { - return Err(ControlError::new( - ErrorCode::InsufficientPermissions, - format!( - "credential grant metadata does not satisfy {}", - action.as_str() - ), - )); - } if metadata.requires_authenticated_user && self.authenticated_user.subject.is_none() { return Err(ControlError::new( ErrorCode::AuthenticatedUserRequired, diff --git a/crates/local_control/src/auth_tests.rs b/crates/local_control/src/auth_tests.rs index e0e524c21b..849a158e63 100644 --- a/crates/local_control/src/auth_tests.rs +++ b/crates/local_control/src/auth_tests.rs @@ -2,7 +2,6 @@ use chrono::Duration; use super::*; use crate::discovery::InstanceId; -use crate::protocol::{PermissionCategory, StateDataCategory}; #[test] fn rejects_missing_authorization_header() { @@ -56,39 +55,30 @@ fn scoped_credential_allows_only_granted_action() { } #[test] -fn scoped_credential_carries_permission_and_authenticated_user_metadata() { +fn scoped_credential_carries_authenticated_user_metadata() { let grant = CredentialGrant::new( InstanceId("inst_test".to_owned()), ActionKind::TabCreate, InvocationContext::OutsideWarp, Duration::minutes(5), ); - assert_eq!(grant.risk_tier, RiskTier::MutatingNonDestructive); - assert_eq!( - grant.state_data_category, - StateDataCategory::AppStateMutation - ); - assert_eq!( - grant.permission_category, - PermissionCategory::MutateAppState - ); assert!(!grant.authenticated_user.required); assert!(grant.authenticated_user.subject.is_none()); } #[test] -fn mismatched_permission_metadata_is_rejected() { - let mut grant = CredentialGrant::new( +fn authenticated_user_actions_require_subject() { + let grant = CredentialGrant::new( InstanceId("inst_test".to_owned()), - ActionKind::TabCreate, - InvocationContext::OutsideWarp, + ActionKind::DriveInspect, + InvocationContext::InsideWarp, Duration::minutes(5), ); - grant.permission_category = PermissionCategory::ReadMetadata; + assert!(grant.authenticated_user.required); let err = grant - .verify_for_action(ActionKind::TabCreate) - .expect_err("metadata mismatch is rejected"); - assert_eq!(err.code, ErrorCode::InsufficientPermissions); + .verify_for_action(ActionKind::DriveInspect) + .expect_err("authenticated-user actions require a subject"); + assert_eq!(err.code, ErrorCode::AuthenticatedUserRequired); } #[test] diff --git a/crates/local_control/src/catalog.rs b/crates/local_control/src/catalog.rs index 7a01262d20..648378bba9 100644 --- a/crates/local_control/src/catalog.rs +++ b/crates/local_control/src/catalog.rs @@ -3,22 +3,6 @@ use serde::{Deserialize, Serialize}; pub const PROTOCOL_VERSION: u32 = 1; -pub const EXCLUDED_LOCAL_FILE_MUTATION_ACTION_NAMES: &[&str] = &[ - "file.read", - "file.write", - "file.append", - "file.delete", - "file.copy", - "file.move", - "file.mkdir", -]; - -pub const EXCLUDED_STANDALONE_SECRET_AUTH_ACTION_NAMES: &[&str] = &[ - "auth.api_key.set", - "auth.api_key.status", - "auth.api_key.revoke", -]; - /// Runtime context from which a control request was initiated. #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -93,7 +77,6 @@ pub enum TargetScope { Appearance, Surface, File, - Project, DriveObject, Auth, Keybinding, @@ -131,7 +114,6 @@ pub enum ActionParameterSpec { Limit, Namespace, PageQuery, - Path, Query, Rename, Resize, @@ -161,7 +143,6 @@ pub enum ActionResultSpec { InstanceMetadata, KeybindingList, KeybindingMetadata, - ProjectList, SettingList, SettingValue, TargetList, @@ -187,1114 +168,305 @@ pub struct ActionMetadata { pub result_spec: ActionResultSpec, } -/// Stable protocol name for every approved `warpctrl` action. -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum ActionKind { - #[serde(rename = "instance.list")] - InstanceList, - #[serde(rename = "instance.inspect")] - InstanceInspect, - #[serde(rename = "app.ping")] - AppPing, - #[serde(rename = "app.version")] - AppVersion, - #[serde(rename = "app.active")] - AppActive, - #[serde(rename = "app.focus")] - AppFocus, - #[serde(rename = "auth.status")] - AuthStatus, - #[serde(rename = "auth.login")] - AuthLogin, - #[serde(rename = "capability.list")] - CapabilityList, - #[serde(rename = "capability.inspect")] - CapabilityInspect, - #[serde(rename = "window.list")] - WindowList, - #[serde(rename = "window.inspect")] - WindowInspect, - #[serde(rename = "window.create")] - WindowCreate, - #[serde(rename = "window.focus")] - WindowFocus, - #[serde(rename = "window.close")] - WindowClose, - #[serde(rename = "tab.list")] - TabList, - #[serde(rename = "tab.inspect")] - TabInspect, - #[serde(rename = "tab.create")] - TabCreate, - #[serde(rename = "tab.activate")] - TabActivate, - #[serde(rename = "tab.move")] - TabMove, - #[serde(rename = "tab.close")] - TabClose, - #[serde(rename = "tab.rename")] - TabRename, - #[serde(rename = "tab.reset_name")] - TabResetName, - #[serde(rename = "tab.color.set")] - TabColorSet, - #[serde(rename = "tab.color.clear")] - TabColorClear, - #[serde(rename = "pane.list")] - PaneList, - #[serde(rename = "pane.inspect")] - PaneInspect, - #[serde(rename = "pane.split")] - PaneSplit, - #[serde(rename = "pane.focus")] - PaneFocus, - #[serde(rename = "pane.navigate")] - PaneNavigate, - #[serde(rename = "pane.resize")] - PaneResize, - #[serde(rename = "pane.maximize")] - PaneMaximize, - #[serde(rename = "pane.unmaximize")] - PaneUnmaximize, - #[serde(rename = "pane.close")] - PaneClose, - #[serde(rename = "pane.rename")] - PaneRename, - #[serde(rename = "pane.reset_name")] - PaneResetName, - #[serde(rename = "session.list")] - SessionList, - #[serde(rename = "session.inspect")] - SessionInspect, - #[serde(rename = "session.activate")] - SessionActivate, - #[serde(rename = "session.previous")] - SessionPrevious, - #[serde(rename = "session.next")] - SessionNext, - #[serde(rename = "session.reopen_closed")] - SessionReopenClosed, - #[serde(rename = "block.list")] - BlockList, - #[serde(rename = "block.inspect")] - BlockInspect, - #[serde(rename = "block.output")] - BlockOutput, - #[serde(rename = "input.get")] - InputGet, - #[serde(rename = "input.insert")] - InputInsert, - #[serde(rename = "input.replace")] - InputReplace, - #[serde(rename = "input.clear")] - InputClear, - #[serde(rename = "input.mode.set")] - InputModeSet, - #[serde(rename = "input.run")] - InputRun, - #[serde(rename = "history.list")] - HistoryList, - #[serde(rename = "theme.list")] - ThemeList, - #[serde(rename = "theme.get")] - ThemeGet, - #[serde(rename = "theme.set")] - ThemeSet, - #[serde(rename = "theme.system.set")] - ThemeSystemSet, - #[serde(rename = "theme.light.set")] - ThemeLightSet, - #[serde(rename = "theme.dark.set")] - ThemeDarkSet, - #[serde(rename = "appearance.get")] - AppearanceGet, - #[serde(rename = "appearance.font_size.increase")] - AppearanceFontSizeIncrease, - #[serde(rename = "appearance.font_size.decrease")] - AppearanceFontSizeDecrease, - #[serde(rename = "appearance.font_size.reset")] - AppearanceFontSizeReset, - #[serde(rename = "appearance.zoom.increase")] - AppearanceZoomIncrease, - #[serde(rename = "appearance.zoom.decrease")] - AppearanceZoomDecrease, - #[serde(rename = "appearance.zoom.reset")] - AppearanceZoomReset, - #[serde(rename = "setting.list")] - SettingList, - #[serde(rename = "setting.get")] - SettingGet, - #[serde(rename = "setting.set")] - SettingSet, - #[serde(rename = "setting.toggle")] - SettingToggle, - #[serde(rename = "keybinding.list")] - KeybindingList, - #[serde(rename = "keybinding.get")] - KeybindingGet, - #[serde(rename = "action.list")] - ActionList, - #[serde(rename = "action.inspect")] - ActionInspect, - #[serde(rename = "surface.settings.open")] - SurfaceSettingsOpen, - #[serde(rename = "surface.command_palette.open")] - SurfaceCommandPaletteOpen, - #[serde(rename = "surface.command_search.open")] - SurfaceCommandSearchOpen, - #[serde(rename = "surface.warp_drive.open")] - SurfaceWarpDriveOpen, - #[serde(rename = "surface.warp_drive.toggle")] - SurfaceWarpDriveToggle, - #[serde(rename = "surface.resource_center.toggle")] - SurfaceResourceCenterToggle, - #[serde(rename = "surface.ai_assistant.toggle")] - SurfaceAiAssistantToggle, - #[serde(rename = "surface.code_review.toggle")] - SurfaceCodeReviewToggle, - #[serde(rename = "surface.left_panel.toggle")] - SurfaceLeftPanelToggle, - #[serde(rename = "surface.right_panel.toggle")] - SurfaceRightPanelToggle, - #[serde(rename = "surface.vertical_tabs.toggle")] - SurfaceVerticalTabsToggle, - #[serde(rename = "file.list")] - FileList, - #[serde(rename = "file.open")] - FileOpen, - #[serde(rename = "project.active")] - ProjectActive, - #[serde(rename = "project.list")] - ProjectList, - #[serde(rename = "project.open")] - ProjectOpen, - #[serde(rename = "drive.list")] - DriveList, - #[serde(rename = "drive.inspect")] - DriveInspect, - #[serde(rename = "drive.open")] - DriveOpen, - #[serde(rename = "drive.notebook.open")] - DriveNotebookOpen, - #[serde(rename = "drive.env_var_collection.open")] - DriveEnvVarCollectionOpen, - #[serde(rename = "drive.object.share.open")] - DriveObjectShareOpen, - #[serde(rename = "drive.object.create")] - DriveObjectCreate, - #[serde(rename = "drive.object.update")] - DriveObjectUpdate, - #[serde(rename = "drive.object.delete")] - DriveObjectDelete, - #[serde(rename = "drive.object.insert")] - DriveObjectInsert, - #[serde(rename = "drive.object.share_to_team")] - DriveObjectShareToTeam, - #[serde(rename = "drive.workflow.run")] - DriveWorkflowRun, +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum InvocationContextSpec { + InsideWarpOnly, + OutsideWarpOnly, + Any, } -impl ActionKind { - pub const ALL: &[Self] = &[ - Self::InstanceList, - Self::InstanceInspect, - Self::AppPing, - Self::AppVersion, - Self::AppActive, - Self::AppFocus, - Self::AuthStatus, - Self::AuthLogin, - Self::CapabilityList, - Self::CapabilityInspect, - Self::WindowList, - Self::WindowInspect, - Self::WindowCreate, - Self::WindowFocus, - Self::WindowClose, - Self::TabList, - Self::TabInspect, - Self::TabCreate, - Self::TabActivate, - Self::TabMove, - Self::TabClose, - Self::TabRename, - Self::TabResetName, - Self::TabColorSet, - Self::TabColorClear, - Self::PaneList, - Self::PaneInspect, - Self::PaneSplit, - Self::PaneFocus, - Self::PaneNavigate, - Self::PaneResize, - Self::PaneMaximize, - Self::PaneUnmaximize, - Self::PaneClose, - Self::PaneRename, - Self::PaneResetName, - Self::SessionList, - Self::SessionInspect, - Self::SessionActivate, - Self::SessionPrevious, - Self::SessionNext, - Self::SessionReopenClosed, - Self::BlockList, - Self::BlockInspect, - Self::BlockOutput, - Self::InputGet, - Self::InputInsert, - Self::InputReplace, - Self::InputClear, - Self::InputModeSet, - Self::InputRun, - Self::HistoryList, - Self::ThemeList, - Self::ThemeGet, - Self::ThemeSet, - Self::ThemeSystemSet, - Self::ThemeLightSet, - Self::ThemeDarkSet, - Self::AppearanceGet, - Self::AppearanceFontSizeIncrease, - Self::AppearanceFontSizeDecrease, - Self::AppearanceFontSizeReset, - Self::AppearanceZoomIncrease, - Self::AppearanceZoomDecrease, - Self::AppearanceZoomReset, - Self::SettingList, - Self::SettingGet, - Self::SettingSet, - Self::SettingToggle, - Self::KeybindingList, - Self::KeybindingGet, - Self::ActionList, - Self::ActionInspect, - Self::SurfaceSettingsOpen, - Self::SurfaceCommandPaletteOpen, - Self::SurfaceCommandSearchOpen, - Self::SurfaceWarpDriveOpen, - Self::SurfaceWarpDriveToggle, - Self::SurfaceResourceCenterToggle, - Self::SurfaceAiAssistantToggle, - Self::SurfaceCodeReviewToggle, - Self::SurfaceLeftPanelToggle, - Self::SurfaceRightPanelToggle, - Self::SurfaceVerticalTabsToggle, - Self::FileList, - Self::FileOpen, - Self::ProjectActive, - Self::ProjectList, - Self::ProjectOpen, - Self::DriveList, - Self::DriveInspect, - Self::DriveOpen, - Self::DriveNotebookOpen, - Self::DriveEnvVarCollectionOpen, - Self::DriveObjectShareOpen, - Self::DriveObjectCreate, - Self::DriveObjectUpdate, - Self::DriveObjectDelete, - Self::DriveObjectInsert, - Self::DriveObjectShareToTeam, - Self::DriveWorkflowRun, - ]; +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +struct ActionSpec { + name: &'static str, + implementation_status: ActionImplementationStatus, + requires_authenticated_user: bool, + invocation_contexts: InvocationContextSpec, + state_data_category: StateDataCategory, + target_scope: TargetScope, + parameter_spec: ActionParameterSpec, + result_spec: ActionResultSpec, +} - pub fn as_str(self) -> &'static str { - serde_names::action_name(self) - } +macro_rules! define_action_catalog { + ($( + $group:ident { + $( + $variant:ident => { + name: $name:literal, + status: $status:ident, + authenticated_user: $authenticated_user:literal, + contexts: $contexts:ident, + state: $state:ident, + target: $target:ident, + params: $params:ident, + result: $result:ident $(,)? + } + ),+ $(,)? + } + )+ $(,)?) => { + /// Stable protocol name for every approved `warpctrl` action. + #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] + pub enum ActionKind { + $( + $( + #[serde(rename = $name)] + $variant, + )+ + )+ + } + + impl ActionKind { + pub const ALL: &[Self] = &[ + $( + $(Self::$variant,)+ + )+ + ]; + + pub fn as_str(self) -> &'static str { + self.spec().name + } + + pub fn metadata(self) -> ActionMetadata { + let spec = self.spec(); + ActionMetadata { + kind: self, + name: spec.name.to_owned(), + implementation_status: spec.implementation_status, + risk_tier: self.default_risk_tier(), + state_data_category: spec.state_data_category, + requires_authenticated_user: spec.requires_authenticated_user, + authenticated_user: AuthenticatedUserRequirement { + required: spec.requires_authenticated_user, + }, + allowed_invocation_contexts: self.allowed_invocation_contexts(), + permission_category: self.default_permission_category(), + target_scope: spec.target_scope, + parameter_spec: spec.parameter_spec, + result_spec: spec.result_spec, + } + } + + pub fn implemented_metadata() -> Vec<ActionMetadata> { + Self::ALL + .iter() + .copied() + .map(Self::metadata) + .filter(|metadata| { + metadata.implementation_status == ActionImplementationStatus::Implemented + }) + .collect() + } + + pub fn is_implemented(self) -> bool { + self.spec().implementation_status == ActionImplementationStatus::Implemented + } + + fn spec(self) -> ActionSpec { + match self { + $( + $(Self::$variant => ActionSpec { + name: $name, + implementation_status: ActionImplementationStatus::$status, + requires_authenticated_user: $authenticated_user, + invocation_contexts: InvocationContextSpec::$contexts, + state_data_category: StateDataCategory::$state, + target_scope: TargetScope::$target, + parameter_spec: ActionParameterSpec::$params, + result_spec: ActionResultSpec::$result, + },)+ + )+ + } + } + + fn allowed_invocation_contexts(self) -> Vec<InvocationContext> { + match self.spec().invocation_contexts { + InvocationContextSpec::InsideWarpOnly => vec![InvocationContext::InsideWarp], + InvocationContextSpec::OutsideWarpOnly => vec![InvocationContext::OutsideWarp], + InvocationContextSpec::Any => vec![ + InvocationContext::InsideWarp, + InvocationContext::OutsideWarp, + ], + } + } + + fn default_risk_tier(self) -> RiskTier { + match self.spec().state_data_category { + StateDataCategory::MetadataRead => RiskTier::ReadOnlyMetadata, + StateDataCategory::UnderlyingDataRead => RiskTier::ReadOnlyTerminalData, + StateDataCategory::UnderlyingDataMutation => RiskTier::MutatingDestructiveOrExecution, + StateDataCategory::AppStateMutation + | StateDataCategory::MetadataConfigurationMutation => RiskTier::MutatingNonDestructive, + } + } - pub fn metadata(self) -> ActionMetadata { - let implementation_status = self.implementation_status(); - let requires_authenticated_user = self.requires_authenticated_user(); - ActionMetadata { - kind: self, - name: self.as_str().to_owned(), - implementation_status, - risk_tier: self.default_risk_tier(), - state_data_category: self.default_state_data_category(), - requires_authenticated_user, - authenticated_user: AuthenticatedUserRequirement { - required: requires_authenticated_user, - }, - allowed_invocation_contexts: self.allowed_invocation_contexts(), - permission_category: self.default_permission_category(), - target_scope: self.default_target_scope(), - parameter_spec: self.parameter_spec(), - result_spec: self.result_spec(), + fn default_permission_category(self) -> PermissionCategory { + match self.spec().state_data_category { + StateDataCategory::MetadataRead => PermissionCategory::ReadMetadata, + StateDataCategory::UnderlyingDataRead => PermissionCategory::ReadUnderlyingData, + StateDataCategory::AppStateMutation => PermissionCategory::MutateAppState, + StateDataCategory::MetadataConfigurationMutation => { + PermissionCategory::MutateMetadataConfiguration + } + StateDataCategory::UnderlyingDataMutation => PermissionCategory::MutateUnderlyingData, + } + } } + }; +} + +define_action_catalog! { + instance { + InstanceList => { name: "instance.list", status: Implemented, authenticated_user: false, contexts: OutsideWarpOnly, state: MetadataRead, target: Instance, params: None, result: InstanceList }, + InstanceInspect => { name: "instance.inspect", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Instance, params: None, result: InstanceMetadata }, } - pub fn implemented_metadata() -> Vec<ActionMetadata> { - Self::ALL - .iter() - .copied() - .map(Self::metadata) - .filter(|metadata| { - metadata.implementation_status == ActionImplementationStatus::Implemented - }) - .collect() + app { + AppPing => { name: "app.ping", status: Implemented, authenticated_user: false, contexts: OutsideWarpOnly, state: MetadataRead, target: Instance, params: None, result: InstanceMetadata }, + AppVersion => { name: "app.version", status: Implemented, authenticated_user: false, contexts: OutsideWarpOnly, state: MetadataRead, target: Instance, params: None, result: InstanceMetadata }, + AppActive => { name: "app.active", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Instance, params: None, result: ActiveTarget }, + AppFocus => { name: "app.focus", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Instance, params: None, result: Acknowledgement }, } - pub fn is_implemented(self) -> bool { - self.implementation_status() == ActionImplementationStatus::Implemented + auth { + AuthStatus => { name: "auth.status", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Auth, params: None, result: AuthStatus }, + AuthLogin => { name: "auth.login", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Auth, params: None, result: Acknowledgement }, } - fn implementation_status(self) -> ActionImplementationStatus { - match self { - Self::InstanceList | Self::AppPing | Self::AppVersion | Self::TabCreate => { - ActionImplementationStatus::Implemented - } - Self::InstanceInspect - | Self::AppActive - | Self::AppFocus - | Self::AuthStatus - | Self::AuthLogin - | Self::CapabilityList - | Self::CapabilityInspect - | Self::WindowList - | Self::WindowInspect - | Self::WindowCreate - | Self::WindowFocus - | Self::WindowClose - | Self::TabList - | Self::TabInspect - | Self::TabActivate - | Self::TabMove - | Self::TabClose - | Self::TabRename - | Self::TabResetName - | Self::TabColorSet - | Self::TabColorClear - | Self::PaneList - | Self::PaneInspect - | Self::PaneSplit - | Self::PaneFocus - | Self::PaneNavigate - | Self::PaneResize - | Self::PaneMaximize - | Self::PaneUnmaximize - | Self::PaneClose - | Self::PaneRename - | Self::PaneResetName - | Self::SessionList - | Self::SessionInspect - | Self::SessionActivate - | Self::SessionPrevious - | Self::SessionNext - | Self::SessionReopenClosed - | Self::BlockList - | Self::BlockInspect - | Self::BlockOutput - | Self::InputGet - | Self::InputInsert - | Self::InputReplace - | Self::InputClear - | Self::InputModeSet - | Self::InputRun - | Self::HistoryList - | Self::ThemeList - | Self::ThemeGet - | Self::ThemeSet - | Self::ThemeSystemSet - | Self::ThemeLightSet - | Self::ThemeDarkSet - | Self::AppearanceGet - | Self::AppearanceFontSizeIncrease - | Self::AppearanceFontSizeDecrease - | Self::AppearanceFontSizeReset - | Self::AppearanceZoomIncrease - | Self::AppearanceZoomDecrease - | Self::AppearanceZoomReset - | Self::SettingList - | Self::SettingGet - | Self::SettingSet - | Self::SettingToggle - | Self::KeybindingList - | Self::KeybindingGet - | Self::ActionList - | Self::ActionInspect - | Self::SurfaceSettingsOpen - | Self::SurfaceCommandPaletteOpen - | Self::SurfaceCommandSearchOpen - | Self::SurfaceWarpDriveOpen - | Self::SurfaceWarpDriveToggle - | Self::SurfaceResourceCenterToggle - | Self::SurfaceAiAssistantToggle - | Self::SurfaceCodeReviewToggle - | Self::SurfaceLeftPanelToggle - | Self::SurfaceRightPanelToggle - | Self::SurfaceVerticalTabsToggle - | Self::FileList - | Self::FileOpen - | Self::ProjectActive - | Self::ProjectList - | Self::ProjectOpen - | Self::DriveList - | Self::DriveInspect - | Self::DriveOpen - | Self::DriveNotebookOpen - | Self::DriveEnvVarCollectionOpen - | Self::DriveObjectShareOpen - | Self::DriveObjectCreate - | Self::DriveObjectUpdate - | Self::DriveObjectDelete - | Self::DriveObjectInsert - | Self::DriveObjectShareToTeam - | Self::DriveWorkflowRun => ActionImplementationStatus::Stub, - } + capability { + CapabilityList => { name: "capability.list", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Capability, params: None, result: CapabilityList }, + CapabilityInspect => { name: "capability.inspect", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Capability, params: ActionName, result: CapabilityMetadata }, } - fn allowed_invocation_contexts(self) -> Vec<InvocationContext> { - match self { - Self::InstanceList | Self::AppPing | Self::AppVersion | Self::TabCreate => { - vec![InvocationContext::OutsideWarp] - } - _ if self.requires_authenticated_user() => vec![InvocationContext::InsideWarp], - _ => vec![ - InvocationContext::InsideWarp, - InvocationContext::OutsideWarp, - ], - } + window { + WindowList => { name: "window.list", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Window, params: None, result: TargetList }, + WindowInspect => { name: "window.inspect", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Window, params: None, result: TargetMetadata }, + WindowCreate => { name: "window.create", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Window, params: TabCreate, result: Acknowledgement }, + WindowFocus => { name: "window.focus", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Window, params: None, result: Acknowledgement }, + WindowClose => { name: "window.close", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Window, params: None, result: Acknowledgement }, } - fn requires_authenticated_user(self) -> bool { - match self { - Self::DriveList - | Self::DriveInspect - | Self::DriveOpen - | Self::DriveNotebookOpen - | Self::DriveEnvVarCollectionOpen - | Self::DriveObjectShareOpen - | Self::DriveObjectCreate - | Self::DriveObjectUpdate - | Self::DriveObjectDelete - | Self::DriveObjectInsert - | Self::DriveObjectShareToTeam - | Self::DriveWorkflowRun - | Self::InputRun => true, - Self::InstanceList - | Self::InstanceInspect - | Self::AppPing - | Self::AppVersion - | Self::AppActive - | Self::AppFocus - | Self::AuthStatus - | Self::AuthLogin - | Self::CapabilityList - | Self::CapabilityInspect - | Self::WindowList - | Self::WindowInspect - | Self::WindowCreate - | Self::WindowFocus - | Self::WindowClose - | Self::TabList - | Self::TabInspect - | Self::TabCreate - | Self::TabActivate - | Self::TabMove - | Self::TabClose - | Self::TabRename - | Self::TabResetName - | Self::TabColorSet - | Self::TabColorClear - | Self::PaneList - | Self::PaneInspect - | Self::PaneSplit - | Self::PaneFocus - | Self::PaneNavigate - | Self::PaneResize - | Self::PaneMaximize - | Self::PaneUnmaximize - | Self::PaneClose - | Self::PaneRename - | Self::PaneResetName - | Self::SessionList - | Self::SessionInspect - | Self::SessionActivate - | Self::SessionPrevious - | Self::SessionNext - | Self::SessionReopenClosed - | Self::BlockList - | Self::BlockInspect - | Self::BlockOutput - | Self::InputGet - | Self::InputInsert - | Self::InputReplace - | Self::InputClear - | Self::InputModeSet - | Self::HistoryList - | Self::ThemeList - | Self::ThemeGet - | Self::ThemeSet - | Self::ThemeSystemSet - | Self::ThemeLightSet - | Self::ThemeDarkSet - | Self::AppearanceGet - | Self::AppearanceFontSizeIncrease - | Self::AppearanceFontSizeDecrease - | Self::AppearanceFontSizeReset - | Self::AppearanceZoomIncrease - | Self::AppearanceZoomDecrease - | Self::AppearanceZoomReset - | Self::SettingList - | Self::SettingGet - | Self::SettingSet - | Self::SettingToggle - | Self::KeybindingList - | Self::KeybindingGet - | Self::ActionList - | Self::ActionInspect - | Self::SurfaceSettingsOpen - | Self::SurfaceCommandPaletteOpen - | Self::SurfaceCommandSearchOpen - | Self::SurfaceWarpDriveOpen - | Self::SurfaceWarpDriveToggle - | Self::SurfaceResourceCenterToggle - | Self::SurfaceAiAssistantToggle - | Self::SurfaceCodeReviewToggle - | Self::SurfaceLeftPanelToggle - | Self::SurfaceRightPanelToggle - | Self::SurfaceVerticalTabsToggle - | Self::FileList - | Self::FileOpen - | Self::ProjectActive - | Self::ProjectList - | Self::ProjectOpen => false, - } + tab { + TabList => { name: "tab.list", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Tab, params: None, result: TargetList }, + TabInspect => { name: "tab.inspect", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Tab, params: None, result: TargetMetadata }, + TabCreate => { name: "tab.create", status: Implemented, authenticated_user: false, contexts: OutsideWarpOnly, state: AppStateMutation, target: Tab, params: TabCreate, result: Acknowledgement }, + TabActivate => { name: "tab.activate", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Tab, params: TabActivate, result: Acknowledgement }, + TabMove => { name: "tab.move", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Tab, params: Direction, result: Acknowledgement }, + TabClose => { name: "tab.close", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Tab, params: TabClose, result: Acknowledgement }, + TabRename => { name: "tab.rename", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Tab, params: Rename, result: Acknowledgement }, + TabResetName => { name: "tab.reset_name", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Tab, params: None, result: Acknowledgement }, + TabColorSet => { name: "tab.color.set", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Tab, params: ColorValue, result: Acknowledgement }, + TabColorClear => { name: "tab.color.clear", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Tab, params: None, result: Acknowledgement }, } - fn default_risk_tier(self) -> RiskTier { - match self.default_state_data_category() { - StateDataCategory::MetadataRead => RiskTier::ReadOnlyMetadata, - StateDataCategory::UnderlyingDataRead => RiskTier::ReadOnlyTerminalData, - StateDataCategory::UnderlyingDataMutation => RiskTier::MutatingDestructiveOrExecution, - StateDataCategory::AppStateMutation - | StateDataCategory::MetadataConfigurationMutation => RiskTier::MutatingNonDestructive, - } + pane { + PaneList => { name: "pane.list", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Pane, params: None, result: TargetList }, + PaneInspect => { name: "pane.inspect", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Pane, params: None, result: TargetMetadata }, + PaneSplit => { name: "pane.split", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Pane, params: Direction, result: Acknowledgement }, + PaneFocus => { name: "pane.focus", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Pane, params: None, result: Acknowledgement }, + PaneNavigate => { name: "pane.navigate", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Pane, params: Direction, result: Acknowledgement }, + PaneResize => { name: "pane.resize", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Pane, params: Resize, result: Acknowledgement }, + PaneMaximize => { name: "pane.maximize", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Pane, params: None, result: Acknowledgement }, + PaneUnmaximize => { name: "pane.unmaximize", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Pane, params: None, result: Acknowledgement }, + PaneClose => { name: "pane.close", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Pane, params: None, result: Acknowledgement }, + PaneRename => { name: "pane.rename", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Pane, params: Rename, result: Acknowledgement }, + PaneResetName => { name: "pane.reset_name", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Pane, params: None, result: Acknowledgement }, } - fn default_state_data_category(self) -> StateDataCategory { - match self { - Self::InstanceList - | Self::InstanceInspect - | Self::AppPing - | Self::AppVersion - | Self::AppActive - | Self::AuthStatus - | Self::CapabilityList - | Self::CapabilityInspect - | Self::WindowList - | Self::WindowInspect - | Self::TabList - | Self::TabInspect - | Self::PaneList - | Self::PaneInspect - | Self::SessionList - | Self::SessionInspect - | Self::BlockList - | Self::ThemeList - | Self::ThemeGet - | Self::AppearanceGet - | Self::SettingList - | Self::SettingGet - | Self::KeybindingList - | Self::KeybindingGet - | Self::ActionList - | Self::ActionInspect - | Self::FileList - | Self::ProjectActive - | Self::ProjectList - | Self::DriveList => StateDataCategory::MetadataRead, - Self::BlockInspect - | Self::BlockOutput - | Self::InputGet - | Self::HistoryList - | Self::DriveInspect => StateDataCategory::UnderlyingDataRead, - Self::TabRename - | Self::TabResetName - | Self::TabColorSet - | Self::TabColorClear - | Self::PaneRename - | Self::PaneResetName - | Self::ThemeSet - | Self::ThemeSystemSet - | Self::ThemeLightSet - | Self::ThemeDarkSet - | Self::AppearanceFontSizeIncrease - | Self::AppearanceFontSizeDecrease - | Self::AppearanceFontSizeReset - | Self::AppearanceZoomIncrease - | Self::AppearanceZoomDecrease - | Self::AppearanceZoomReset - | Self::SettingSet - | Self::SettingToggle => StateDataCategory::MetadataConfigurationMutation, - Self::DriveObjectCreate - | Self::DriveObjectUpdate - | Self::DriveObjectDelete - | Self::DriveObjectInsert - | Self::DriveObjectShareToTeam - | Self::DriveWorkflowRun - | Self::InputRun => StateDataCategory::UnderlyingDataMutation, - Self::AppFocus - | Self::AuthLogin - | Self::WindowCreate - | Self::WindowFocus - | Self::WindowClose - | Self::TabCreate - | Self::TabActivate - | Self::TabMove - | Self::TabClose - | Self::PaneSplit - | Self::PaneFocus - | Self::PaneNavigate - | Self::PaneResize - | Self::PaneMaximize - | Self::PaneUnmaximize - | Self::PaneClose - | Self::SessionActivate - | Self::SessionPrevious - | Self::SessionNext - | Self::SessionReopenClosed - | Self::InputInsert - | Self::InputReplace - | Self::InputClear - | Self::InputModeSet - | Self::SurfaceSettingsOpen - | Self::SurfaceCommandPaletteOpen - | Self::SurfaceCommandSearchOpen - | Self::SurfaceWarpDriveOpen - | Self::SurfaceWarpDriveToggle - | Self::SurfaceResourceCenterToggle - | Self::SurfaceAiAssistantToggle - | Self::SurfaceCodeReviewToggle - | Self::SurfaceLeftPanelToggle - | Self::SurfaceRightPanelToggle - | Self::SurfaceVerticalTabsToggle - | Self::FileOpen - | Self::ProjectOpen - | Self::DriveOpen - | Self::DriveNotebookOpen - | Self::DriveEnvVarCollectionOpen - | Self::DriveObjectShareOpen => StateDataCategory::AppStateMutation, - } + session { + SessionList => { name: "session.list", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Session, params: None, result: TargetList }, + SessionInspect => { name: "session.inspect", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Session, params: None, result: TargetMetadata }, + SessionActivate => { name: "session.activate", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Session, params: None, result: Acknowledgement }, + SessionPrevious => { name: "session.previous", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Session, params: None, result: Acknowledgement }, + SessionNext => { name: "session.next", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Session, params: None, result: Acknowledgement }, + SessionReopenClosed => { name: "session.reopen_closed", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Session, params: None, result: Acknowledgement }, } - fn default_permission_category(self) -> PermissionCategory { - match self.default_state_data_category() { - StateDataCategory::MetadataRead => PermissionCategory::ReadMetadata, - StateDataCategory::UnderlyingDataRead => PermissionCategory::ReadUnderlyingData, - StateDataCategory::AppStateMutation => PermissionCategory::MutateAppState, - StateDataCategory::MetadataConfigurationMutation => { - PermissionCategory::MutateMetadataConfiguration - } - StateDataCategory::UnderlyingDataMutation => PermissionCategory::MutateUnderlyingData, - } + block { + BlockList => { name: "block.list", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Block, params: Limit, result: TargetList }, + BlockInspect => { name: "block.inspect", status: Stub, authenticated_user: false, contexts: Any, state: UnderlyingDataRead, target: Block, params: None, result: Content }, + BlockOutput => { name: "block.output", status: Stub, authenticated_user: false, contexts: Any, state: UnderlyingDataRead, target: Block, params: None, result: Content }, } - fn default_target_scope(self) -> TargetScope { - match self { - Self::InstanceList - | Self::InstanceInspect - | Self::AppPing - | Self::AppVersion - | Self::AppActive - | Self::AppFocus => TargetScope::Instance, - Self::AuthStatus | Self::AuthLogin => TargetScope::Auth, - Self::CapabilityList | Self::CapabilityInspect => TargetScope::Capability, - Self::WindowList - | Self::WindowInspect - | Self::WindowCreate - | Self::WindowFocus - | Self::WindowClose => TargetScope::Window, - Self::TabList - | Self::TabInspect - | Self::TabCreate - | Self::TabActivate - | Self::TabMove - | Self::TabClose - | Self::TabRename - | Self::TabResetName - | Self::TabColorSet - | Self::TabColorClear => TargetScope::Tab, - Self::PaneList - | Self::PaneInspect - | Self::PaneSplit - | Self::PaneFocus - | Self::PaneNavigate - | Self::PaneResize - | Self::PaneMaximize - | Self::PaneUnmaximize - | Self::PaneClose - | Self::PaneRename - | Self::PaneResetName => TargetScope::Pane, - Self::SessionList - | Self::SessionInspect - | Self::SessionActivate - | Self::SessionPrevious - | Self::SessionNext - | Self::SessionReopenClosed => TargetScope::Session, - Self::BlockList | Self::BlockInspect | Self::BlockOutput => TargetScope::Block, - Self::InputGet - | Self::InputInsert - | Self::InputReplace - | Self::InputClear - | Self::InputModeSet - | Self::InputRun => TargetScope::Input, - Self::HistoryList => TargetScope::History, - Self::ThemeList - | Self::ThemeGet - | Self::ThemeSet - | Self::ThemeSystemSet - | Self::ThemeLightSet - | Self::ThemeDarkSet - | Self::AppearanceGet - | Self::AppearanceFontSizeIncrease - | Self::AppearanceFontSizeDecrease - | Self::AppearanceFontSizeReset - | Self::AppearanceZoomIncrease - | Self::AppearanceZoomDecrease - | Self::AppearanceZoomReset => TargetScope::Appearance, - Self::SettingList | Self::SettingGet | Self::SettingSet | Self::SettingToggle => { - TargetScope::Settings - } - Self::KeybindingList | Self::KeybindingGet => TargetScope::Keybinding, - Self::ActionList | Self::ActionInspect => TargetScope::Action, - Self::SurfaceSettingsOpen - | Self::SurfaceCommandPaletteOpen - | Self::SurfaceCommandSearchOpen - | Self::SurfaceWarpDriveOpen - | Self::SurfaceWarpDriveToggle - | Self::SurfaceResourceCenterToggle - | Self::SurfaceAiAssistantToggle - | Self::SurfaceCodeReviewToggle - | Self::SurfaceLeftPanelToggle - | Self::SurfaceRightPanelToggle - | Self::SurfaceVerticalTabsToggle => TargetScope::Surface, - Self::FileList | Self::FileOpen => TargetScope::File, - Self::ProjectActive | Self::ProjectList | Self::ProjectOpen => TargetScope::Project, - Self::DriveList - | Self::DriveInspect - | Self::DriveOpen - | Self::DriveNotebookOpen - | Self::DriveEnvVarCollectionOpen - | Self::DriveObjectShareOpen - | Self::DriveObjectCreate - | Self::DriveObjectUpdate - | Self::DriveObjectDelete - | Self::DriveObjectInsert - | Self::DriveObjectShareToTeam - | Self::DriveWorkflowRun => TargetScope::DriveObject, - } + input { + InputGet => { name: "input.get", status: Stub, authenticated_user: false, contexts: Any, state: UnderlyingDataRead, target: Input, params: None, result: Content }, + InputInsert => { name: "input.insert", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Input, params: Text, result: Acknowledgement }, + InputReplace => { name: "input.replace", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Input, params: Text, result: Acknowledgement }, + InputClear => { name: "input.clear", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Input, params: None, result: Acknowledgement }, + InputModeSet => { name: "input.mode.set", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Input, params: InputMode, result: Acknowledgement }, + InputRun => { name: "input.run", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: UnderlyingDataMutation, target: Input, params: Text, result: Acknowledgement }, } - fn parameter_spec(self) -> ActionParameterSpec { - match self { - Self::InstanceList - | Self::InstanceInspect - | Self::AppPing - | Self::AppVersion - | Self::AppActive - | Self::AppFocus - | Self::AuthStatus - | Self::AuthLogin - | Self::CapabilityList - | Self::WindowList - | Self::WindowInspect - | Self::TabList - | Self::TabInspect - | Self::PaneList - | Self::PaneInspect - | Self::SessionList - | Self::SessionInspect - | Self::ThemeList - | Self::ThemeGet - | Self::AppearanceGet - | Self::KeybindingList - | Self::ActionList - | Self::FileList - | Self::ProjectActive - | Self::ProjectList => ActionParameterSpec::None, - Self::CapabilityInspect | Self::ActionInspect => ActionParameterSpec::ActionName, - Self::WindowCreate | Self::TabCreate => ActionParameterSpec::TabCreate, - Self::WindowFocus - | Self::WindowClose - | Self::TabResetName - | Self::TabColorClear - | Self::PaneFocus - | Self::PaneMaximize - | Self::PaneUnmaximize - | Self::PaneClose - | Self::PaneResetName - | Self::SessionActivate => ActionParameterSpec::None, - Self::TabActivate => ActionParameterSpec::TabActivate, - Self::TabMove | Self::PaneSplit | Self::PaneNavigate => ActionParameterSpec::Direction, - Self::TabClose => ActionParameterSpec::TabClose, - Self::TabRename | Self::PaneRename => ActionParameterSpec::Rename, - Self::TabColorSet => ActionParameterSpec::ColorValue, - Self::PaneResize => ActionParameterSpec::Resize, - Self::SessionPrevious | Self::SessionNext | Self::SessionReopenClosed => { - ActionParameterSpec::None - } - Self::BlockList | Self::HistoryList => ActionParameterSpec::Limit, - Self::BlockInspect | Self::BlockOutput => ActionParameterSpec::None, - Self::InputGet => ActionParameterSpec::None, - Self::InputInsert | Self::InputReplace | Self::InputRun => ActionParameterSpec::Text, - Self::InputClear => ActionParameterSpec::None, - Self::InputModeSet => ActionParameterSpec::InputMode, - Self::ThemeSet | Self::ThemeLightSet | Self::ThemeDarkSet => { - ActionParameterSpec::ThemeName - } - Self::ThemeSystemSet => ActionParameterSpec::BooleanValue, - Self::AppearanceFontSizeIncrease - | Self::AppearanceFontSizeDecrease - | Self::AppearanceFontSizeReset - | Self::AppearanceZoomIncrease - | Self::AppearanceZoomDecrease - | Self::AppearanceZoomReset => ActionParameterSpec::None, - Self::SettingList => ActionParameterSpec::Namespace, - Self::SettingGet => ActionParameterSpec::Key, - Self::SettingSet => ActionParameterSpec::KeyValue, - Self::SettingToggle => ActionParameterSpec::Key, - Self::KeybindingGet => ActionParameterSpec::BindingName, - Self::SurfaceSettingsOpen => ActionParameterSpec::PageQuery, - Self::SurfaceCommandPaletteOpen | Self::SurfaceCommandSearchOpen => { - ActionParameterSpec::Query - } - Self::SurfaceWarpDriveOpen - | Self::SurfaceWarpDriveToggle - | Self::SurfaceResourceCenterToggle - | Self::SurfaceAiAssistantToggle - | Self::SurfaceCodeReviewToggle - | Self::SurfaceLeftPanelToggle - | Self::SurfaceRightPanelToggle - | Self::SurfaceVerticalTabsToggle => ActionParameterSpec::None, - Self::FileOpen => ActionParameterSpec::FileOpen, - Self::ProjectOpen => ActionParameterSpec::Path, - Self::DriveList => ActionParameterSpec::DriveObjectList, - Self::DriveInspect - | Self::DriveOpen - | Self::DriveNotebookOpen - | Self::DriveEnvVarCollectionOpen - | Self::DriveObjectShareOpen - | Self::DriveObjectDelete - | Self::DriveObjectShareToTeam => ActionParameterSpec::DriveObjectId, - Self::DriveObjectCreate => ActionParameterSpec::DriveObjectCreate, - Self::DriveObjectUpdate => ActionParameterSpec::DriveObjectUpdate, - Self::DriveObjectInsert => ActionParameterSpec::DriveObjectInsert, - Self::DriveWorkflowRun => ActionParameterSpec::WorkflowRun, - } + history { + HistoryList => { name: "history.list", status: Stub, authenticated_user: false, contexts: Any, state: UnderlyingDataRead, target: History, params: Limit, result: Content }, } - fn result_spec(self) -> ActionResultSpec { - match self { - Self::InstanceList => ActionResultSpec::InstanceList, - Self::InstanceInspect | Self::AppPing | Self::AppVersion => { - ActionResultSpec::InstanceMetadata - } - Self::AppActive => ActionResultSpec::ActiveTarget, - Self::AuthStatus => ActionResultSpec::AuthStatus, - Self::CapabilityList => ActionResultSpec::CapabilityList, - Self::CapabilityInspect => ActionResultSpec::CapabilityMetadata, - Self::WindowList - | Self::TabList - | Self::PaneList - | Self::SessionList - | Self::BlockList => ActionResultSpec::TargetList, - Self::WindowInspect | Self::TabInspect | Self::PaneInspect | Self::SessionInspect => { - ActionResultSpec::TargetMetadata - } - Self::BlockInspect | Self::BlockOutput | Self::InputGet | Self::HistoryList => { - ActionResultSpec::Content - } - Self::ThemeList => ActionResultSpec::ThemeList, - Self::ThemeGet => ActionResultSpec::ThemeState, - Self::AppearanceGet => ActionResultSpec::AppearanceState, - Self::SettingList => ActionResultSpec::SettingList, - Self::SettingGet => ActionResultSpec::SettingValue, - Self::KeybindingList => ActionResultSpec::KeybindingList, - Self::KeybindingGet => ActionResultSpec::KeybindingMetadata, - Self::ActionList => ActionResultSpec::CapabilityList, - Self::ActionInspect => ActionResultSpec::CapabilityMetadata, - Self::FileList => ActionResultSpec::FileList, - Self::ProjectActive | Self::ProjectList => ActionResultSpec::ProjectList, - Self::DriveList => ActionResultSpec::DriveObjectList, - Self::DriveInspect => ActionResultSpec::DriveObjectMetadata, - Self::AppFocus - | Self::AuthLogin - | Self::WindowCreate - | Self::WindowFocus - | Self::WindowClose - | Self::TabCreate - | Self::TabActivate - | Self::TabMove - | Self::TabClose - | Self::TabRename - | Self::TabResetName - | Self::TabColorSet - | Self::TabColorClear - | Self::PaneSplit - | Self::PaneFocus - | Self::PaneNavigate - | Self::PaneResize - | Self::PaneMaximize - | Self::PaneUnmaximize - | Self::PaneClose - | Self::PaneRename - | Self::PaneResetName - | Self::SessionActivate - | Self::SessionPrevious - | Self::SessionNext - | Self::SessionReopenClosed - | Self::InputInsert - | Self::InputReplace - | Self::InputClear - | Self::InputModeSet - | Self::InputRun - | Self::ThemeSet - | Self::ThemeSystemSet - | Self::ThemeLightSet - | Self::ThemeDarkSet - | Self::AppearanceFontSizeIncrease - | Self::AppearanceFontSizeDecrease - | Self::AppearanceFontSizeReset - | Self::AppearanceZoomIncrease - | Self::AppearanceZoomDecrease - | Self::AppearanceZoomReset - | Self::SettingSet - | Self::SettingToggle - | Self::SurfaceSettingsOpen - | Self::SurfaceCommandPaletteOpen - | Self::SurfaceCommandSearchOpen - | Self::SurfaceWarpDriveOpen - | Self::SurfaceWarpDriveToggle - | Self::SurfaceResourceCenterToggle - | Self::SurfaceAiAssistantToggle - | Self::SurfaceCodeReviewToggle - | Self::SurfaceLeftPanelToggle - | Self::SurfaceRightPanelToggle - | Self::SurfaceVerticalTabsToggle - | Self::FileOpen - | Self::ProjectOpen - | Self::DriveOpen - | Self::DriveNotebookOpen - | Self::DriveEnvVarCollectionOpen - | Self::DriveObjectShareOpen - | Self::DriveObjectCreate - | Self::DriveObjectUpdate - | Self::DriveObjectDelete - | Self::DriveObjectInsert - | Self::DriveObjectShareToTeam - | Self::DriveWorkflowRun => ActionResultSpec::Acknowledgement, - } + theme { + ThemeList => { name: "theme.list", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Appearance, params: None, result: ThemeList }, + ThemeGet => { name: "theme.get", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Appearance, params: None, result: ThemeState }, + ThemeSet => { name: "theme.set", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Appearance, params: ThemeName, result: Acknowledgement }, + ThemeSystemSet => { name: "theme.system.set", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Appearance, params: BooleanValue, result: Acknowledgement }, + ThemeLightSet => { name: "theme.light.set", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Appearance, params: ThemeName, result: Acknowledgement }, + ThemeDarkSet => { name: "theme.dark.set", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Appearance, params: ThemeName, result: Acknowledgement }, } -} -mod serde_names { - use super::ActionKind; + appearance { + AppearanceGet => { name: "appearance.get", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Appearance, params: None, result: AppearanceState }, + AppearanceFontSizeIncrease => { name: "appearance.font_size.increase", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Appearance, params: None, result: Acknowledgement }, + AppearanceFontSizeDecrease => { name: "appearance.font_size.decrease", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Appearance, params: None, result: Acknowledgement }, + AppearanceFontSizeReset => { name: "appearance.font_size.reset", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Appearance, params: None, result: Acknowledgement }, + AppearanceZoomIncrease => { name: "appearance.zoom.increase", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Appearance, params: None, result: Acknowledgement }, + AppearanceZoomDecrease => { name: "appearance.zoom.decrease", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Appearance, params: None, result: Acknowledgement }, + AppearanceZoomReset => { name: "appearance.zoom.reset", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Appearance, params: None, result: Acknowledgement }, + } - pub(super) fn action_name(action: ActionKind) -> &'static str { - match action { - ActionKind::InstanceList => "instance.list", - ActionKind::InstanceInspect => "instance.inspect", - ActionKind::AppPing => "app.ping", - ActionKind::AppVersion => "app.version", - ActionKind::AppActive => "app.active", - ActionKind::AppFocus => "app.focus", - ActionKind::AuthStatus => "auth.status", - ActionKind::AuthLogin => "auth.login", - ActionKind::CapabilityList => "capability.list", - ActionKind::CapabilityInspect => "capability.inspect", - ActionKind::WindowList => "window.list", - ActionKind::WindowInspect => "window.inspect", - ActionKind::WindowCreate => "window.create", - ActionKind::WindowFocus => "window.focus", - ActionKind::WindowClose => "window.close", - ActionKind::TabList => "tab.list", - ActionKind::TabInspect => "tab.inspect", - ActionKind::TabCreate => "tab.create", - ActionKind::TabActivate => "tab.activate", - ActionKind::TabMove => "tab.move", - ActionKind::TabClose => "tab.close", - ActionKind::TabRename => "tab.rename", - ActionKind::TabResetName => "tab.reset_name", - ActionKind::TabColorSet => "tab.color.set", - ActionKind::TabColorClear => "tab.color.clear", - ActionKind::PaneList => "pane.list", - ActionKind::PaneInspect => "pane.inspect", - ActionKind::PaneSplit => "pane.split", - ActionKind::PaneFocus => "pane.focus", - ActionKind::PaneNavigate => "pane.navigate", - ActionKind::PaneResize => "pane.resize", - ActionKind::PaneMaximize => "pane.maximize", - ActionKind::PaneUnmaximize => "pane.unmaximize", - ActionKind::PaneClose => "pane.close", - ActionKind::PaneRename => "pane.rename", - ActionKind::PaneResetName => "pane.reset_name", - ActionKind::SessionList => "session.list", - ActionKind::SessionInspect => "session.inspect", - ActionKind::SessionActivate => "session.activate", - ActionKind::SessionPrevious => "session.previous", - ActionKind::SessionNext => "session.next", - ActionKind::SessionReopenClosed => "session.reopen_closed", - ActionKind::BlockList => "block.list", - ActionKind::BlockInspect => "block.inspect", - ActionKind::BlockOutput => "block.output", - ActionKind::InputGet => "input.get", - ActionKind::InputInsert => "input.insert", - ActionKind::InputReplace => "input.replace", - ActionKind::InputClear => "input.clear", - ActionKind::InputModeSet => "input.mode.set", - ActionKind::InputRun => "input.run", - ActionKind::HistoryList => "history.list", - ActionKind::ThemeList => "theme.list", - ActionKind::ThemeGet => "theme.get", - ActionKind::ThemeSet => "theme.set", - ActionKind::ThemeSystemSet => "theme.system.set", - ActionKind::ThemeLightSet => "theme.light.set", - ActionKind::ThemeDarkSet => "theme.dark.set", - ActionKind::AppearanceGet => "appearance.get", - ActionKind::AppearanceFontSizeIncrease => "appearance.font_size.increase", - ActionKind::AppearanceFontSizeDecrease => "appearance.font_size.decrease", - ActionKind::AppearanceFontSizeReset => "appearance.font_size.reset", - ActionKind::AppearanceZoomIncrease => "appearance.zoom.increase", - ActionKind::AppearanceZoomDecrease => "appearance.zoom.decrease", - ActionKind::AppearanceZoomReset => "appearance.zoom.reset", - ActionKind::SettingList => "setting.list", - ActionKind::SettingGet => "setting.get", - ActionKind::SettingSet => "setting.set", - ActionKind::SettingToggle => "setting.toggle", - ActionKind::KeybindingList => "keybinding.list", - ActionKind::KeybindingGet => "keybinding.get", - ActionKind::ActionList => "action.list", - ActionKind::ActionInspect => "action.inspect", - ActionKind::SurfaceSettingsOpen => "surface.settings.open", - ActionKind::SurfaceCommandPaletteOpen => "surface.command_palette.open", - ActionKind::SurfaceCommandSearchOpen => "surface.command_search.open", - ActionKind::SurfaceWarpDriveOpen => "surface.warp_drive.open", - ActionKind::SurfaceWarpDriveToggle => "surface.warp_drive.toggle", - ActionKind::SurfaceResourceCenterToggle => "surface.resource_center.toggle", - ActionKind::SurfaceAiAssistantToggle => "surface.ai_assistant.toggle", - ActionKind::SurfaceCodeReviewToggle => "surface.code_review.toggle", - ActionKind::SurfaceLeftPanelToggle => "surface.left_panel.toggle", - ActionKind::SurfaceRightPanelToggle => "surface.right_panel.toggle", - ActionKind::SurfaceVerticalTabsToggle => "surface.vertical_tabs.toggle", - ActionKind::FileList => "file.list", - ActionKind::FileOpen => "file.open", - ActionKind::ProjectActive => "project.active", - ActionKind::ProjectList => "project.list", - ActionKind::ProjectOpen => "project.open", - ActionKind::DriveList => "drive.list", - ActionKind::DriveInspect => "drive.inspect", - ActionKind::DriveOpen => "drive.open", - ActionKind::DriveNotebookOpen => "drive.notebook.open", - ActionKind::DriveEnvVarCollectionOpen => "drive.env_var_collection.open", - ActionKind::DriveObjectShareOpen => "drive.object.share.open", - ActionKind::DriveObjectCreate => "drive.object.create", - ActionKind::DriveObjectUpdate => "drive.object.update", - ActionKind::DriveObjectDelete => "drive.object.delete", - ActionKind::DriveObjectInsert => "drive.object.insert", - ActionKind::DriveObjectShareToTeam => "drive.object.share_to_team", - ActionKind::DriveWorkflowRun => "drive.workflow.run", - } + setting { + SettingList => { name: "setting.list", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Settings, params: Namespace, result: SettingList }, + SettingGet => { name: "setting.get", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Settings, params: Key, result: SettingValue }, + SettingSet => { name: "setting.set", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Settings, params: KeyValue, result: Acknowledgement }, + SettingToggle => { name: "setting.toggle", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Settings, params: Key, result: Acknowledgement }, + } + + keybinding { + KeybindingList => { name: "keybinding.list", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Keybinding, params: None, result: KeybindingList }, + KeybindingGet => { name: "keybinding.get", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Keybinding, params: BindingName, result: KeybindingMetadata }, + } + + action { + ActionList => { name: "action.list", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Action, params: None, result: CapabilityList }, + ActionInspect => { name: "action.inspect", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Action, params: ActionName, result: CapabilityMetadata }, + } + + surface { + SurfaceSettingsOpen => { name: "surface.settings.open", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Surface, params: PageQuery, result: Acknowledgement }, + SurfaceCommandPaletteOpen => { name: "surface.command_palette.open", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Surface, params: Query, result: Acknowledgement }, + SurfaceCommandSearchOpen => { name: "surface.command_search.open", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Surface, params: Query, result: Acknowledgement }, + SurfaceWarpDriveOpen => { name: "surface.warp_drive.open", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Surface, params: None, result: Acknowledgement }, + SurfaceWarpDriveToggle => { name: "surface.warp_drive.toggle", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Surface, params: None, result: Acknowledgement }, + SurfaceResourceCenterToggle => { name: "surface.resource_center.toggle", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Surface, params: None, result: Acknowledgement }, + SurfaceAiAssistantToggle => { name: "surface.ai_assistant.toggle", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Surface, params: None, result: Acknowledgement }, + SurfaceCodeReviewToggle => { name: "surface.code_review.toggle", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Surface, params: None, result: Acknowledgement }, + SurfaceLeftPanelToggle => { name: "surface.left_panel.toggle", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Surface, params: None, result: Acknowledgement }, + SurfaceRightPanelToggle => { name: "surface.right_panel.toggle", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Surface, params: None, result: Acknowledgement }, + SurfaceVerticalTabsToggle => { name: "surface.vertical_tabs.toggle", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Surface, params: None, result: Acknowledgement }, + } + + file { + FileList => { name: "file.list", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: File, params: None, result: FileList }, + FileOpen => { name: "file.open", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: File, params: FileOpen, result: Acknowledgement }, + } + + drive { + DriveList => { name: "drive.list", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: MetadataRead, target: DriveObject, params: DriveObjectList, result: DriveObjectList }, + DriveInspect => { name: "drive.inspect", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: UnderlyingDataRead, target: DriveObject, params: DriveObjectId, result: DriveObjectMetadata }, + DriveOpen => { name: "drive.open", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: AppStateMutation, target: DriveObject, params: DriveObjectId, result: Acknowledgement }, + DriveNotebookOpen => { name: "drive.notebook.open", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: AppStateMutation, target: DriveObject, params: DriveObjectId, result: Acknowledgement }, + DriveEnvVarCollectionOpen => { name: "drive.env_var_collection.open", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: AppStateMutation, target: DriveObject, params: DriveObjectId, result: Acknowledgement }, + DriveObjectShareOpen => { name: "drive.object.share.open", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: AppStateMutation, target: DriveObject, params: DriveObjectId, result: Acknowledgement }, + DriveObjectCreate => { name: "drive.object.create", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: UnderlyingDataMutation, target: DriveObject, params: DriveObjectCreate, result: Acknowledgement }, + DriveObjectUpdate => { name: "drive.object.update", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: UnderlyingDataMutation, target: DriveObject, params: DriveObjectUpdate, result: Acknowledgement }, + DriveObjectDelete => { name: "drive.object.delete", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: UnderlyingDataMutation, target: DriveObject, params: DriveObjectId, result: Acknowledgement }, + DriveObjectInsert => { name: "drive.object.insert", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: UnderlyingDataMutation, target: DriveObject, params: DriveObjectInsert, result: Acknowledgement }, + DriveObjectShareToTeam => { name: "drive.object.share_to_team", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: UnderlyingDataMutation, target: DriveObject, params: DriveObjectId, result: Acknowledgement }, + DriveWorkflowRun => { name: "drive.workflow.run", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: UnderlyingDataMutation, target: DriveObject, params: WorkflowRun, result: Acknowledgement }, } } diff --git a/crates/local_control/src/protocol.rs b/crates/local_control/src/protocol.rs index e7e0f1bad3..53f5716ca8 100644 --- a/crates/local_control/src/protocol.rs +++ b/crates/local_control/src/protocol.rs @@ -4,9 +4,8 @@ use uuid::Uuid; pub use crate::catalog::{ ActionImplementationStatus, ActionKind, ActionMetadata, ActionParameterSpec, ActionResultSpec, - AuthenticatedUserRequirement, EXCLUDED_LOCAL_FILE_MUTATION_ACTION_NAMES, - EXCLUDED_STANDALONE_SECRET_AUTH_ACTION_NAMES, ExecutionContextProof, InvocationContext, - PROTOCOL_VERSION, PermissionCategory, RiskTier, StateDataCategory, TargetScope, + AuthenticatedUserRequirement, ExecutionContextProof, InvocationContext, PROTOCOL_VERSION, + PermissionCategory, RiskTier, StateDataCategory, TargetScope, }; pub use crate::selectors::{ PaneSelector, PaneTarget, TabSelector, TabTarget, TargetSelector, WindowSelector, WindowTarget, @@ -121,9 +120,6 @@ pub enum ActionParams { page: Option<String>, query: Option<String>, }, - Path { - path: String, - }, Query { query: Option<String>, }, diff --git a/crates/local_control/src/protocol_tests.rs b/crates/local_control/src/protocol_tests.rs index 5c8c49bdaf..9bd9ae26a8 100644 --- a/crates/local_control/src/protocol_tests.rs +++ b/crates/local_control/src/protocol_tests.rs @@ -35,7 +35,7 @@ fn malformed_action_name_is_not_deserialized() { } #[test] -fn excluded_action_names_are_not_deserialized() { +fn non_allowlisted_action_names_are_not_deserialized() { for action in [ "file.write", "file.delete", diff --git a/crates/warp_cli/src/local_control/commands.rs b/crates/warp_cli/src/local_control/commands.rs index 6bc57585c5..9cf097e416 100644 --- a/crates/warp_cli/src/local_control/commands.rs +++ b/crates/warp_cli/src/local_control/commands.rs @@ -4,7 +4,6 @@ use local_control::protocol::{ }; use local_control::selection::select_instance; use serde::Serialize; -use serde_json::json; use crate::agent::OutputFormat; use crate::local_control::output::{write_json, write_json_line}; @@ -83,10 +82,8 @@ pub(super) fn run_app_command( output_format: OutputFormat, ) -> Result<(), ControlError> { match command { - AppCommand::Ping(args) => run_action(args, ActionKind::AppPing, json!({}), output_format), - AppCommand::Version(args) => { - run_action(args, ActionKind::AppVersion, json!({}), output_format) - } + AppCommand::Ping(args) => run_action(args, ActionKind::AppPing, output_format), + AppCommand::Version(args) => run_action(args, ActionKind::AppVersion, output_format), } } pub(super) fn run_tab_command( @@ -94,25 +91,19 @@ pub(super) fn run_tab_command( output_format: OutputFormat, ) -> Result<(), ControlError> { match command { - TabCommand::Create(args) => { - run_action(args, ActionKind::TabCreate, json!({}), output_format) - } + TabCommand::Create(args) => run_action(args, ActionKind::TabCreate, output_format), } } fn run_action( args: TargetArgs, action: ActionKind, - params: serde_json::Value, output_format: OutputFormat, ) -> Result<(), ControlError> { let records = local_control::discovery::list_instances(); let selector = instance_selector(args); let instance = select_instance(&records, &selector)?; - let request = RequestEnvelope::new(Action { - kind: action, - params, - }); + let request = RequestEnvelope::new(Action::new(action)); let response = local_control::client::send_request(&instance, &request)?; let local_control::protocol::ControlResponse::Ok { data } = response.response else { return Err(ControlError::new( diff --git a/crates/warp_features/src/lib.rs b/crates/warp_features/src/lib.rs index 9a35c2d6f1..6f05819ac0 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -949,7 +949,6 @@ pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[ FeatureFlag::GroupedTabs, FeatureFlag::AsyncFind, FeatureFlag::RemoteCodeReview, - FeatureFlag::WarpControlCli, ]; /// Features enabled for feature preview build users (e.g.: Friends of Warp). @@ -999,7 +998,9 @@ impl FeatureFlag { // Allow calling this in integration tests because we sometimes use it in the app // during flows that integration tests cover. if cfg!(test) && cfg!(not(feature = "integration_tests")) { - panic!("Tried to globally enable {self:?} in a test. Use FeatureFlag::{self:?}.override_enabled instead"); + panic!( + "Tried to globally enable {self:?} in a test. Use FeatureFlag::{self:?}.override_enabled instead" + ); } FLAG_STATES[self as usize].store(enabled, Ordering::Relaxed); } @@ -1042,16 +1043,28 @@ impl FeatureFlag { BlocklistMarkdownImages => { Some("Enables rendering markdown images inline in AI block list responses.") } - CloudEnvironments => Some("Enables creating and managing Warp Environments via the CLI."), - CreateEnvironmentSlashCommand => Some("Enables the /create environment slash command for setting up Warp Environments with custom configurations."), + CloudEnvironments => { + Some("Enables creating and managing Warp Environments via the CLI.") + } + CreateEnvironmentSlashCommand => Some( + "Enables the /create environment slash command for setting up Warp Environments with custom configurations.", + ), GlobalSearch => Some("Enables global search in the left panel"), BlocklistMarkdownTableRendering => { Some("Enables rendering markdown tables inline in AI block list responses.") } - MarkdownTables => Some("Enables rendering and interaction support for markdown tables in notebooks."), - SettingsFile => Some("Enables configuring Warp via a user-editable `settings.toml` file, with hot reload and error reporting for invalid values."), - GitOperationsInCodeReview => Some("Enables commit, push, and create-PR actions directly from the code review panel."), - OrchestrationV2 => Some("Enables orchestration of teams of agents with dedicated UI, lifecycle events and inter-agent messaging."), + MarkdownTables => { + Some("Enables rendering and interaction support for markdown tables in notebooks.") + } + SettingsFile => Some( + "Enables configuring Warp via a user-editable `settings.toml` file, with hot reload and error reporting for invalid values.", + ), + GitOperationsInCodeReview => Some( + "Enables commit, push, and create-PR actions directly from the code review panel.", + ), + OrchestrationV2 => Some( + "Enables orchestration of teams of agents with dedicated UI, lifecycle events and inter-agent messaging.", + ), _ => None, } } diff --git a/specs/warp-control-cli/PRODUCT.md b/specs/warp-control-cli/PRODUCT.md index 725241019b..92a0db3bd2 100644 --- a/specs/warp-control-cli/PRODUCT.md +++ b/specs/warp-control-cli/PRODUCT.md @@ -8,7 +8,7 @@ Goals: - Make Warp's own UI and app state available to agents through a typed, permissioned control plane instead of brittle screen automation or arbitrary internal dispatch. - Keep CLI startup lightweight by avoiding GUI-app startup or full terminal initialization for routine control commands. - Keep the surface allowlisted and finite instead of exposing arbitrary internal actions. -- Make targeting explicit and deterministic across multiple Warp processes, windows, tabs, panes, terminal sessions, terminal blocks, Warp Drive objects, files, projects/workspaces, command surfaces, and other uniquely addressable Warp nouns. +- Make targeting explicit and deterministic across multiple Warp processes, windows, tabs, panes, terminal sessions, terminal blocks, Warp Drive objects, files, command surfaces, and other uniquely addressable Warp nouns. - Support both ergonomic active-target defaults and precise selectors for automation. - Define a complete protocol/catalog up front, while shipping the implementation incrementally. Non-goals: @@ -32,7 +32,7 @@ Persistent settings changes, Warp Drive creation or sharing, cross-app preferenc 2. The CLI exposes only explicitly allowlisted actions. Unknown action names, unsupported parameter combinations, or requests for non-allowlisted capabilities fail with structured errors; they are never forwarded to arbitrary internal dispatch. 3. Every successful mutating request identifies: - The Warp process instance that executed it. - - The resolved target, when the action addresses a window, tab, pane, terminal session, terminal block, file, project/workspace, Warp Drive object, surface, or other targetable noun. + - The resolved target, when the action addresses a window, tab, pane, terminal session, terminal block, file, Warp Drive object, surface, or other targetable noun. - A success payload suitable for JSON output. 4. Every failure identifies: - A stable machine-readable error code. @@ -61,7 +61,7 @@ Persistent settings changes, Warp Drive creation or sharing, cross-app preferenc - Pane selector resolves within the tab or active pane group context. - Session selector resolves within the pane when the pane hosts terminal session state. - Block selector resolves within the terminal session when the command is block-scoped. - Non-hierarchical selectors such as file paths, projects/workspaces, Warp Drive objects, and global app surfaces still resolve inside the selected instance and must not silently borrow lower-level pane/session defaults unless the action definition explicitly requires that scope. + Non-hierarchical selectors such as file paths, Warp Drive objects, and global app surfaces still resolve inside the selected instance and must not silently borrow lower-level pane/session defaults unless the action definition explicitly requires that scope. 9. Every selector family supports an ergonomic `active` form when that concept exists: - Active instance, if unambiguous. - Active window in the selected instance. @@ -76,13 +76,12 @@ Persistent settings changes, Warp Drive creation or sharing, cross-app preferenc - Session selectors support `active`, opaque session IDs, and session indices scoped to the resolved pane when sessions are user-visible as an ordered list. - Block selectors support `active`, opaque block IDs, and block indices scoped to the resolved terminal session when blocks are user-visible as an ordered list. A block command may also support read-only filters such as command text, status, time range, or “last completed” for interactive lookup, but those filters must fail on ambiguity and resolve to concrete block IDs before reading output. - File selectors use paths, plus optional line/column coordinates where the command supports opening a location. - - Project/workspace selectors use paths, opaque project/workspace IDs when exposed by introspection, and exact names only as interactive convenience selectors. - Warp Drive selectors use opaque object IDs, with optional type-scoped exact name/path lookups for interactive use. Type scopes must include the user-facing object families Warp exposes today: spaces, folders, notebooks, workflows, agent-mode workflows/prompts, environment variable collections, AI facts/rules, MCP servers, MCP server collections, and trash entries when trash operations are supported. 11. “Active session” means the currently selected terminal session for the resolved pane/window context. If the selected target does not contain a terminal session, session-scoped actions fail rather than silently redirecting elsewhere. 12. When a command omits lower-level selectors, it resolves them from the chosen higher-level context using active defaults. Example: a pane split command with only `--instance` uses that instance’s active window, active tab, and active pane. 13. When an explicitly supplied target disappears between discovery and execution, the request fails with a stale-target error. The CLI must not silently choose a different tab, pane, or session. 14. The protocol is command-oriented, not open-ended state mutation. Each action has a named command, validated parameters, and defined target scope. -15. The complete allowlisted action catalog should be organized around stable public nouns rather than internal view/action names. The target taxonomy includes instances, windows, tabs, panes, terminal sessions, terminal blocks, input buffers, command history entries, file/path intents, projects/workspaces, Warp Drive spaces, folders, notebooks, workflows, agent-mode workflows/prompts, environment variable collections, AI facts/rules, MCP servers, MCP server collections, settings, themes, keybindings, command surfaces such as the command palette and command search, panels/surfaces such as Warp Drive, resource center, AI assistant, code review, left/right panels, and vertical tabs, plus action/capability metadata. The initial implementation may expose only a subset, but new command families should extend this noun taxonomy instead of inventing unrelated selector conventions. +15. The complete allowlisted action catalog should be organized around stable public nouns rather than internal view/action names. The target taxonomy includes instances, windows, tabs, panes, terminal sessions, terminal blocks, input buffers, command history entries, file/path intents, Warp Drive spaces, folders, notebooks, workflows, agent-mode workflows/prompts, environment variable collections, AI facts/rules, MCP servers, MCP server collections, settings, themes, keybindings, command surfaces such as the command palette and command search, panels/surfaces such as Warp Drive, resource center, AI assistant, code review, left/right panels, and vertical tabs, plus action/capability metadata. The initial implementation may expose only a subset, but new command families should extend this noun taxonomy instead of inventing unrelated selector conventions. 16. Discovery and read-only state actions: - List instances. - Get protocol/app version information for one instance. @@ -202,7 +201,7 @@ The public `warpctrl` API is organized around nouns that map to stable user-faci Catalog support status is part of the public API contract. An action reported as `implemented` by `warpctrl action list --implemented-only`, `warpctrl capability list --implemented-only`, or app discovery metadata must be reachable through a standalone `warpctrl ...` parser route, represented in generated help/completions/docs, and backed by an app-side bridge handler in the selected app build. Planned actions without that complete path must be reported as stubs or planned entries, even if an internal app handler already exists. ### State and data taxonomy The product surface must distinguish what kind of state a command touches. This distinction is part of the public API and the permission model, not just an implementation detail. -- **Metadata reads** inspect app structure or configuration metadata without exposing user content: instances, windows, tabs, panes, sessions, capability metadata, action metadata, keybinding metadata, theme names, setting keys, current project identity, and other structural state. +- **Metadata reads** inspect app structure or configuration metadata without exposing user content: instances, windows, tabs, panes, sessions, capability metadata, action metadata, keybinding metadata, theme names, setting keys, and other structural state. - **Underlying data reads** expose user content or data-bearing state without changing it: terminal output, block contents, command history, input buffer contents, Warp Drive object contents, AI conversation content, and any other content that could contain user data or secrets. - **App-state mutations** change visible local Warp UI state without directly changing user data: opening or focusing windows, creating or closing tabs, splitting panes, focusing panes, opening panels, opening command surfaces, opening files in Warp, and editing the input buffer without submitting it. - **Metadata/configuration mutations** change persistent configuration or metadata, but not primary user content: changing themes, font size, zoom, allowlisted settings, keybindings, tab names, pane names, and tab colors. @@ -261,10 +260,8 @@ Appearance, settings, and command-surface reads: - `warpctrl keybinding get <binding_name> [selectors]` - `warpctrl action list [selectors]` - `warpctrl action inspect <action> [selectors]` -Local file and project reads that expose only app/editor state, not arbitrary filesystem traversal: +Local file reads that expose only app/editor state, not arbitrary filesystem traversal: - `warpctrl file list [selectors]` -- `warpctrl project active [selectors]` -- `warpctrl project list [selectors]` Authenticated read-only Warp Drive metadata and data reads, enabled only when the selected app has a logged-in Warp user and the grant allows authenticated reads. Listing is metadata; inspecting object content is an underlying data read: - `warpctrl drive list --type <workflow|notebook|env-var-collection|prompt|folder|ai-fact|mcp-server|space|trash> [selectors]` - `warpctrl drive inspect <id> [selectors]` @@ -347,9 +344,8 @@ Metadata/configuration mutations for appearance and settings: - `warpctrl appearance zoom reset [selectors]` - `warpctrl setting set <key> <value> [selectors]` - `warpctrl setting toggle <key> [selectors]` -App-state mutations for files, projects, and Warp Drive views: +App-state mutations for files and Warp Drive views: - `warpctrl file open <path> [--line <line>] [--column <column>] [--new-tab] [selectors]` -- `warpctrl project open <path> [selectors]` - `warpctrl drive open <id> [selectors]` - `warpctrl drive notebook open <id> [selectors]` - `warpctrl drive env-var-collection open <id> [selectors]` @@ -391,7 +387,7 @@ CLI documentation should be generated from the command catalog instead of mainta - Generated documentation must distinguish implemented commands from planned catalog entries. A command may appear in specs as planned, but public operator docs must not imply it is usable until the selected app build advertises support for it. - CI or presubmit checks should fail when CLI parser/help output, generated reference docs, completions, or the built-in skill are stale relative to the command catalog. ## Action classification and permission model -Agents, scripts, and human developers are expected to be major consumers of `warpctrl`. The action catalog must therefore classify every action by risk posture, state/data category, permission category, and authenticated-user requirement so Warp can enforce local-control permissions in the app bridge. +Agents, scripts, and human developers are expected to be major consumers of `warpctrl`. The action catalog must therefore classify every action by risk posture, state/data category, permission category, and authenticated-user requirement so Warp can enforce local-control policy in the app bridge. Every action definition must include: - a stable action name and namespace; - a risk posture; @@ -411,7 +407,7 @@ Every action in the catalog belongs to exactly one of the following permission c 1. **Read-only / metadata.** Actions that return local app structure, app state, or configuration metadata without exposing terminal content, file content, Warp Drive object content, AI conversation content, or other user data. - Instance discovery and health: `instance list`, `app active`, `app version`, `app ping`. - Layout enumeration: `window list`, `tab list`, `pane list`, `session list`. - - Metadata reads: `theme list`, `setting list`, `keybinding list`, `action list`, `project active`, and Drive object listing that returns object IDs/names/types but not content. + - Metadata reads: `theme list`, `setting list`, `keybinding list`, `action list`, and Drive object listing that returns object IDs/names/types but not content. 2. **Read-only / underlying data.** Actions that return user content or data-bearing state without changing it. - Terminal reads: block output, scrollback, command history, input editor contents, session replay, or terminal-derived traces. - Warp Drive object content reads, AI conversation reads, and any authenticated-user data read. @@ -419,7 +415,7 @@ Every action in the catalog belongs to exactly one of the following permission c 3. **Mutating / app state.** Actions that change visible local Warp UI state without directly changing underlying user data. - Layout and focus: `window create`, `window focus`, `tab create`, `tab activate`, `tab move`, `window close`, `tab close`, `pane split`, `pane focus`, `pane navigate`, `pane maximize`, `pane resize`, and panel/surface toggles. - Input-buffer staging: `input insert`, `input replace`, and `input clear` as long as they do not submit or execute the buffer. - - Opening views: opening settings, command palette, command search, Warp Drive, code review, files, projects, notebooks, and env-var collections. + - Opening views: opening settings, command palette, command search, Warp Drive, code review, files, notebooks, and env-var collections. 4. **Mutating / metadata or configuration.** Actions that change persistent metadata or configuration but do not directly mutate primary user data. - Tab and pane names, tab colors, themes, system-theme settings, font size, zoom, allowlisted app settings, and keybindings. Metadata/configuration writes need a stronger permission than app-state-only changes because they persist beyond the current UI interaction, but they are still distinct from data writes. @@ -446,25 +442,18 @@ The CLI should expose auth/status flows for both modes: - Raw Firebase, server, OAuth, cloud API tokens, and raw scripting API keys are never exported to `warpctrl` output, shell scripts, generated docs, logs, discovery records, or JSON responses. This authenticated scripting protocol applies only to actions whose allowlist entry requires a true logged-in Warp user, external API-key identity, or underlying-data-mutation authority. Logged-out-safe local actions continue to use local-control credentials without requiring Warp account login or API-key setup. ### Execution context policy -`warpctrl` should eventually distinguish verified invocations from inside Warp-managed terminal sessions from external invocations. The current foundation branch supports external invocation only and must reject verified Warp-terminal claims until the proof broker is implemented. -- **Verified Warp-terminal invocation:** a `warpctrl` process started inside a Warp-managed terminal session and able to present an app-issued execution-context proof. The top-level setting for this context should default to on. When the selected app has a logged-in Warp user, this context can receive authenticated-user grants if the user's Scripting permissions allow that grant. -- **External invocation:** a `warpctrl` process started outside Warp's terminal, such as from another terminal app, launch agent, IDE, or background script. The top-level setting for this context must default to off. When disabled, external invocations receive no local-control credentials, including logged-out-safe metadata credentials. +`warpctrl` should eventually distinguish verified invocations from inside Warp-managed terminal sessions from external invocations. The current foundation branch implements the setting shape for both contexts, supports external invocation only when the user explicitly enables the broadest mode, and must reject verified Warp-terminal claims until the proof broker is implemented. +- **Verified Warp-terminal invocation:** a `warpctrl` process started inside a Warp-managed terminal session and able to present an app-issued execution-context proof. This is allowed by the default **Enabled within Warp** mode once the proof broker exists. When the selected app has a logged-in Warp user, this context can receive authenticated-user grants if the selected mode allows the context and the action's catalog policy allows that grant. +- **External invocation:** a `warpctrl` process started outside Warp's terminal, such as from another terminal app, launch agent, IDE, or background script. This is allowed only by **Enabled everywhere, including outside Warp**. When disabled for the selected mode, external invocations receive no local-control credentials, including logged-out-safe metadata credentials. - The app must not trust a caller-declared label. Environment variables may help discover the context, but the broker must verify a session-bound capability or equivalent proof before issuing in-Warp-only grants. ### Settings surface -Warp should add a new top-level Settings pane page named **Scripting**. This page should own settings for local scripting and automation surfaces, including Warp control. The current foundation branch should expose only outside-Warp Warp control settings. In the long-term model, once verified Warp-terminal invocation is implemented, Warp control should include two top-level toggles: -- **Allow Warp control from inside Warp:** default on. Controls `warpctrl` invocations from verified Warp-managed terminal sessions. -- **Allow Warp control from outside Warp:** default off. Controls `warpctrl` invocations from external terminals, scripts, IDEs, launch agents, and other same-user processes. -The Scripting page should explain that inside-Warp control is scoped to commands launched from Warp-managed terminals, while outside-Warp control allows other local apps and scripts to talk to Warp's control plane. Disabling either top-level toggle should invalidate credentials for that invocation context. -### Granular local-control permissions -In the long-term model, the Scripting settings page should expose granular permissions beneath the inside-Warp and outside-Warp toggles. The current foundation branch exposes only the outside-Warp subset. Recommended controls: -- Allow metadata reads. -- Allow underlying data reads. -- Allow app-state mutations. -- Allow metadata/configuration mutations. -- Allow underlying data mutations. -- Allow authenticated-user actions from verified Warp terminals. -- Allow authenticated-user actions from external clients, default off and separate from the in-Warp permission. -These settings define the maximum grants the broker may issue. The app bridge still enforces the action's risk posture, state/data category, authenticated-user requirement, execution-context requirement, and target scope for every request. Enabling app-state mutation must not imply permission to mutate underlying data. +Warp should add a new top-level Settings pane page named **Scripting**. This page should own settings for local scripting and automation surfaces, including Warp control. Warp control should be represented as a single private, local-only mode setting with three choices: +- **Disabled:** no local-control invocation context can receive credentials. +- **Enabled within Warp:** default. Allows only verified Warp-managed terminal invocations once the proof broker exists. In the current foundation branch, inside-Warp proof verification is not implemented yet, so requests in this mode are rejected rather than silently treated as external. +- **Enabled everywhere, including outside Warp:** allows verified Warp-managed terminal invocations and external local clients such as other terminals, scripts, IDEs, launch agents, and same-user automation to request local-control credentials. +The Scripting page should explain that the default mode scopes control to Warp-managed terminals, while the broadest mode allows other local apps and scripts to talk to Warp's control plane. Changing the mode should invalidate or prevent credentials for invocation contexts no longer allowed by the selected mode. +### Local-control permission policy +The Scripting settings page should not expose separate per-risk local-control toggles in the foundation stack. The single mode setting defines which invocation contexts may receive credentials. The app bridge still enforces each action's risk posture, state/data category, authenticated-user requirement, execution-context requirement, and target scope for every request. Enabling the broadest mode must not bypass catalog enforcement or imply permission to run actions that require authenticated scripting identity, logged-in user state, or future review. ### Agent Profile permissions Agent Profiles should expose a dedicated **Warp control** permission group for agents that can invoke `warpctrl`. This permission group should mirror the local-control action categories so users can choose different `warpctrl` authority for different agent workflows: - Metadata reads. @@ -488,13 +477,12 @@ Scoped credentials should include: - revocation/audit identity. The bridge, not the CLI frontend, enforces these grants. If a request exceeds its credential, the bridge returns `insufficient_permissions`, `authenticated_user_required`, `authenticated_user_unavailable`, or `execution_context_not_allowed` as appropriate. ### Future entity extensibility: files, blocks, and Warp Drive objects -The selector and action model should accommodate entity types beyond the current window/tab/pane/session hierarchy. Important entity families are **terminal blocks**, **file/path intents**, **projects/workspaces**, and **Warp Drive objects**. Broad Drive mutation and command execution are not in scope for the foundation branch, but they are in scope for later authenticated branches in the expanded stack. Local file content reads, writes, appends, deletes, and other filesystem-content mutations are intentionally out of scope for the public `warpctrl` catalog because native agent file tools are the preferred surface for file content operations. Agent-prompt submission remains excluded until separately reviewed. +The selector and action model should accommodate entity types beyond the current window/tab/pane/session hierarchy. Important entity families are **terminal blocks**, **file/path intents**, and **Warp Drive objects**. Broad Drive mutation and command execution are not in scope for the foundation branch, but they are in scope for later authenticated branches in the expanded stack. Local file content reads, writes, appends, deletes, and other filesystem-content mutations are intentionally out of scope for the public `warpctrl` catalog because native agent file tools are the preferred surface for file content operations. Agent-prompt submission remains excluded until separately reviewed. **Terminal blocks.** Blocks are first-class targetable terminal entities, not just data hanging off a session. Block selectors should support the same addressing primitives as terminal sessions where meaningful: active/current block, opaque block ID, and block index scoped to the resolved session. Block reads can expose command text, output, status, timing, exit code, and metadata, so block content reads are underlying-data reads while block listing that returns only IDs/status/timestamps may be metadata reads. Stale, missing, or ambiguous block selectors must fail rather than selecting a neighboring block. **Files.** Warp already supports file opening via deep links and the built-in editor. The `file` namespace is limited to app-state and metadata behaviors that operate Warp's visible UI: - `warpctrl file open <path>` — app-state mutation that opens a file in a Warp editor tab, equivalent to clicking a file link. - `warpctrl file open <path> --line <n>` — app-state mutation that opens at a specific line. - `warpctrl file list` — metadata read that lists files currently open in editor tabs across the instance. -- `warpctrl project open <path>` — app-state mutation that opens or focuses a project/workspace in Warp where that matches existing user-visible behavior. File selectors use filesystem paths (absolute or relative to the working directory of the target pane/session when the command defines that behavior). Unlike window/tab/pane selectors, file selectors are not opaque IDs — they are user-visible paths. The protocol should support a `file` field in the target selector that accepts a path string, distinct from the opaque ID selectors used for windows, tabs, and panes. `warpctrl` must not expose file content reads or filesystem-content mutations; agents and scripts should use native file tools for those operations. **Warp Drive objects.** Warp Drive stores typed objects that users can reference, execute, edit, and share. The object taxonomy should include, at minimum, spaces, folders, notebooks, workflows, agent-mode workflows/prompts, environment variable collections, AI facts/rules, MCP servers, MCP server collections, and trash entries where trash operations are exposed. A future `drive` namespace could support: - `warpctrl drive list --type workflow` — authenticated metadata read that lists Warp Drive objects by type. diff --git a/specs/warp-control-cli/SECURITY.md b/specs/warp-control-cli/SECURITY.md index 8922516064..79d19936c9 100644 --- a/specs/warp-control-cli/SECURITY.md +++ b/specs/warp-control-cli/SECURITY.md @@ -4,7 +4,7 @@ The correct architecture is not a single shared localhost bearer token with clie The action-category model is primarily a safety and intent mechanism, not a hard security boundary against malicious same-user software. It lets a user, script, or agent intentionally request metadata-only, data-read, app-state mutation, metadata/configuration mutation, or underlying-data mutation access so it does not accidentally mutate state, expose sensitive content, or execute commands. It should not be described as strong access control against a process that can already run arbitrary commands as the user. `warpctrl` has two distinct authorization dimensions: local-control authority and authenticated scripting authority. Local-control authority proves the request is allowed to control the local app. Authenticated scripting authority proves the Warp user or automation identity that is allowed to act on user-authenticated data such as Warp Drive objects, AI conversation traces, synced settings, cloud-backed user state, and execution-underlying actions. Logged-out users should retain a smaller local-only control surface, but authenticated-user and high-risk underlying-data mutation actions require either a verified Warp-terminal grant tied to the selected app's logged-in user or an external Warp-issued API-key grant. ## Current foundation status -The current foundation implementation supports outside-Warp local-control requests only. Verified inside-Warp invocation is specified as future work because the app-issued terminal-session proof broker, proof injection path, and session registry do not exist yet. Until those pieces land, `InvocationContext::InsideWarp` requests must be rejected with `execution_context_not_allowed`, implemented action metadata must not advertise inside-Warp support, and Settings > Scripting must not expose inside-Warp enablement or permission toggles. +The current foundation implementation stores a single local-control mode with three choices: disabled, enabled within Warp by default, and enabled everywhere including outside Warp. Verified inside-Warp invocation is specified as future work because the app-issued terminal-session proof broker, proof injection path, and session registry do not exist yet. Until those pieces land, `InvocationContext::InsideWarp` requests must be rejected with `execution_context_not_allowed`, implemented action metadata must not advertise inside-Warp support, and external requests must receive credentials only when the user selects the broadest mode. ## Security goals - Allow trusted local users and approved automation to control a running Warp instance through a stable, scriptable interface. - Prevent unauthenticated localhost clients from invoking read or mutating control actions. @@ -12,12 +12,12 @@ The current foundation implementation supports outside-Warp local-control reques - Support multiple running Warp processes without a shared global mutating port or global credential. - Separate discovery metadata from control authority so enumerating an instance does not automatically grant full control. - Require explicit in-app user enablement before local control scripting from outside Warp can issue credentials or accept control requests. -- Allow local control scripting from verified Warp-managed terminal sessions by default, subject to granular permission settings. -- Store the authoritative enablement states in protected local storage so external apps cannot enable outside-Warp control by editing ordinary settings. +- Allow local control scripting from verified Warp-managed terminal sessions by default once proof verification exists, subject to the selected local-control mode and action policy. +- Store the authoritative local-control mode in protected local storage so external apps cannot enable outside-Warp control by editing ordinary settings. - Keep raw credential material out of plaintext discovery records and protect it with platform secure storage where available. - Distinguish verified `warpctrl` invocations that originate from a Warp-managed terminal session from external same-user invocations. -- When outside-Warp control is enabled, allow external invocations only for a smaller local-only action set by default that does not touch user-authenticated data. -- Allow in-Warp invocations to receive authenticated-user grants when the selected Warp app has a true logged-in user and the user's local-control settings permit that grant. +- When the broadest mode enables outside-Warp control, allow external invocations only for the action set explicitly allowed by the action catalog and granted credential. +- Allow in-Warp invocations to receive authenticated-user grants when the selected Warp app has a true logged-in user and the user's local-control mode and action policy permit that grant. - Support least-privilege safety modes for automation and interactive use without relying on an unenforceable identity label. - Classify every action by state/data category and enforce the required permission category in the local app bridge, not in the CLI frontend. - Classify every action by whether it requires an authenticated Warp user. New actions should default to requiring an authenticated user unless they are deliberately reviewed as safe for logged-out or external use. @@ -90,40 +90,37 @@ Compared with these systems, `warpctrl` should combine: - VS Code's preference for typed public commands and separate treatment of remote control. The resulting architecture should not claim to solve arbitrary same-user malicious-app isolation. Its real value is preventing web-origin and other-user access, preventing unauthenticated direct localhost calls, keeping raw credentials out of plaintext discovery, giving honest scripts and agents narrow safety grants, and routing high-risk operations through local Warp app validation and user/policy approval. ## Authoritative enablement model -This section describes the long-term model. The current foundation branch implements only the outside-Warp half of this model and rejects inside-Warp requests until app-issued Warp-terminal proofs are implemented. -Warp control has two top-level enablement states based on invocation context: -- **Allow scripting from inside Warp:** controls `warpctrl` invocations from verified Warp-managed terminal sessions. This should default to on so commands run inside Warp can use local control subject to granular permissions. -- **Allow scripting from outside Warp:** controls `warpctrl` invocations from external terminals, scripts, launch agents, IDEs, or other same-user processes. This must default to off. -Both controls should live in a new top-level Settings pane page named **Scripting**. The Scripting page owns the user-facing controls for local scripting surfaces, including Warp control, and should explain the difference between commands run inside Warp and commands run from other apps. -The visible UI settings are not enough by themselves. The authoritative enablement states must be stored in protected local storage that ordinary same-user apps cannot update by writing a plist, settings database row, registry key, JSON file, or synced cloud preference. This avoids turning outside-Warp control into a feature that any process can silently enable before invoking `warpctrl`. -Current foundation implementation note: outside-Warp enablement and granular permission bits are represented in the typed `LocalControlSettings` group as private, local-only settings. Each implemented setting must use `private: true`, `SyncToCloud::Never`, an explicit private storage key, and no `toml_path`, so it is excluded from `settings.toml`, the generated settings schema, Settings Sync, Warp Drive, and user-editable or server-backed settings surfaces. This private-settings path is an interim storage boundary, not the final protected-storage requirement; before public shipment, these authoritative bits must move to platform protected storage where available. +Warp control has one top-level mode setting based on invocation context: +- **Disabled:** no local-control invocation context can receive credentials. +- **Enabled within Warp:** default. Controls `warpctrl` invocations from verified Warp-managed terminal sessions once proof verification exists. +- **Enabled everywhere, including outside Warp:** controls verified Warp-managed terminal invocations and external terminals, scripts, launch agents, IDEs, or other same-user processes. +The mode should live in a new top-level Settings pane page named **Scripting**. The Scripting page owns the user-facing controls for local scripting surfaces, including Warp control, and should explain the difference between commands run inside Warp and commands run from other apps. +The visible UI setting is not enough by itself. The authoritative mode must be stored in protected local storage that ordinary same-user apps cannot update by writing a plist, settings database row, registry key, JSON file, or synced cloud preference. This avoids turning outside-Warp control into a feature that any process can silently enable before invoking `warpctrl`. +Current foundation implementation note: the mode is represented in the typed `LocalControlSettings` group as a private, local-only setting. The implemented setting must use `private: true`, `SyncToCloud::Never`, an explicit private storage key, and no `toml_path`, so it is excluded from `settings.toml`, the generated settings schema, Settings Sync, Warp Drive, and user-editable or server-backed settings surfaces. This private-settings path is an interim storage boundary, not the final protected-storage requirement; before public shipment, this authoritative mode must move to platform protected storage where available. Enablement requirements: -- The settings are local-only and must not sync through Settings Sync, Warp Drive, or server-backed user preferences. -- The implemented foundation settings must remain private and absent from user-visible settings files, generated schemas, local-control settings read/write commands, and any allowlisted settings mutation catalog. -- Only the running Warp app, through the Settings > Scripting UI, should be able to enable or disable the authoritative states. -- `warpctrl`, shell scripts, config files, command-line flags, registry edits, defaults writes, and direct local-control protocol requests must not be able to enable either setting. -- The in-Warp setting may default to enabled, but turning it off should prevent verified Warp-terminal invocations from receiving local-control grants. -- The outside-Warp setting defaults to disabled and should require an intentional user gesture before enabling; the UI should explain that it allows scripts and automation from other apps to control Warp. -- The Scripting page should expose granular local-control permission settings for implemented invocation contexts rather than a single all-powerful switch. -- Each setting should be easy to disable from the same UI, and disabling either setting should revoke or invalidate active local-control credentials for that invocation context. -- If enterprise or managed-device policy is added later, policy may force-disable either setting or allow an administrator-controlled default, but policy should be separate from user-editable local settings. +- The mode is local-only and must not sync through Settings Sync, Warp Drive, or server-backed user preferences. +- The implemented foundation setting must remain private and absent from user-visible settings files, generated schemas, local-control settings read/write commands, and any allowlisted settings mutation catalog. +- Only the running Warp app, through the Settings > Scripting UI, should be able to change the authoritative mode. +- `warpctrl`, shell scripts, config files, command-line flags, registry edits, defaults writes, and direct local-control protocol requests must not be able to enable or widen the mode. +- The default mode may allow verified Warp-terminal invocations, but turning the mode to disabled should prevent verified Warp-terminal invocations from receiving local-control grants. +- Outside-Warp control requires an intentional user gesture to select the broadest mode; the UI should explain that it allows scripts and automation from other apps to control Warp. +- The mode should be easy to change from the same UI, and narrowing the mode should revoke or invalidate active local-control credentials for invocation contexts no longer allowed. +- If enterprise or managed-device policy is added later, policy may force-disable the mode or force a narrower default, but policy should be separate from user-editable local settings. Disabled-state behavior: - Warp should not mint scoped local-control credentials for a request whose invocation context is disabled. - The control listener should reject requests from disabled contexts with a structured disabled-state error before authentication, selector resolution, or handler dispatch. - Discovery records should avoid publishing actionable endpoint or credential-reference metadata for disabled outside-Warp control. If a minimal record is needed for UX, it should expose only non-sensitive status such as `outside_warp_control_enabled: false`. - `warpctrl` may detect a disabled context and print instructions to enable it in Settings > Scripting, but it must not offer a command that flips the setting. -- Previously issued credentials must become unusable when their invocation context is disabled, even if their original expiry has not elapsed. +- Previously issued credentials must become unusable when their invocation context is no longer allowed, even if their original expiry has not elapsed. These enablement gates do not create perfect same-user malicious-app isolation. A hostile process with Accessibility or Screen Recording permission might still try to automate the Warp UI. The outside-Warp gate is still important because it closes the much easier paths where external apps silently edit local preferences, call a config CLI, or write synced settings to enable a powerful control surface. -### Granular permission settings -Once the relevant inside-Warp or outside-Warp enablement setting allows a request context, users should control which categories of `warpctrl` authority can be granted. These permissions should appear under Settings > Scripting. Recommended independent permissions: -- **Metadata reads:** permit external and in-Warp clients to inspect non-sensitive local app structure and configuration metadata such as instances, windows, tabs, panes, app version, theme names, setting keys, action metadata, and Drive object IDs/names/types without content. -- **Underlying data reads:** permit reads of terminal output, scrollback, input buffers, command history, session traces, Warp Drive object contents, AI conversation content, and other content-bearing state. -- **App-state mutations:** permit local UI/layout/focus changes such as opening windows, creating tabs, closing tabs, focusing panes, splitting panes, opening panels, opening files/projects/views, and staging text in the input buffer without executing it. -- **Metadata/configuration mutations:** permit persistent metadata or configuration changes such as tab/pane names, tab colors, themes, font size, zoom, allowlisted settings, and keybindings. -- **Underlying data mutations:** permit Warp Drive object CRUD and personal-to-team sharing, AI conversation mutations, and any other allowlisted action that can change user data or cause external side effects. Terminal command execution and Warp Drive workflow execution belong in this category when their later authenticated branches implement them. Accepted-command submission and agent-prompt submission remain unavailable until separately reviewed. Local file content operations are intentionally excluded from the public `warpctrl` catalog and should use native file tools instead. -- **Authenticated-user actions from Warp terminals:** permit `warpctrl` invocations that originate from a verified Warp-managed terminal session to receive grants backed by the currently logged-in Warp user, enabling actions that read or mutate Warp Drive, AI conversation traces, synced settings, or other user-authenticated state. -- **Authenticated-user actions from external clients:** default off. If supported, this must be a separate explicit permission from in-Warp authenticated actions because external same-user processes are a weaker context than a Warp-managed terminal session. -Granular permissions should be independently configurable for inside-Warp and outside-Warp contexts where the distinction matters. Disabling any category should invalidate active credentials that include that category. The broker and app bridge must enforce these settings locally for every credential request and every presented credential. App-state mutation permission must not imply metadata/configuration mutation or underlying data mutation permission. +### Permission categories and grants +The foundation stack should not expose separate per-risk toggles under Settings > Scripting. Once the selected mode allows a request context, the broker and app bridge still enforce each action's catalog classification and the credential's grants: +- **Metadata reads:** inspect non-sensitive local app structure and configuration metadata such as instances, windows, tabs, panes, app version, theme names, setting keys, action metadata, and Drive object IDs/names/types without content. +- **Underlying data reads:** read terminal output, scrollback, input buffers, command history, session traces, Warp Drive object contents, AI conversation content, and other content-bearing state. +- **App-state mutations:** change local UI/layout/focus such as opening windows, creating tabs, closing tabs, focusing panes, splitting panes, opening panels, opening files/views, and staging text in the input buffer without executing it. +- **Metadata/configuration mutations:** change persistent metadata or configuration such as tab/pane names, tab colors, themes, font size, zoom, allowlisted settings, and keybindings. +- **Underlying data mutations:** mutate Warp Drive objects, share personal objects to a team, mutate AI conversation data, run terminal commands, run typed workflows, or perform any other allowlisted action that can change user data or cause external side effects. +The single mode setting is an invocation-context gate, not a replacement for action classification. App-state mutation permission must not imply metadata/configuration mutation or underlying data mutation permission. Authenticated-user actions remain separately gated by verified Warp-terminal or external API-key identity and by the selected app's logged-in user state where required. ## Trust boundaries `warpctrl` has several distinct trust boundaries. ### Operating-system user boundary @@ -134,12 +131,12 @@ These scoped credentials are guardrails for well-behaved clients. They prevent a ### Warp-terminal execution context boundary `warpctrl` should be able to receive special grants when it is invoked from within a Warp-managed terminal session, but the bridge must not trust a caller-supplied string such as `caller_class=warp_terminal`. The app should issue a session-bound execution-context proof to Warp-managed terminal sessions and have the broker verify that proof before minting in-Warp-only grants. Acceptable designs include a short-lived per-session capability, an app-owned broker handshake tied to the terminal session, or an equivalent proof that arbitrary external processes cannot mint by setting an environment variable. Plain environment variables may be used as handles or hints, but they must not be the sole authority for in-Warp privileges because external processes can spoof them. -Verified in-Warp context can raise the maximum eligible grant set, especially for authenticated-user actions. It does not by itself bypass the user's granular local-control settings, action categories, target scopes, or logged-in-user requirements. +Verified in-Warp context can raise the maximum eligible grant set, especially for authenticated-user actions. It does not by itself bypass the selected local-control mode, action categories, target scopes, or logged-in-user requirements. ### Authenticated scripting boundary Actions that touch user-authenticated Warp data or perform high-risk underlying-data mutations require authenticated scripting authority. This includes Warp Drive object contents, object mutation, the v0 personal-to-team sharing path, AI conversation traces, cloud-backed user settings, team/account data, typed workflow execution, terminal command execution, and any other surface whose normal app access depends on the user's Warp account or can cause external side effects. There are two supported authenticated scripting modes: -- **Verified Warp-terminal mode:** `warpctrl` presents an app-issued terminal-session proof. If the selected app is logged into Warp and Settings > Scripting permits authenticated actions from verified Warp terminals, the broker may mint an authenticated-user grant tied to the selected app's current user subject. -- **External API-key mode:** `warpctrl` presents a Warp-issued scripting API key or a short-lived token exchanged from that key. If outside-Warp scripting and external authenticated grants are enabled, the broker verifies the key, scopes, expiry, revocation state, and user subject before minting a local authenticated-user grant. +- **Verified Warp-terminal mode:** `warpctrl` presents an app-issued terminal-session proof. If the selected app is logged into Warp and Settings > Scripting mode plus action policy permit authenticated actions from verified Warp terminals, the broker may mint an authenticated-user grant tied to the selected app's current user subject. +- **External API-key mode:** `warpctrl` presents a Warp-issued scripting API key or a short-lived token exchanged from that key. If the broadest mode and external authenticated grants are enabled, the broker verifies the key, scopes, expiry, revocation state, and user subject before minting a local authenticated-user grant. For app-backed authenticated actions, the app bridge should execute on behalf of the selected app's logged-in user through existing app auth state. For explicitly API-key-backed actions, the API key subject and scopes must be recorded in the local grant and the handler must not export raw Firebase, server, OAuth, or cloud API tokens to shell scripts. If the selected app logs out, switches users, or no longer matches a grant that requires app-user identity, authenticated actions fail with structured errors rather than falling back to logged-out behavior. Logged-out users may still use the smaller local-only action set explicitly marked as not requiring authenticated scripting authority. ### Authenticated scripting protocol @@ -150,14 +147,14 @@ Requirements: - `warpctrl auth api-key set --key-env <env_var>|--key-stdin [selectors]` stores or references a Warp-issued scripting API key in platform secure storage. Non-interactive scripts may provide the key through a secret-manager-injected environment variable. - `warpctrl auth api-key status [selectors]` reports non-secret subject, expiry, and scope metadata for the configured API key. - `warpctrl auth api-key revoke [selectors]` removes the local key reference and revokes the server-side key where supported. -- The credential broker may mint an app-user authenticated grant only after confirming the selected app has a true logged-in Warp user and the requested authenticated-user setting is enabled for the verified invocation context. -- The credential broker may mint an external API-key grant only after validating the key or exchanging it for a short-lived assertion, confirming that external authenticated grants are enabled, and checking that the key scope covers the requested action family and permission category. +- The credential broker may mint an app-user authenticated grant only after confirming the selected app has a true logged-in Warp user and the selected mode plus action policy allow the verified invocation context. +- The credential broker may mint an external API-key grant only after validating the key or exchanging it for a short-lived assertion, confirming that the broadest mode and external authenticated grants are enabled, and checking that the key scope covers the requested action family and permission category. - Authenticated credentials are bound to the selected instance, subject, grant mode, scopes, expiry, and optional target/resource restrictions. If the app logs out, switches users, loses authenticated state, or the presented credential subject no longer matches a grant that requires app-user identity, authenticated actions fail with `authenticated_user_mismatch` or another structured authenticated-user error. - Raw Firebase, server, OAuth, cloud API tokens, and raw API keys must not be exported to `warpctrl` output, shell completions, generated docs, logs, discovery records, or local-control JSON responses. Logged-out-safe actions continue to use local-control credentials without requiring authenticated scripting identity. ### Application identity boundary On platforms with secure credential storage, especially macOS, the raw local-control credential should be readable only by Warp-owned, correctly signed code. On macOS this means storing raw credential material in Keychain with access constrained by Warp's signing identity, designated requirement, Keychain access group, or equivalent platform mechanism. This narrows token extraction from “any same-user process can read a file” to “only trusted Warp-signed code can unwrap the secret.” -This boundary protects the credential from direct theft and prevents arbitrary apps from making authenticated raw HTTP requests to the local-control listener. It also lets the authoritative enablement state be stored somewhere harder to modify than ordinary user preferences. It does not prove that the user personally intended the specific action. Any same-user process may still be able to invoke the trusted `warpctrl` binary or automate the Warp UI. That confused-deputy risk is reduced by explicit in-app enablement, scoped credential issuance, action-category policy, and local app-side bridge enforcement, but it is not eliminated as a hard same-user security boundary. +This boundary protects the credential from direct theft and prevents arbitrary apps from making authenticated raw HTTP requests to the local-control listener. It also lets the authoritative mode be stored somewhere harder to modify than ordinary user preferences. It does not prove that the user personally intended the specific action. Any same-user process may still be able to invoke the trusted `warpctrl` binary or automate the Warp UI. That confused-deputy risk is reduced by explicit in-app enablement, scoped credential issuance, action-category policy, and local app-side bridge enforcement, but it is not eliminated as a hard same-user security boundary. ### Action boundary Every action belongs to a state/data category. The bridge must map the requested action to a required permission category and compare that category to the presented credential before selector resolution or handler dispatch. ### Target boundary @@ -182,13 +179,13 @@ A valid credential for one instance or target must not imply authority over anot - Kernel, hypervisor, or administrator-level compromise. - Security semantics for remote URL control endpoints. Remote control requires a separate transport and identity design before it can ship. ## Architecture overview -The full security model has eight layers. The current foundation branch implements the outside-Warp path and keeps the inside-Warp execution-context layer as a rejected future protocol concept until proof verification exists. +The full security model has eight layers. The current foundation branch implements the single mode gate, allows outside-Warp credentials only in the broadest mode, and keeps the inside-Warp execution-context layer as a rejected future protocol concept until proof verification exists. The security model has eight layers: -1. **Protected enablement:** Use protected local storage for separate inside-Warp and outside-Warp enablement states, with inside-Warp on by default and outside-Warp off by default. +1. **Protected enablement:** Use protected local storage for the single local-control mode, with inside-Warp allowed by default and outside-Warp off unless the broadest mode is selected. 2. **Discovery:** Find compatible live Warp instances without granting broad authority. 3. **Secure credential storage:** Store raw secrets outside plaintext discovery records and restrict access to trusted Warp-owned code where the platform supports it. 4. **Execution context verification:** Distinguish verified Warp-terminal invocations from external same-user invocations without trusting caller-declared labels. -5. **Credential issuance:** Issue scope-specific credentials with explicit grants and lifetimes only when the request's invocation context is enabled and the user's granular permissions allow the requested category. +5. **Credential issuance:** Issue scope-specific credentials with explicit grants and lifetimes only when the selected mode allows the request's invocation context and the requested action/category is allowed. 6. **Transport authentication:** Reject disabled or unauthenticated requests before reading or mutating app state. 7. **Safety and user-auth policy:** Enforce permission categories, target scopes, execution-context requirements, and authenticated-user requirements locally in the app bridge. 8. **Deterministic dispatch:** Resolve targets exactly and invoke only allowlisted typed handlers. @@ -275,7 +272,7 @@ Recommended defaults: - Credential issuance is unavailable unless the protected enablement state allows the request's invocation context: inside Warp or outside Warp. - Commands should start from least privilege and request only the grant needed for the requested action. - External same-user invocations should default to the smaller logged-out-safe local action set unless policy or explicit approval grants more. -- Verified Warp-terminal invocations may receive broader local-control grants when the user's granular settings allow them. +- Verified Warp-terminal invocations may receive broader local-control grants when the selected mode and action policy allow them. - App-user authenticated grants are available only when the selected Warp app has a true logged-in Warp user and the requested execution context is allowed by local-control settings. External API-key authenticated grants are available only after key validation/exchange and only when external authenticated scripting is enabled. - Metadata reads require an explicit `read_metadata` grant. - Underlying data reads require an explicit `read_underlying_data` grant. @@ -296,7 +293,7 @@ This model does not make untrusted same-user software safe. A malicious local pr ### Credential storage Credential storage should be platform-appropriate: - Local discovery may store a credential reference rather than the credential itself. -- The authoritative local-control enablement states for inside-Warp and outside-Warp scripting should use the same class of protected local storage as raw credential material, but they should be accessible to the Warp app for the Settings > Scripting UI and not writable by `warpctrl` or arbitrary external apps. +- The authoritative local-control mode should use the same class of protected local storage as raw credential material, but it should be accessible to the Warp app for the Settings > Scripting UI and not writable by `warpctrl` or arbitrary external apps. - Raw long-lived credentials should prefer platform-secure storage such as macOS Keychain or Windows Credential Manager when practical. - On macOS, raw control secrets should be stored in Keychain and restricted to trusted Warp-signed code using a designated requirement, Keychain access group, trusted-application ACL, or equivalent code-signing based mechanism. Restricting by filesystem path alone is insufficient because paths can be replaced or wrapped. - Keychain item access should include the Warp app, the signed `warpctrl` binary, and any signed Warp-owned local broker/helper that needs to unwrap raw secrets. It should exclude arbitrary same-user applications. @@ -337,7 +334,7 @@ When an authenticated-user or authenticated-scripting action is requested: - app-user mode requires the selected app to have an active logged-in Warp user; - API-key mode requires a validated key or exchanged assertion with sufficient scopes, subject, expiry, and revocation state; - the presented local-control credential must include an authenticated grant for that user or API-key-backed subject; -- the user's granular settings must allow authenticated actions for the verified execution context or external API-key mode; +- the selected mode, action policy, and authenticated-scripting policy must allow authenticated actions for the verified execution context or external API-key mode; - the app bridge should execute app-user actions through the app's existing authenticated state rather than exporting raw cloud auth credentials to `warpctrl`. If these conditions are not met, the app returns a structured error. It must not fall back to logged-out behavior or silently omit user-authenticated data from a result that claims success. ## Safety policy model @@ -375,7 +372,7 @@ Change visible local Warp UI state without directly changing underlying user dat Examples: - creating, focusing, activating, moving, or closing windows, tabs, panes, or sessions; - splitting, navigating, maximizing, or resizing panes; -- opening panels, palettes, files, projects, notebooks, and other user-facing surfaces; +- opening panels, palettes, files, notebooks, and other user-facing surfaces; - inserting, replacing, or clearing staged input buffer text without submitting or executing it. ### Metadata/configuration mutations Change persistent metadata or configuration without directly mutating primary user content. @@ -469,8 +466,8 @@ Important errors include: The app must not downgrade these failures into broader default actions, and the CLI must preserve structured server errors in both human-readable and JSON output. ## Required controls before full catalog expansion Before shipping each action family, verify that these controls are implemented for that family: -- Local control scripting must be enabled for the request's invocation context before the action family can run; inside-Warp control defaults on and outside-Warp control defaults off. -- The authoritative enablement states live under Settings > Scripting, are protected from external writes, and are local-only rather than synced. +- Local control scripting must be enabled for the request's invocation context before the action family can run; the default mode allows inside-Warp only once proof verification exists, and outside-Warp control requires the broadest mode. +- The authoritative mode lives under Settings > Scripting, is protected from external writes, and is local-only rather than synced. - The action has a documented state/data category and required permission category. - The action has a documented `requires_authenticated_user` value. New actions default to `true` unless explicitly reviewed as logged-out-safe. - The action documents allowed execution contexts and whether external clients may run it. @@ -489,13 +486,13 @@ Before shipping each action family, verify that these controls are implemented f ## Platform requirements ### macOS and Linux Discovery files must be stored in a per-user directory with owner-only permissions. -On macOS, raw credential material and the authoritative local-control enablement states should live in Keychain, not in the discovery record or an ordinary preferences file. Keychain access should be constrained to Warp-owned signed binaries or helpers using code-signing based access control. The enablement states should be writable by the Warp app's Settings > Scripting flow and not writable by `warpctrl`. The discovery record should hold only metadata and a credential reference when the relevant inside-Warp or outside-Warp context is enabled. -On Linux, raw credentials and the authoritative enablement states should prefer platform-secure storage where available; otherwise short-lived scoped credentials may live in owner-only local state with strict file and directory permissions. If an enablement state falls back to owner-only local state, the weaker same-user protection should be documented. +On macOS, raw credential material and the authoritative local-control mode should live in Keychain, not in the discovery record or an ordinary preferences file. Keychain access should be constrained to Warp-owned signed binaries or helpers using code-signing based access control. The mode should be writable by the Warp app's Settings > Scripting flow and not writable by `warpctrl`. The discovery record should hold only metadata and a credential reference when the selected mode allows the relevant invocation context. +On Linux, raw credentials and the authoritative mode should prefer platform-secure storage where available; otherwise short-lived scoped credentials may live in owner-only local state with strict file and directory permissions. If the mode falls back to owner-only local state, the weaker same-user protection should be documented. Unix domain sockets with peer credential checks may be considered for stronger same-machine identity than bearer tokens alone. ### Windows Discovery records and credential material must live under the current user's app data directory with ACLs restricted to the current user, Administrators, and SYSTEM. -The authoritative enablement states should use Credential Manager, DPAPI-backed protected storage, or an equivalent app-controlled protected store rather than normal registry settings that arbitrary same-user processes can write. -Windows support for authenticated local control should not be considered complete until the implementation creates, validates, and tests those ACLs and the protected enablement-state behavior for both inside-Warp and outside-Warp settings. +The authoritative mode should use Credential Manager, DPAPI-backed protected storage, or an equivalent app-controlled protected store rather than normal registry settings that arbitrary same-user processes can write. +Windows support for authenticated local control should not be considered complete until the implementation creates, validates, and tests those ACLs and protected mode behavior. ## Remote control is separate The local architecture intentionally assumes same-machine, same-user control over a loopback listener. Future remote URLs must use a different security design that includes: - transport encryption; diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index 5fa4e459dc..87374bf6f6 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -1,6 +1,6 @@ # Context `PRODUCT.md` defines a standalone local Warp control CLI binary, provisionally named `warpctrl`, with an allowlisted action catalog, deterministic addressing across multiple running Warp app processes, and an incremental implementation plan. -`SECURITY.md` is the normative security architecture for this feature. Implementation work must follow it for the top-level Settings > Scripting surface, protected enablement storage, granular permissions, discovery metadata, credential storage, scoped safety grants, verified execution context, authenticated-user requirements, localhost/browser protections, permission-category enforcement, deterministic target resolution, and local app-side validation. The long-term architecture includes separate verified inside-Warp and outside-Warp invocation contexts, but the current foundation implementation supports outside-Warp requests only and must reject `InvocationContext::InsideWarp` until the verified Warp-terminal proof broker lands. If this technical plan and `SECURITY.md` disagree, update the plan before implementing rather than treating the security architecture as optional follow-up work. +`SECURITY.md` is the normative security architecture for this feature. Implementation work must follow it for the top-level Settings > Scripting surface, protected mode storage, discovery metadata, credential storage, scoped safety grants, verified execution context, authenticated-user requirements, localhost/browser protections, permission-category enforcement, deterministic target resolution, and local app-side validation. The long-term architecture includes separate verified inside-Warp and outside-Warp invocation contexts, but the current foundation implementation supports outside-Warp requests only in the broadest mode and must reject `InvocationContext::InsideWarp` until the verified Warp-terminal proof broker lands. If this technical plan and `SECURITY.md` disagree, update the plan before implementing rather than treating the security architecture as optional follow-up work. The existing app already has three relevant building blocks: - `crates/http_server/src/lib.rs (7-61)` runs a native-only loopback Axum server on fixed port `9277`. - `app/src/lib.rs (1993-2001)` registers that HTTP server in the native app and currently merges only installation-detection and profiling routers. @@ -26,22 +26,22 @@ The most important constraint surfaced by this code is that the current fixed-po ### 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: -- Long term, local control scripting has separate inside-Warp and outside-Warp enablement states. Inside-Warp control for verified Warp-managed terminal sessions can default on only after the app-issued proof broker exists; outside-Warp control for external terminals, scripts, IDEs, launch agents, and other same-user processes defaults off. -- In the current foundation slice, only outside-Warp enablement and permissions are implemented. Inside-Warp credential requests must be rejected and inside-Warp settings must not be exposed in the UI. -- In the long-term model, both controls live under a new top-level Settings pane page named **Scripting**. -- The authoritative enablement states are local-only, not Settings Sync'd, and stored in protected local storage rather than ordinary user-editable settings. -- The current foundation branch must mark all implemented outside-Warp local-control settings as `private: true` and `sync_to_cloud: SyncToCloud::Never`. They must not appear in the user-visible `settings.toml` file, generated settings schema, Settings Sync, Warp Drive, server-backed preferences, or any future `warpctrl settings` surface. -- `warpctrl`, direct protocol requests, shell scripts, config files, registry/plist edits, defaults writes, and server-backed preferences must not be able to enable either setting. +- Local control scripting has a single mode setting: disabled, enabled within Warp by default, and enabled everywhere including outside Warp. Inside-Warp control for verified Warp-managed terminal sessions can work only after the app-issued proof broker exists; outside-Warp control for external terminals, scripts, IDEs, launch agents, and other same-user processes requires the broadest mode. +- In the current foundation slice, the mode setting is implemented, outside-Warp credential requests are allowed only in the broadest mode, and inside-Warp credential requests must be rejected until proof verification exists. +- The control lives under a new top-level Settings pane page named **Scripting**. +- The authoritative mode is local-only, not Settings Sync'd, and stored in protected local storage rather than ordinary user-editable settings. +- The current foundation branch must mark the implemented local-control mode as `private: true` and `sync_to_cloud: SyncToCloud::Never`. It must not appear in the user-visible `settings.toml` file, generated settings schema, Settings Sync, Warp Drive, server-backed preferences, or any future `warpctrl settings` surface. +- `warpctrl`, direct protocol requests, shell scripts, config files, registry/plist edits, defaults writes, and server-backed preferences must not be able to enable or widen the mode. - Discovery records do not publish actionable endpoints or credential references for disabled outside-Warp control. - Credential issuance is unavailable when the request's invocation context is disabled. - Raw credential material is kept out of plaintext discovery records and stored in platform secure storage where available. - The broker distinguishes verified Warp-terminal invocations from external invocations using an app-issued execution-context proof, not a caller-declared label. Until that broker exists, `InsideWarp` is a reserved protocol concept that must not receive credentials. - External invocations default to a smaller logged-out-safe action set that does not touch user-authenticated data. -- Verified Warp-terminal invocations may receive authenticated-user grants only when the selected app has a true logged-in Warp user and local-control settings allow authenticated-user actions from Warp terminals. +- Verified Warp-terminal invocations may receive authenticated-user grants only when the selected app has a true logged-in Warp user and local-control mode plus action policy allow authenticated-user actions from Warp terminals. - The app rejects disabled, unauthenticated, expired, revoked, insufficient-scope, unsupported, malformed, ambiguous, missing-target, and stale-target requests with structured errors. - Every action has a documented state/data category and the app bridge enforces the required permission category locally before selector resolution or handler dispatch. - Every action has a documented `requires_authenticated_user` value and allowed execution contexts. New actions default to requiring an authenticated user unless explicitly reviewed as logged-out-safe. -- Granular local-control settings under Settings > Scripting gate the maximum grants for metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, underlying data mutations, authenticated-user actions from Warp terminals, and authenticated-user actions from external clients. +- The Settings > Scripting mode gates invocation contexts; action metadata, credential grants, Agent/Profile policy, and authenticated-scripting identity gate metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, underlying data mutations, and authenticated-user actions. - Permission categories are treated as user-intent and accident-prevention guardrails, not as strong same-user malicious-app isolation. - Remote control remains out of scope for the local same-machine credential model. The first implementation slice should include the protected enablement gate, credential issuance checks, and app-side permission-category enforcement even if the only mutating action initially implemented is `tab.create`. Shipping `tab.create` without the enablement and validation architecture would create the wrong foundation for the full catalog. @@ -113,7 +113,7 @@ Recommended design: - control-listener endpoint - protocol version - start timestamp - - credential metadata or secure-storage references only when the relevant inside-Warp or outside-Warp context is enabled + - credential metadata or secure-storage references only when the selected mode allows the relevant inside-Warp or outside-Warp context - The CLI loads discovery records, removes or ignores stale records after health checks, and chooses an instance using the product selector rules. - `warpctrl instance list` is a CLI-first projection of this discovery registry plus health responses. When outside-Warp control is disabled, discovery must follow `SECURITY.md`: either publish no actionable local-control record for external clients or publish only a minimal disabled-status record with no endpoint authority or credential reference. @@ -122,12 +122,12 @@ This design preserves the current `9277` behavior while avoiding cross-process p Mutating localhost routes should not copy the permissive CORS posture of `/install_detection`. Recommended local trust model: - No browser-readable CORS allowance on control endpoints. -- The relevant implemented Scripting setting must allow the request context before credentials are minted or sensitive control requests are accepted. In the current foundation branch that means outside-Warp only; future inside-Warp support must add its own verified setting gate. -- The authoritative enablement bit must live in protected local storage and must not be writable by `warpctrl` or ordinary same-user preference/config edits. +- The relevant Scripting mode must allow the request context before credentials are minted or sensitive control requests are accepted. In the current foundation branch that means outside-Warp only when the broadest mode is selected; future inside-Warp support must also verify the terminal proof. +- The authoritative mode must live in protected local storage and must not be writable by `warpctrl` or ordinary same-user preference/config edits. - Per-instance raw credential material must be kept out of plaintext discovery records and stored in platform secure storage where practical. - The CLI may load or request scoped credentials through an app-owned broker/helper, but it must not mint authority itself. - The broker verifies whether the invocation originated from a Warp-managed terminal session before issuing in-Warp-only grants. -- The broker issues authenticated-user grants only when the selected app has a true logged-in Warp user and the relevant local-control permission is enabled. +- The broker issues authenticated-user grants only when the selected app has a true logged-in Warp user and the selected mode plus action policy allow the grant. - The app rejects disabled-state, missing, malformed, invalid, expired, or revoked credentials before selector resolution or mutation. - The app maps every action to a state/data category and rejects insufficient grants before selector resolution or mutation. - The app maps every action to a `requires_authenticated_user` value and allowed execution contexts, rejecting mismatches before selector resolution or mutation. @@ -142,22 +142,22 @@ Minimum implementable design: - The shell receives only proof material needed by `warpctrl`, such as an opaque handle plus a short-lived token or challenge-response input. Plain environment variables may carry handles or hints, but a caller-set variable must not be sufficient authority. - `warpctrl` invoked from that terminal sends `InvocationContext::InsideWarp` and `ExecutionContextProof::VerifiedWarpTerminal` to `/v1/control/credentials` when it has proof material. Without proof material it must use `OutsideWarp`. - The broker verifies the proof against the app-owned registry, including app instance, session liveness, expiry, revocation, and nonce or challenge binding before minting any inside-Warp scoped credential. -- The broker then checks Settings > Scripting and permission-category policy for the requested action. A valid proof raises the maximum eligible grant set; it does not bypass user settings, action metadata, authenticated-user requirements, target scopes, or bridge enforcement. +- The broker then checks Settings > Scripting mode and permission-category policy for the requested action. A valid proof raises the maximum eligible grant set; it does not bypass user settings, action metadata, authenticated-user requirements, target scopes, or bridge enforcement. - The minted credential records `invocation_context: InsideWarp`, the granted permission category, expiry, instance, and any authenticated-user subject. The app bridge revalidates the grant and current app policy on every control request. Hardened follow-ups can strengthen this minimum design by storing secret material in platform secure storage, exposing only opaque handles through the shell, using Unix-domain-socket or named-pipe peer-credential checks, binding proofs to broker challenges, and invalidating proofs on shell/session teardown, app logout, user switch, or Settings > Scripting changes. These hardening layers improve direct-token theft resistance, but they do not create a perfect security boundary against malicious same-user software with process, filesystem, Accessibility, or screen-observation access. ### 5. Authenticated scripting identity and API-key grants The full control catalog includes Warp Drive data mutation and execution-underlying actions. Those actions require an authenticated scripting layer in addition to local-control credentials. Local-control credentials prove authority to call the local app; authenticated scripting credentials prove the Warp user or automation identity allowed to request user-backed or high-risk actions. #### Inside-Warp authenticated scripting -For `warpctrl` launched inside a Warp-managed terminal, use the verified terminal proof broker from the previous section. When the proof is valid and the selected app is logged into Warp, the broker may mint an authenticated-user grant bound to the app's current user subject. The grant is available only if Settings > Scripting enables authenticated-user actions for verified Warp terminals and the requested action's permission category is enabled. +For `warpctrl` launched inside a Warp-managed terminal, use the verified terminal proof broker from the previous section. When the proof is valid and the selected app is logged into Warp, the broker may mint an authenticated-user grant bound to the app's current user subject. The grant is available only if the selected Settings > Scripting mode and action policy allow authenticated-user actions for verified Warp terminals. The CLI must not receive raw Firebase, OAuth, server, or session tokens. The app bridge executes authenticated actions through the selected app's existing auth state and rejects the grant if the app logs out, switches users, or the grant subject no longer matches the app user. #### External API-key authenticated scripting For `warpctrl` launched outside Warp, by cron, or by another pure scripting environment, introduce a separate API-key path. The user creates or supplies a Warp-issued scripting API key with explicit scopes such as local-control authenticated reads, Drive mutation, or execution-underlying actions. The CLI may reference the key from a secret manager or environment variable such as `WARPCTRL_API_KEY`, or store it in platform secure storage through `warpctrl auth api-key set --key-stdin`; it must never print or write the raw key to discovery records, logs, JSON output, shell completions, or repo config. The local broker exchanges or validates the API key with Warp services, obtains a short-lived signed identity assertion, and mints a local authenticated-user grant only when all of the following hold: -- outside-Warp scripting is enabled; +- the broadest local-control mode is selected; - external authenticated-user grants are enabled separately from logged-out outside-Warp control; - the API key is valid, unexpired, unrevoked, and scoped for the requested permission category and action family; - the selected app is logged into the same Warp user subject, or the action is explicitly designed to use API-key-backed identity without exporting app cloud tokens; -- the requested local-control permission category is enabled; +- the requested local-control permission category is granted by action policy and credential issuance; - any resource or target restrictions in the key and grant are satisfied. The grant should record the API-key subject, scopes, credential ID, expiry, invocation context, permission category, and optional target/resource restrictions. The app bridge revalidates the grant before selector resolution and handler dispatch. #### Auth command surface and storage @@ -199,7 +199,7 @@ HTTP handler (Tokio thread) LocalControlBridge::handle_request (main thread) │ - ├─ verify protected context-specific enablement state is still enabled + ├─ verify protected local-control mode still allows the context ├─ map action to required permission category ├─ map action to authenticated-user and execution-context requirements ├─ verify presented credential grants that category, target family, execution context, and authenticated-user access @@ -226,7 +226,7 @@ LocalControlBridge::handle_request (main thread) - **Deterministic targeting.** The bridge must not silently fall back from the active window to an arbitrary ordered window for mutating actions. If the caller relies on the default active selector and no active window exists, return a structured missing-target or invalid-selector error. If future command forms allow explicit window IDs, resolve the explicit ID exactly or return `stale_target`. #### Adding new action handlers To add a new action to the bridge: -1. Add a variant to `ActionKind` in `crates/local_control/src/protocol.rs`. +1. Add an entry to the macro-backed `ActionKind` catalog in `crates/local_control/src/catalog.rs`. 2. Document its `SECURITY.md` state/data category, required permission grant, `requires_authenticated_user` value, and allowed execution contexts. 3. Add a match arm in `LocalControlBridge::handle_request` in `app/src/local_control/mod.rs`. 4. Before selector resolution or dispatch, verify local control is enabled and the presented credential grants the action category, target family, execution context, and authenticated-user access if required. @@ -277,8 +277,8 @@ Recommended modules/families: - theme list/set, system-theme controls, font/zoom actions, allowlisted settings reads/writes/toggles. - Panels/surfaces: - settings/page/search, palettes, left/right panels, Drive, resource center, code review, vertical tabs, AI assistant. -- Files/projects: - - app-state-only path opening, project opening, and metadata reads for files already open in Warp. File content reads and filesystem-content mutations are intentionally excluded from the public `warpctrl` catalog. +- Files: + - app-state-only path opening and metadata reads for files already open in Warp. File content reads and filesystem-content mutations are intentionally excluded from the public `warpctrl` catalog. - Warp Drive: - object listing/inspection/opening, object creation/update/delete/insert, opening the share dialog, the v0 personal-to-team share mutation, and typed workflow execution where supported. Do not use a generic “dispatch action by string” endpoint. Every handler should be reachable only through an explicit `ControlAction` variant. @@ -301,17 +301,16 @@ The `warpui::Action` trait should not be extended for this purpose because it cu The first `warpctrl` implementation slice should land the minimum cross-cutting architecture plus a single representative tab mutation: - Shared protocol types and error envelopes. - `FeatureFlag::WarpControlCli` and Cargo feature `warp_control_cli`, with app-side runtime gating for settings, discovery, bridge registration, and local-control endpoints. -- New top-level Settings > Scripting page rendered only while `FeatureFlag::WarpControlCli` is enabled. The current foundation exposes outside-Warp local-control settings only; verified inside-Warp controls are deferred until the proof broker exists. -- Protected local-only enablement storage where outside-Warp control defaults off. Future inside-Warp enablement must use the same protected-storage class before it is exposed. -- As an interim foundation step, the outside-Warp top-level enablement and granular permission bits live in the typed `LocalControlSettings` group as private settings with `SyncToCloud::Never`, explicit private storage keys, and no `toml_path`. This keeps them out of the user-visible settings file and generated settings schema while leaving the protected-storage migration as a required pre-ship hardening step. -- Granular outside-Warp local-control permission storage under Settings > Scripting for metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, and underlying data mutations. Future inside-Warp permissions should be added only with verified terminal proof support. +- New top-level Settings > Scripting page rendered only while `FeatureFlag::WarpControlCli` is enabled. The current foundation exposes one local-control mode with disabled, enabled within Warp, and enabled everywhere choices; verified inside-Warp proof acceptance is deferred until the proof broker exists. +- Protected local-only mode storage where outside-Warp control defaults off unless the broadest mode is selected. +- As an interim foundation step, the local-control mode lives in the typed `LocalControlSettings` group as a private setting with `SyncToCloud::Never`, an explicit private storage key, and no `toml_path`. This keeps it out of the user-visible settings file and generated settings schema while leaving the protected-storage migration as a required pre-ship hardening step. - Discovery registry and CLI instance selection. - A standalone `warpctrl` binary or artifact path that runs control commands without starting the GUI app runtime. - Per-process authenticated local-control server that refuses sensitive work when outside-Warp control is disabled and rejects inside-Warp credential requests until verified terminal proof support is implemented. - Scoped credential issuance/storage with no raw credentials in plaintext discovery records, including execution-context fields and authenticated-user grant fields. - App-side request bridge and selector resolver. - Action-category mapping and app-side safety-grant enforcement. -- Action metadata for `tab.create` that deliberately classifies it as a logged-out-safe app-state mutation only when the user's granular local-control settings allow app-state mutation. +- Action metadata for `tab.create` that deliberately classifies it as a logged-out-safe app-state mutation only when the selected local-control mode allows the invocation context. - Read-only `ping/version` plus `warpctrl instance list` or equivalent minimal discovery command. - End-to-end `warpctrl tab create` for the selected instance, reusing the same app behavior as the user-visible new-terminal-tab action. Why `tab.create` first: @@ -322,7 +321,7 @@ Why `tab.create` first: The PR should also introduce the shell-facing CLI command grammar that the remainder of the protocol will reuse and establish a lightweight CLI startup path distinct from GUI startup. ### 10. Follow-up slices: fill out the remaining protocol in parallel After the first slice validates discovery, auth, selector resolution, CLI syntax, and server-to-app execution, follow-up slices can add the remaining allowlisted catalog in parallelized action-family groups. The baseline code should make new action additions mostly additive: -- Extend `ControlAction`. +- Extend the macro-backed action catalog. - Update the relevant `WarpCtrlBehavior` mappings for the internal app actions that implement, overlap with, exclude, or defer the behavior. - Add typed params/results. - Add a handler. @@ -362,7 +361,7 @@ The intended v2 stack is: 1. `zach/warp-cli-v2/contract-spec-sync` — create from `origin/master`. It owns the product, technical, security, and README specs plus the shared contract/foundation: protocol crate shape, discovery/auth scaffolding, selector and error types, action metadata/catalog structure, Scripting settings surface, protected local-control settings, local-control server/bridge skeleton, standalone `warpctrl` binary skeleton, packaging hooks, module split, `WarpCtrlBehavior` scaffolding, and the minimum first-slice smoke path needed to prove the end-to-end architecture. 2. `zach/warp-cli-v2/auth-security` — create from `zach/warp-cli-v2/contract-spec-sync`. It owns authentication and security enforcement that applies across command families: scoped grants, execution-context policy, authenticated-user/API-key plumbing, denial paths, Settings > Scripting security controls, and tests proving high-risk actions cannot run without the required identity and permission category. 3. `zach/warp-cli-v2/readonly-capability-targets` — create from `zach/warp-cli-v2/auth-security`. It owns structural metadata reads, capability/action metadata, target selector parsing and resolution, opaque IDs, active window/tab/pane/session targeting, metadata-read permission checks, and read-only CLI output for these surfaces. It must not expose terminal output, input buffers, history, Drive object contents, or other underlying user data. -4. `zach/warp-cli-v2/appstate-file-drive-views` — create from `zach/warp-cli-v2/readonly-capability-targets`. It owns read-only app-state, file/project view, Drive view, block/input/history-style underlying-data read surfaces that are approved for the public catalog, along with the required underlying-data-read permission checks. It must keep local filesystem content reads/writes outside the v0 public catalog unless the specs are updated first. +4. `zach/warp-cli-v2/appstate-file-drive-views` — create from `zach/warp-cli-v2/readonly-capability-targets`. It owns read-only app-state, file view, Drive view, block/input/history-style underlying-data read surfaces that are approved for the public catalog, along with the required underlying-data-read permission checks. It must keep local filesystem content reads/writes outside the v0 public catalog unless the specs are updated first. 5. `zach/warp-cli-v2/metadata-config-mutations` — create from `zach/warp-cli-v2/appstate-file-drive-views`. It owns metadata/configuration mutations: allowlisted settings changes, labels/titles/appearance/configuration updates, settings or surface-opening commands that are metadata/configuration rather than underlying-data mutations, and tests proving unallowlisted or private settings are rejected. 6. `zach/warp-cli-v2/drive-data-mutations` — create from `zach/warp-cli-v2/metadata-config-mutations`. It owns authenticated underlying-data mutations for Warp Drive objects, including typed object create/update/delete/insert and the approved v0 personal-to-team sharing path. It must use disposable resources in tests and must not implement local file content reads, writes, appends, deletes, or other filesystem-content mutations. 7. `zach/warp-cli-v2/execution-underlying` — create from `zach/warp-cli-v2/drive-data-mutations`. It owns authenticated execution-underlying actions such as `input.run` and typed workflow execution where supported. It must require underlying-data-mutation permission plus authenticated scripting identity, deterministic target resolution, audit records, and tests proving accepted-command submission and agent-prompt submission remain unavailable. @@ -405,7 +404,7 @@ Keep PR boundaries aligned with the v2 stack: - PR1: `zach/warp-cli-v2/contract-spec-sync` into `master` for specs, shared contracts, protocol, CLI skeleton, settings, bridge, module scaffolding, and first-slice smoke behavior. - PR2: `zach/warp-cli-v2/auth-security` into `zach/warp-cli-v2/contract-spec-sync` or its merged successor for auth/security enforcement, execution-context policy, scoped grants, and Settings > Scripting security controls. - PR3: `zach/warp-cli-v2/readonly-capability-targets` into `zach/warp-cli-v2/auth-security` or its merged successor for metadata reads, capabilities, target selectors, and read-only structural command output. -- PR4: `zach/warp-cli-v2/appstate-file-drive-views` into `zach/warp-cli-v2/readonly-capability-targets` or its merged successor for approved underlying-data read surfaces, app-state/file/project/Drive views, and underlying-data-read permission tests. +- PR4: `zach/warp-cli-v2/appstate-file-drive-views` into `zach/warp-cli-v2/readonly-capability-targets` or its merged successor for approved underlying-data read surfaces, app-state/file/Drive views, and underlying-data-read permission tests. - PR5: `zach/warp-cli-v2/metadata-config-mutations` into `zach/warp-cli-v2/appstate-file-drive-views` or its merged successor for metadata/configuration mutations, allowlisted settings changes, and configuration-denial tests. - PR6: `zach/warp-cli-v2/drive-data-mutations` into `zach/warp-cli-v2/metadata-config-mutations` or its merged successor for authenticated Warp Drive underlying-data mutations. - PR7: `zach/warp-cli-v2/execution-underlying` into `zach/warp-cli-v2/drive-data-mutations` or its merged successor for authenticated execution-underlying actions. @@ -453,14 +452,14 @@ Map tests directly to `PRODUCT.md` behavior. - Tests must enumerate the implemented catalog and prove each implemented action has at least one parseable standalone CLI example that maps to the same `ActionKind`. - Tests must also prove each shipped parser route maps to an allowlisted catalog action and that help/completion generation includes the implemented route. `action list --implemented-only`, `capability list --implemented-only`, discovery metadata, shell completions, generated docs, and app-side bridge support must not drift silently from one another. - Security architecture: - - Protected enablement tests proving outside-Warp control defaults off, disabled outside-Warp context rejects credential issuance, sensitive discovery, and mutating requests with `local_control_disabled`, and current inside-Warp credential requests are rejected with `execution_context_not_allowed`. + - Protected mode tests proving outside-Warp control defaults off, disabled and inside-only modes reject outside-Warp credential issuance, sensitive discovery, and mutating requests with `local_control_disabled`, the broadest mode allows outside-Warp credential issuance, and current inside-Warp credential requests are rejected with `execution_context_not_allowed`. - Tests proving discovery in disabled state exposes no actionable endpoint authority or credential reference. - Credential-storage tests proving raw credentials are not written into plaintext discovery records. - Execution-context tests proving external clients cannot receive grants reserved for verified Warp-terminal invocations. - Permission-category enforcement tests proving insufficient grants fail with `insufficient_permissions` before selector resolution or handler dispatch, including separate denial cases for app-state mutation, metadata/configuration mutation, and underlying-data mutation. - Authenticated-user tests proving user-authenticated actions fail without a logged-in app user or authenticated-user grant. - External API-key tests proving missing, invalid, expired, revoked, wrong-subject, and insufficient-scope keys fail before selector resolution or handler dispatch. - - Settings > Scripting tests proving both top-level toggles and granular disabled categories invalidate credentials and prevent new grants. + - Settings > Scripting tests proving mode changes invalidate credentials and prevent new grants for invocation contexts no longer allowed. - Structured-error tests for disabled, unauthenticated, expired, revoked, insufficient-scope, execution-context-denied, authenticated-user-required, authenticated-user-unavailable, unsupported, malformed, ambiguous, missing-target, stale-target, and invalid-selector requests. - Behavior 1-6, 29-31: - Protocol version/unit tests. @@ -491,7 +490,7 @@ Verification screenshots should make the cause and effect visible in a single im Before/after screenshots for visible mutations should preserve the same staggered layout so reviewers can compare the command context and UI state directly. If a single combined screenshot is not possible because of window-manager, display-size, or focus limitations, the verifier must capture paired screenshots with the same ordinal: one terminal-output screenshot that fully shows the command and output, and one UI screenshot that shows the resulting Warp state. The manifest entry should explain why the combined composition was not possible. Screenshots should not crop out the command, exit status, selected Warp target, or relevant visible UI effect. Before every computer-use scenario, the verifier must explicitly ask and answer, "What is the best way to show the impact of this CLI command?" The verifier should then put Warp into a state where the expected effect is clearly visible before running the command. For example, syntax-highlighting changes should start with recognizable text in the input editor that will visibly change; font-size and zoom changes should start with enough terminal text or UI chrome to compare scale; tab or pane rename/color commands should keep the affected tab or pane label visible; app-state mutation commands should make the target workspace, tab, pane, input box, or surface visible; and denial paths should show the relevant Settings > Scripting state or target state that makes the denial meaningful. Each manifest entry for a visible or user-facing command should describe the chosen proof setup, the expected visual effect, and any setup screenshot used to establish the before state. After each command that has a visible or user-facing result, the verifier must use computer vision on the captured screenshot or screen recording to inspect whether the visible Warp state matches the expected effect. The verifier should record the visual inspection result in the manifest, including unexpected UI changes, missing visual evidence, ambiguous screenshots, focus/onboarding artifacts, or differences between JSON success and the visible app state. JSON success alone is not sufficient for visible-effect validation; if the screenshot does not clearly prove the expected effect, the case should be marked failed or blocked with an explanation, even when the CLI response is successful. -The verifier must exercise every invocation context implemented by the branch under review. For the current foundation branch, inside-Warp verification means proving `InsideWarp` requests are rejected and no inside-Warp Settings > Scripting controls are exposed. Once verified Warp-terminal support is implemented, the verifier must also run `warpctrl` from a terminal session inside the feature-flag-enabled Warp app and prove the app-issued execution-context proof is accepted and inside-Warp settings gate command categories. The outside-Warp path must run the same `warpctrl` binary from an external terminal or automation shell outside the built Warp app and prove outside-Warp control is default-off, then works only after enabling outside-Warp scripting and the required granular permissions in Settings > Scripting. +The verifier must exercise every invocation context implemented by the branch under review. For the current foundation branch, inside-Warp verification means proving `InsideWarp` requests are rejected even though the default mode is enabled within Warp until proof verification exists. Once verified Warp-terminal support is implemented, the verifier must also run `warpctrl` from a terminal session inside the feature-flag-enabled Warp app and prove the app-issued execution-context proof is accepted and Settings > Scripting mode gates the invocation context. The outside-Warp path must run the same `warpctrl` binary from an external terminal or automation shell outside the built Warp app and prove outside-Warp control is default-off, then works only after selecting the broadest mode in Settings > Scripting. The verification matrix should cover every implemented command in `PRODUCT.md` at least once in JSON output mode. The verifier must capture a terminal-output screenshot for every command invocation, including successful calls, expected denials, unsupported stubs, and fail-closed policy gates. Where there is a visible UI effect, the verifier must also capture app UI screenshots after the command runs, and before the command when that is needed to prove a before/after state transition. At minimum: - read-only metadata commands show successful CLI output in a terminal screenshot and, for active/focus/list commands, a visible target that matches the output; - underlying data read commands show successful CLI output only when the underlying-data-read permission is enabled, plus terminal screenshots for disabled-permission denials; @@ -549,9 +548,9 @@ flowchart LR - Browser-to-localhost abuse: - Mitigation: no permissive CORS, protected in-app enablement, explicit local auth, scoped grants, and mutating routes gated before selector resolution. - External apps silently enabling outside-Warp local control: - - Mitigation: the outside-Warp enablement state defaults off, lives in protected local storage behind Settings > Scripting, is local-only, is not Settings Sync'd, and is not writable through `warpctrl`, config files, registry/plist preference edits, defaults writes, or server-backed settings. + - Mitigation: outside-Warp control requires the broadest mode, which lives in protected local storage behind Settings > Scripting, is local-only, is not Settings Sync'd, and is not writable through `warpctrl`, config files, registry/plist preference edits, defaults writes, or server-backed settings. - External apps obtaining in-Warp authenticated-user grants: - - Mitigation: require an app-issued execution-context proof for Warp-terminal-only grants, do not trust caller-declared labels or plain environment variables as sole authority, and keep external authenticated-user grants behind a separate default-off permission. + - Mitigation: require an app-issued execution-context proof for Warp-terminal-only grants, do not trust caller-declared labels or plain environment variables as sole authority, and keep external authenticated-user grants behind the broadest local-control mode plus authenticated-scripting policy. - Logged-out requests touching user-authenticated data: - Mitigation: every action declares `requires_authenticated_user`, new actions default to true, and the bridge returns authenticated-user errors before selector resolution or dispatch. - Implementation drift from `SECURITY.md`: From ed284890359261a22410295652fec3bb3f8c81d0 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Wed, 27 May 2026 18:50:32 -0600 Subject: [PATCH 33/48] Align warpctrl TECH protocol envelope Co-Authored-By: Oz <oz-agent@warp.dev> --- specs/warp-control-cli/TECH.md | 47 ++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index 87374bf6f6..a8cde0a4b9 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -74,30 +74,55 @@ Recommended selector variants: - `FileSelector`: `Path { path, line, column }`. - `DriveObjectSelector`: `Id(DriveObjectId)` or `Lookup { object_type, name_or_path }`. Index selectors are resolved only within their parent selector context, so tab index resolution requires a resolved window and pane/session index resolution requires a resolved tab or pane. Title and name/path lookup selectors are ergonomic helpers for interactive use and must fail on ambiguity rather than choosing the first match. -Recommended top-level request shape for `tab.create`: +Recommended top-level request shape for `tab.create` matches the shared `RequestEnvelope` and `Action` serde contract. The action kind and action-specific parameters are nested together under `action` so the allowlisted action name and parameter payload travel as one typed protocol field: ```json { "protocol_version": 1, "request_id": "client-generated-id", - "action": "tab.create", "target": { "window": "active" }, - "params": {} + "action": { + "kind": "tab.create", + "params": {} + } } ``` -Recommended response shape: +Recommended success response shape matches `ResponseEnvelope` and the tagged `ControlResponse::Ok` variant: ```json { - "ok": true, "protocol_version": 1, "request_id": "client-generated-id", - "instance_id": "opaque-instance-id", - "resolved_target": { - "window_id": "opaque-window-id", - "tab_id": "opaque-tab-id" - }, - "result": {} + "response": { + "status": "ok", + "data": {} + } +} +``` +Recommended request-scoped error response shape matches `ResponseEnvelope` and the tagged `ControlResponse::Error` variant: +```json +{ + "protocol_version": 1, + "request_id": "client-generated-id", + "response": { + "status": "error", + "error": { + "code": "missing_target", + "message": "No active window is available", + "details": null + } + } +} +``` +Recommended decode-level error response shape for malformed requests that cannot be decoded into a full request envelope: +```json +{ + "protocol_version": 1, + "error": { + "code": "invalid_params", + "message": "Request body could not be decoded", + "details": null + } } ``` Error payloads should include stable codes defined in `SECURITY.md`, including `local_control_disabled`, `unauthorized_local_client`, `insufficient_permissions`, `authenticated_user_required`, `authenticated_user_unavailable`, `execution_context_not_allowed`, `ambiguous_instance`, `ambiguous_target`, `stale_target`, `invalid_selector`, `unsupported_action`, `not_allowlisted`, `invalid_params`, `target_state_conflict`, `missing_target`, and `no_instance`. From 88b0bd791f19800e248e8097d0a480f7e3de66e4 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Wed, 27 May 2026 19:12:29 -0600 Subject: [PATCH 34/48] Document warpctrl active window fallback Co-Authored-By: Oz <oz-agent@warp.dev> --- specs/warp-control-cli/PRODUCT.md | 3 ++- specs/warp-control-cli/SECURITY.md | 6 +++--- specs/warp-control-cli/TECH.md | 6 +++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/specs/warp-control-cli/PRODUCT.md b/specs/warp-control-cli/PRODUCT.md index 92a0db3bd2..63dc84d923 100644 --- a/specs/warp-control-cli/PRODUCT.md +++ b/specs/warp-control-cli/PRODUCT.md @@ -69,6 +69,7 @@ Persistent settings changes, Warp Drive creation or sharing, cross-app preferenc - Active pane in the selected tab. - Active session in the selected pane. - Active or selected terminal block in the selected session when a current block is unambiguous. + For window-scoped mutations, an omitted or active window selector may fall back to the sole existing window when no active window is reported, because that target is still unambiguous. If there are no windows, the request fails with `missing_target`; if multiple windows exist and none is active, it fails with `ambiguous_target`. 10. Every selector family supports explicit opaque IDs returned by introspection. Selector families may also support scoped indices, titles/names, or paths where those concepts are already user-visible, but IDs remain the preferred automation surface. - Window selectors support `active`, opaque window IDs, window indices from `window list`, and exact window titles for interactive use. - Tab selectors support `active`, opaque tab IDs, tab indices scoped to the resolved window, and exact tab titles for interactive use. @@ -224,7 +225,7 @@ All commands that address a running app target accept the same selector flags wh - File commands use path arguments or `--path <path>` where the path is the selected file entity; `--line <n>` and `--column <n>` refine the location when supported. - Drive commands use object ID arguments or `--drive-id <id>` where the ID is the selected Warp Drive entity; name/path lookup must be type-scoped when supported. - `--output-format <pretty|json|ndjson|text>` controls output shape and remains globally available. -Within a selector family, specifying more than one form is invalid. For example, `--tab-id` conflicts with `--tab-index`, `--tab-title`, and `--tab`. Omitted lower-level selectors use active defaults only when that active target is unambiguous. Explicit IDs must resolve exactly or fail with `stale_target`; index/title/name/path selectors that match zero targets fail with `missing_target`, and selectors that match multiple targets fail with `ambiguous_target`. +Within a selector family, specifying more than one form is invalid. For example, `--tab-id` conflicts with `--tab-index`, `--tab-title`, and `--tab`. Omitted lower-level selectors use active defaults only when the resolved target is unambiguous; window-scoped mutations may use the sole existing window when no active window is reported. Explicit IDs must resolve exactly or fail with `stale_target`; index/title/name/path selectors that match zero targets fail with `missing_target`, and selectors that match multiple targets fail with `ambiguous_target`. ### Read-only command set The read-only branches should implement the following commands before mutating catalog expansion begins: `zach/warp-cli-readonly-metadata` owns structural metadata reads, and `zach/warp-cli-readonly-data-settings` owns underlying-data reads plus read-only settings/appearance/docs. Read-only does not mean one permission: metadata reads and underlying data reads are separate grant categories. Metadata and capability reads: diff --git a/specs/warp-control-cli/SECURITY.md b/specs/warp-control-cli/SECURITY.md index 79d19936c9..1ea2ba6077 100644 --- a/specs/warp-control-cli/SECURITY.md +++ b/specs/warp-control-cli/SECURITY.md @@ -394,8 +394,8 @@ This category requires authenticated scripting identity plus explicit user or po Targeting is part of security. The protocol must not convert ambiguous or stale selectors into best-effort mutations. Rules: - Instance selection happens before request dispatch and must be explicit when ambiguous. -- `active` selectors may be ergonomic defaults only when the active target is unambiguous. -- If no active target exists for a mutating request, return `missing_target` or `invalid_selector`. +- `active` selectors may be ergonomic defaults only when the resolved target is unambiguous. For window-scoped mutations, the resolver first uses the active window and may fall back to the sole existing window when exactly one window exists. +- If no active target exists for a mutating request and no action-specific deterministic fallback applies, return `missing_target` or `invalid_selector`; if multiple fallback candidates exist, return `ambiguous_target`. - Explicit opaque IDs must resolve exactly or return `stale_target`. - Index selectors must resolve to concrete IDs before execution and must not race into a different target silently. - Session-scoped requests against non-terminal panes return `target_state_conflict`. @@ -457,7 +457,7 @@ Important errors include: - `execution_context_not_allowed` when the action or requested grant is not allowed from the verified invocation context, such as an external client attempting an in-Warp-only authenticated-user action; - `ambiguous_instance` when multiple compatible instances cannot be resolved safely; - `invalid_selector` for malformed or unsupported selector syntax; -- `missing_target` when an active/default target does not exist; +- `missing_target` when an active/default target does not exist and no deterministic fallback target exists; - `stale_target` when an explicit target ID no longer exists; - `unsupported_action` for actions not implemented by the selected instance; - `not_allowlisted` for actions intentionally excluded from the public control surface; diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index a8cde0a4b9..a3253cc229 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -232,7 +232,7 @@ LocalControlBridge::handle_request (main thread) │ └─ ActionKind::TabCreate │ ├─ validate_tab_create_target(&request.target) │ ├─ ctx.windows().active_window() - │ │ └─ if none: return invalid_selector / missing_target + │ │ └─ if none: resolve the sole window, or return missing_target / ambiguous_target │ ├─ ctx.views_of_type::<Workspace>(window_id) │ └─ workspace.update(ctx, |workspace, ctx| { │ workspace.handle_action( @@ -248,7 +248,7 @@ LocalControlBridge::handle_request (main thread) - **Synchronous result.** Unlike fire-and-forget patterns (e.g., URI intent dispatch in `app/src/uri/mod.rs`), the `spawn` call returns a concrete `Result<R, ModelDropped>`, so the HTTP handler can produce a structured success or error response. - **Reuses existing infrastructure.** `ModelSpawner` is already used throughout the codebase for background-to-main-thread communication (e.g., async file I/O results, network responses). No new concurrency primitive is needed. - **Action dispatch reuses existing app behavior.** The bridge calls `workspace.handle_action(&WorkspaceAction::AddTerminalTab { ... }, ctx)` — the exact same method the UI keybinding system uses. This ensures the control CLI produces identical behavior to the corresponding user action, including side effects like tab count updates, focus changes, and event emissions. -- **Deterministic targeting.** The bridge must not silently fall back from the active window to an arbitrary ordered window for mutating actions. If the caller relies on the default active selector and no active window exists, return a structured missing-target or invalid-selector error. If future command forms allow explicit window IDs, resolve the explicit ID exactly or return `stale_target`. +- **Deterministic targeting.** The bridge may use an active/default window selector for mutating actions only when the target is deterministic: first use the active Warp window, then fall back to the sole window if exactly one window exists. If no window exists, return `missing_target`; if more than one window exists and none is active, return `ambiguous_target`. If future command forms allow explicit window IDs, resolve the explicit ID exactly or return `stale_target`. #### Adding new action handlers To add a new action to the bridge: 1. Add an entry to the macro-backed `ActionKind` catalog in `crates/local_control/src/catalog.rs`. @@ -268,7 +268,7 @@ Recommended resolution order: 5. Resolve session only for session-scoped commands. 6. Resolve block/file/Drive selectors only for commands whose action metadata declares that target family. Selector behavior: -- `active` resolves from current app focus/selection state. +- `active` resolves from current app focus/selection state. For window-scoped mutations in the first slice, a missing active window may resolve to the sole existing window because that target is still unambiguous; zero matching windows return `missing_target`, and multiple windows without an active window return `ambiguous_target`. - Explicit opaque IDs must resolve exactly or return `stale_target`. - Index selectors are allowed only for user-visible indexed concepts and should resolve to a concrete opaque ID before execution. - Title, name, and path selectors are convenience selectors. They must be exact by default, document any future fuzzy behavior explicitly, and return `ambiguous_target` when more than one target matches. From 67f1cd72309dcb7857ba85d2bcd496fbbe37fa07 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Thu, 28 May 2026 08:46:37 -0600 Subject: [PATCH 35/48] Ignore generated local publish artifacts Co-Authored-By: Oz <oz-agent@warp.dev> --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 2f08b37049..a76ae01abf 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,7 @@ __pycache__/ # Project notes .note/ + +# Generated local publishing artifacts +.warp/pr-walkthrough/ +.wrangler/ From a312e79acfa66fc31b16b9bd15366fc096236ab0 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Fri, 29 May 2026 14:02:30 -0600 Subject: [PATCH 36/48] Update warpctrl specs for wrapper architecture Co-Authored-By: Oz <oz-agent@warp.dev> --- specs/warp-control-cli/PRODUCT.md | 16 +++---- specs/warp-control-cli/TECH.md | 75 ++++++++++++++++--------------- 2 files changed, 47 insertions(+), 44 deletions(-) diff --git a/specs/warp-control-cli/PRODUCT.md b/specs/warp-control-cli/PRODUCT.md index 63dc84d923..be59472748 100644 --- a/specs/warp-control-cli/PRODUCT.md +++ b/specs/warp-control-cli/PRODUCT.md @@ -1,10 +1,10 @@ # Summary -Warp should ship an allowlisted standalone local control CLI binary, provisionally named `warpctrl`, that acts as an agent control plane for operating Warp itself. `warpctrl` lets agents and developers script the same classes of user-visible actions they can already perform inside the running app: manipulating windows, tabs, panes, sessions, terminal blocks, appearance, settings, Warp Drive views, and selected UI surfaces. The CLI should operate against one or more already-running local Warp app processes through a stable machine protocol, with deterministic target selection and clear errors when a process or target is ambiguous. +Warp should ship an allowlisted local control CLI command, provisionally named `warpctrl`, that acts as an agent control plane for operating Warp itself. `warpctrl` is exposed as an Oz-style wrapper script that invokes the existing channel-specific Warp binary in control mode rather than as a separate standalone binary. `warpctrl` lets agents and developers script the same classes of user-visible actions they can already perform inside the running app: manipulating windows, tabs, panes, sessions, terminal blocks, appearance, settings, Warp Drive views, and selected UI surfaces. The CLI should operate against one or more already-running local Warp app processes through a stable machine protocol, with deterministic target selection and clear errors when a process or target is ambiguous. ## Problem Warp already has rich interactive actions, but they are primarily reachable through UI, keybindings, menus, or deeplinks. Agents can use native tools for files, code, shell commands, MCP calls, and many context reads, but they cannot reliably operate Warp's own product surfaces: arranging the user's workspace, focusing the correct pane, opening Warp Drive objects, presenting settings, or recovering from ambiguous UI state. Developers also cannot reliably compose those same actions into shell scripts, demos, automation, or agent workflows, and there is no general local protocol for addressing a specific running Warp instance, window, pane, session, terminal block, Warp Drive object, or other uniquely named Warp entity. ## Goals / Non-goals Goals: -- Provide a first-class, scriptable standalone `warpctrl` binary for controlling running Warp app processes. +- Provide a first-class, scriptable `warpctrl` command for controlling running Warp app processes. - Make Warp's own UI and app state available to agents through a typed, permissioned control plane instead of brittle screen automation or arbitrary internal dispatch. - Keep CLI startup lightweight by avoiding GUI-app startup or full terminal initialization for routine control commands. - Keep the surface allowlisted and finite instead of exposing arbitrary internal actions. @@ -16,7 +16,7 @@ Non-goals: - Exposing every internal app action, debug action, developer-only helper, or privileged state mutation. - Treating the CLI as a general RPC escape hatch into Warp internals. - Replacing native agent tools for code editing, file operations, shell execution, web/MCP calls, or attached conversation/block context when those tools already solve the task better. -- Requiring developers or automation to spawn the Warp GUI executable in CLI mode for ordinary control commands. +- Requiring developers or automation to directly invoke the Warp app executable path for ordinary control commands; the packaged `warpctrl` wrapper should hide that implementation detail. - Requiring the first implementation slice to ship every action in the catalog. ## Primary user stories These stories define the most compelling product uses for `warpctrl`. The command catalog below is intentionally broader, but the product should prioritize surfaces that agents cannot already operate well through native tools. @@ -157,7 +157,7 @@ Persistent settings changes, Warp Drive creation or sharing, cross-app preferenc - Arbitrary setting names outside the allowlist. - Accepted-command submission and agent-prompt submission until they receive a separate product/security review. Terminal command execution and typed Warp Drive object mutations are no longer excluded from the full product scope, but they belong to later authenticated underlying-data mutation branches and require stronger authenticated-user and permission gates than app-state or settings mutations. Local file content reads, writes, appends, deletes, and other filesystem-content mutations are excluded from the public `warpctrl` catalog; file/path support is limited to app-state intents such as opening a path in Warp and metadata reads of files already open in Warp. -27. CLI command names should be noun-oriented and discoverable. During the provisional standalone-binary phase, the control CLI should expose a `warpctrl ...` command surface: +27. CLI command names should be noun-oriented and discoverable. During the provisional wrapper-script phase, the control CLI should expose a `warpctrl ...` command surface: - `warpctrl instance list` - `warpctrl app active` - `warpctrl tab create` @@ -191,15 +191,15 @@ Persistent settings changes, Warp Drive creation or sharing, cross-app preferenc 33. The first `warpctrl` implementation slice should ship the smallest end-to-end vertical slice that proves: - The current implementation supports outside-Warp local-control requests only; verified inside-Warp requests are specified for future work and rejected until the app-issued terminal proof broker exists. - Process discovery and target resolution work. - - A standalone CLI binary can reach a running local Warp process without launching or initializing the GUI app. + - The wrapper-script command can reach a running local Warp process through the existing Warp binary's early control-mode dispatch without launching or initializing the GUI app. - `warpctrl tab create` creates a new terminal tab in the selected running instance. - The command returns a structured success or failure payload suitable for human-readable and JSON output. The first slice should include the minimum health/introspection commands needed to discover a running instance and exercise `tab.create`. -34. Follow-up PRs should fill out the remaining catalog in parallelizable groups once the protocol, discovery model, target resolution, error model, `tab.create` action path, and standalone `warpctrl` packaging shape have been validated by the first slice. +34. Follow-up PRs should fill out the remaining catalog in parallelizable groups once the protocol, discovery model, target resolution, error model, `tab.create` action path, and wrapper-script `warpctrl` packaging shape have been validated by the first slice. 35. The protocol transport should be designed so that the default target is localhost but the CLI can be extended in the future to target remote URLs (e.g., a Warp instance on another machine or a hosted control endpoint). This is not in scope for the first implementation but should not be precluded by the architecture. ## API command surface The public `warpctrl` API is organized around nouns that map to stable user-facing entities. Command names are intentionally not a dump of every internal `WorkspaceAction`, `TerminalAction`, keybinding, or command-palette binding. Internal actions inform the catalog, but a command is added only when it has a stable user-facing behavior, typed parameters, deterministic target resolution, and an explicit risk classification. -Catalog support status is part of the public API contract. An action reported as `implemented` by `warpctrl action list --implemented-only`, `warpctrl capability list --implemented-only`, or app discovery metadata must be reachable through a standalone `warpctrl ...` parser route, represented in generated help/completions/docs, and backed by an app-side bridge handler in the selected app build. Planned actions without that complete path must be reported as stubs or planned entries, even if an internal app handler already exists. +Catalog support status is part of the public API contract. An action reported as `implemented` by `warpctrl action list --implemented-only`, `warpctrl capability list --implemented-only`, or app discovery metadata must be reachable through the wrapper-backed `warpctrl ...` parser route, represented in generated help/completions/docs, and backed by an app-side bridge handler in the selected app build. Planned actions without that complete path must be reported as stubs or planned entries, even if an internal app handler already exists. ### State and data taxonomy The product surface must distinguish what kind of state a command touches. This distinction is part of the public API and the permission model, not just an implementation detail. - **Metadata reads** inspect app structure or configuration metadata without exposing user content: instances, windows, tabs, panes, sessions, capability metadata, action metadata, keybinding metadata, theme names, setting keys, and other structural state. @@ -365,7 +365,7 @@ These are underlying-data mutations because they can modify user data, execute c The command surface must continue to exclude debug-only, crash-only, auth-token, heap-dump, and arbitrary internal dispatch actions even when those actions are available in command palette or keybinding registries. Examples that remain excluded are app crash/panic helpers, access-token copy helpers, heap profile dumps, debug reset actions, raw view-tree debugging, and broad internal action-by-string execution. ## Branch stacking and delivery model The Warp Control CLI work should ship as a raw-git branch stack so the combined specs/foundation slice, read-only expansion, and mutating expansion remain reviewable independently: -- `zach/warp-cli-core-foundation` is the bottom review branch and targets `master`. It owns `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs alongside the first implementation slice: shared protocol, discovery/auth scaffolding, outside-Warp Settings > Scripting gates, local-control bridge/server, standalone `warpctrl` binary, packaging hooks, and the smallest safe end-to-end action. Verified inside-Warp invocation is documented for future implementation but is not supported by this branch. +- `zach/warp-cli-core-foundation` is the bottom review branch and targets `master`. It owns `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs alongside the first implementation slice: shared protocol, discovery/auth scaffolding, outside-Warp Settings > Scripting gates, local-control bridge/server, `warpctrl` wrapper entrypoint, packaging hooks, and the smallest safe end-to-end action. Verified inside-Warp invocation is documented for future implementation but is not supported by this branch. - `zach/warp-cli-readonly-metadata` stacks on `zach/warp-cli-core-foundation` and implements structural metadata reads, including instance/app health, active-chain, windows, tabs, panes, sessions, and action metadata. - `zach/warp-cli-readonly-data-settings` stacks on `zach/warp-cli-readonly-metadata` and fills in underlying-data reads plus read-only settings/appearance/docs, including terminal block output, input-buffer reads, history reads, and allowlisted settings metadata. - `zach/warp-cli-authenticated-scripting` stacks on `zach/warp-cli-readonly-data-settings` and implements authenticated-user grant plumbing for both verified Warp-terminal invocations and external API-key scripting identities. It does not broaden action support by itself; it makes later high-risk branches enforceable. diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index a3253cc229..9b66bfa3c2 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -1,5 +1,5 @@ # Context -`PRODUCT.md` defines a standalone local Warp control CLI binary, provisionally named `warpctrl`, with an allowlisted action catalog, deterministic addressing across multiple running Warp app processes, and an incremental implementation plan. +`PRODUCT.md` defines a local Warp control CLI command, provisionally named `warpctrl`, with an allowlisted action catalog, deterministic addressing across multiple running Warp app processes, and an incremental implementation plan. The public command should be exposed through an Oz-style wrapper script that invokes the existing channel-specific Warp binary in control mode, not through a separate standalone control binary. `SECURITY.md` is the normative security architecture for this feature. Implementation work must follow it for the top-level Settings > Scripting surface, protected mode storage, discovery metadata, credential storage, scoped safety grants, verified execution context, authenticated-user requirements, localhost/browser protections, permission-category enforcement, deterministic target resolution, and local app-side validation. The long-term architecture includes separate verified inside-Warp and outside-Warp invocation contexts, but the current foundation implementation supports outside-Warp requests only in the broadest mode and must reject `InvocationContext::InsideWarp` until the verified Warp-terminal proof broker lands. If this technical plan and `SECURITY.md` disagree, update the plan before implementing rather than treating the security architecture as optional follow-up work. The existing app already has three relevant building blocks: - `crates/http_server/src/lib.rs (7-61)` runs a native-only loopback Axum server on fixed port `9277`. @@ -15,11 +15,11 @@ Warp also already has the app-side behaviors the control API should reuse rather - `app/src/workspace/action.rs (95-776)` is the largest existing inventory of user-visible workspace actions and informs the allowlist catalog. - `app/src/workspace/util.rs (12-18)` defines `PaneViewLocator`, and `app/src/pane_group/pane/mod.rs (84-177)` defines serializable pane identifiers, both useful reference points for selector resolution. - `app/src/uri/mod.rs (822-1093, 1166-1364)` demonstrates external intents being resolved into active windows/workspaces and dispatched into running app state. -The current Oz CLI build/distribution model is also directly relevant because the control CLI should follow the same standalone-artifact approach rather than relying on the Warp GUI executable to service ordinary shell invocations: +The current Oz CLI build/distribution model is also directly relevant because the control CLI should follow the same wrapper-script approach rather than introducing a separate bundled binary: - `crates/warp_cli/src/lib.rs (88-188, 316-418)` defines the existing CLI/parser conventions and channel-specific command naming support. -- `app/src/lib.rs (631-746)` routes CLI invocations into CLI execution rather than GUI launch. -- `script/macos/bundle (353-735)` and `script/linux/bundle (157-294)` build standalone CLI artifacts with the `standalone` feature. -- `.github/workflows/create_release.yml (423-554, 660-858, 992-1276)` publishes macOS/Linux CLI artifacts. +- `app/src/lib.rs (631-746)` routes CLI invocations into CLI execution rather than GUI launch, and already avoids GUI startup for commands such as `dump-debug-info`. +- `script/macos/bundle (542-567)` writes the bundled Oz wrapper script into `Resources/bin` and uses `exec -a "$0"` to call the channel binary in `Contents/MacOS`. +- `script/linux/bundle (157-198)` shows the existing channelized binary naming and the current standalone artifact code that should be removed from the `warpctrl` path rather than extended. - `script/windows/windows-installer.iss (235-263)` shows the current Windows helper-wrapper pattern for CLI access. The most important constraint surfaced by this code is that the current fixed-port local HTTP server cannot be the entire solution for a multi-process control API. If multiple local Warp processes attempt to expose mutating routes through the same fixed port, only one can own it. The control design therefore needs explicit per-process discovery and addressing. ## Proposed changes @@ -46,7 +46,7 @@ Required security gates: - Remote control remains out of scope for the local same-machine credential model. The first implementation slice should include the protected enablement gate, credential issuance checks, and app-side permission-category enforcement even if the only mutating action initially implemented is `tab.create`. Shipping `tab.create` without the enablement and validation architecture would create the wrong foundation for the full catalog. ### 1. Protocol crate and stable envelope -Create a small shared protocol crate or equivalent shared module used by both the app server and standalone CLI client. It should define: +Create a small shared protocol crate or equivalent shared module used by both the app server and the `warpctrl` command-mode client. It should define: - Protocol version metadata. - Discovery/health response types. - Execution-context proof/request types for verified Warp-terminal invocations versus external invocations. @@ -330,7 +330,7 @@ The first `warpctrl` implementation slice should land the minimum cross-cutting - Protected local-only mode storage where outside-Warp control defaults off unless the broadest mode is selected. - As an interim foundation step, the local-control mode lives in the typed `LocalControlSettings` group as a private setting with `SyncToCloud::Never`, an explicit private storage key, and no `toml_path`. This keeps it out of the user-visible settings file and generated settings schema while leaving the protected-storage migration as a required pre-ship hardening step. - Discovery registry and CLI instance selection. -- A standalone `warpctrl` binary or artifact path that runs control commands without starting the GUI app runtime. +- A `warpctrl` wrapper entrypoint that invokes the existing channel-specific Warp binary with a hidden `--warpctrl` control-mode flag and runs control commands without starting the GUI app runtime. - Per-process authenticated local-control server that refuses sensitive work when outside-Warp control is disabled and rejects inside-Warp credential requests until verified terminal proof support is implemented. - Scoped credential issuance/storage with no raw credentials in plaintext discovery records, including execution-context fields and authenticated-user grant fields. - App-side request bridge and selector resolver. @@ -343,7 +343,7 @@ Why `tab.create` first: - It exercises process discovery, local authentication, request bridging, selector defaults, app-context dispatch, and structured success/error output without introducing higher-risk terminal input execution. - It exercises the protected enablement and scoped-grant model before higher-risk action families depend on it. - It gives operators a concise end-to-end smoke test: discover a running instance, create a tab, and confirm the live app changed. -The PR should also introduce the shell-facing CLI command grammar that the remainder of the protocol will reuse and establish a lightweight CLI startup path distinct from GUI startup. +The PR should also introduce the shell-facing CLI command grammar that the remainder of the protocol will reuse and establish a lightweight control-mode startup path inside the existing Warp binary that dispatches before GUI startup. ### 10. Follow-up slices: fill out the remaining protocol in parallel After the first slice validates discovery, auth, selector resolution, CLI syntax, and server-to-app execution, follow-up slices can add the remaining allowlisted catalog in parallelized action-family groups. The baseline code should make new action additions mostly additive: - Extend the macro-backed action catalog. @@ -353,37 +353,40 @@ After the first slice validates discovery, auth, selector resolution, CLI syntax - Add validation/tests. - Add CLI surface/tests. ### 11. CLI parsing and output libraries -The `warpctrl` CLI must use the same argument parsing and output libraries as the existing Oz CLI so that conventions, derive patterns, and shell-completion generation remain consistent across both binaries. -- **clap** (with the `derive` feature) for argument parsing, subcommand trees, and help generation. Both binaries share the `warp_cli` crate, so parser types defined there are reused directly. +The `warpctrl` CLI must use the same argument parsing and output libraries as the existing Oz CLI so that conventions, derive patterns, and shell-completion generation remain consistent across both command surfaces. +- **clap** (with the `derive` feature) for argument parsing, subcommand trees, and help generation. Oz and `warpctrl` share the `warp_cli` crate, so parser types defined there are reused directly. - **serde** / **serde_json** for JSON request/response serialization and for `--output-format json` output. - **clap_complete** for shell completion generation, reusing the same infrastructure the Oz CLI uses. - The `OutputFormat` enum (`Pretty`, `Json`, `Ndjson`, `Text`) is shared from `warp_cli::agent::OutputFormat` so human-readable vs. machine-readable output follows the same conventions. - New subcommand types for `warpctrl` live in `warp_cli::local_control` and follow the same `#[derive(Parser)]` / `#[derive(Subcommand)]` / `#[derive(Args)]` patterns used by the Oz CLI's top-level `Args` and `CliCommand` types. -Do not introduce alternative parsing libraries (e.g., `structopt`, `argh`) or alternative serialization approaches. Keeping one set of libraries across both CLIs reduces dependency weight, ensures consistent `--help` formatting, and lets contributors move between the two surfaces without learning a different stack. +Do not introduce alternative parsing libraries (e.g., `structopt`, `argh`) or alternative serialization approaches. Keeping one set of libraries across both command surfaces reduces dependency weight, ensures consistent `--help` formatting, and lets contributors move between the two surfaces without learning a different stack. ### 12. CLI packaging and release shape -The shipped product shape should be a separate bundled `warpctrl` CLI binary that reuses shared CLI/protocol crates but does not depend on launching the GUI binary in command mode. Follow the Oz CLI release model as closely as practical: +The shipped product shape should be a bundled `warpctrl` wrapper script or helper that calls the existing channel-specific Warp binary with a hidden `--warpctrl` flag. It should match the Oz app-bundle model: users invoke `warpctrl ...`, while the wrapper delegates to the real Warp executable that already carries channel identity, embedded resources, signing, and release metadata. - macOS: - - Add a standalone control CLI artifact path next to the existing Oz standalone CLI artifact flow. - - If the app bundle also exposes a wrapper/install flow, keep channelized naming consistent with the final product name decision. + - Add a `Resources/bin/warpctrl` wrapper next to the existing Oz wrapper script in the app bundle. + - The wrapper should use the same pattern as Oz: compute its script directory, `exec -a "$0"` the channel binary in `Contents/MacOS`, and append the hidden `--warpctrl` flag before forwarding user arguments. + - Keep channelized naming consistent with the final product name decision; if non-stable channels need aliases, the aliases should still point at the same channel app binary. - Linux: - - Extend bundle/release scripts to emit control CLI standalone artifacts and packages in the same broad pattern as the current Oz CLI tarball/deb/rpm/Arch package flow. + - Prefer installing a small `warpctrl` wrapper or symlink/helper in the same package as the Warp app, routed to the packaged channel binary with `--warpctrl`. + - Do not add a separate `--artifact warpctrl` standalone release path unless a later product decision explicitly chooses independent CLI packages. - Windows: - - Mirror the existing installer-generated helper-wrapper pattern first if that remains the canonical Oz behavior on Windows. - - If the product decision is to ship a true standalone Windows control CLI binary, add a dedicated release path in follow-up work rather than silently diverging from existing Oz precedent. + - Mirror the existing installer-generated helper-wrapper pattern first. + - If Windows cannot cheaply use a shell-script-style wrapper, generate the smallest possible helper that forwards to the installed channel binary with `--warpctrl` and preserves stdout/stderr behavior for scripts. Startup and dependency expectations: -- The CLI process should initialize only command parsing, discovery, authentication material loading, protocol serialization, HTTP transport, and output formatting needed for the requested command. -- The CLI should not initialize GUI state, rendering, terminal session models, app workspaces, or other main-app-only subsystems. -- Startup cost should be treated as part of the product contract because control commands are expected to compose naturally in scripts and repeated interactive shell usage. +- `app/src/lib.rs` should recognize `--warpctrl` before app launch and route into `warp_cli::local_control` just as current CLI commands route before GUI launch. +- The control-mode path should initialize only command parsing, discovery, authentication material loading, protocol serialization, HTTP transport, output formatting, and any minimal shared state needed for channel/version/feature evaluation. +- The control-mode path should not initialize GUI state, rendering, terminal session models, app workspaces, or other main-app-only subsystems. +- Startup cost should be treated as part of the product contract because control commands are expected to compose naturally in scripts and repeated interactive shell usage. Add a focused startup-path regression test or smoke check so the wrapper path stays close to Oz command latency. Naming decision: -- Product examples use provisional `warpctrl ...` command lines for the standalone local-control binary. -- Final artifact filenames, channelized aliases, and installer exposure should be chosen before broad rollout to avoid churn in bundle scripts, docs, shell completions, and release workflow files. +- Product examples use provisional `warpctrl ...` command lines for the local-control wrapper. +- Final wrapper names, channelized aliases, and installer exposure should be chosen before broad rollout to avoid churn in bundle scripts, docs, shell completions, and release workflow files. ## Implementation Plan ### Branch stack Use raw git for the stack; do not use Graphite for these branches. The active durable review stack is the recovered `zach/warp-cli-v2/*` stack. This stack is the review architecture for the current implementation because it preserves the fan-in work while slicing it into branch-sized review boundaries. The older branch names in the pre-recovery plan are historical source material only and should not be used as the active PR stack. Spec ownership is part of the branch architecture. The only v2 branch that may intentionally change `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, or `README.md` is `zach/warp-cli-v2/contract-spec-sync`. After a spec change lands there, propagate it upward through every higher v2 branch with raw git rebases so those files remain byte-identical across the stack. Higher implementation branches must not make independent spec edits except when resolving a propagation conflict in a way that preserves the bottom-branch content. The intended v2 stack is: -1. `zach/warp-cli-v2/contract-spec-sync` — create from `origin/master`. It owns the product, technical, security, and README specs plus the shared contract/foundation: protocol crate shape, discovery/auth scaffolding, selector and error types, action metadata/catalog structure, Scripting settings surface, protected local-control settings, local-control server/bridge skeleton, standalone `warpctrl` binary skeleton, packaging hooks, module split, `WarpCtrlBehavior` scaffolding, and the minimum first-slice smoke path needed to prove the end-to-end architecture. +1. `zach/warp-cli-v2/contract-spec-sync` — create from `origin/master`. It owns the product, technical, security, and README specs plus the shared contract/foundation: protocol crate shape, discovery/auth scaffolding, selector and error types, action metadata/catalog structure, Scripting settings surface, protected local-control settings, local-control server/bridge skeleton, `warpctrl` wrapper/control-mode entrypoint, packaging hooks, module split, `WarpCtrlBehavior` scaffolding, and the minimum first-slice smoke path needed to prove the end-to-end architecture. 2. `zach/warp-cli-v2/auth-security` — create from `zach/warp-cli-v2/contract-spec-sync`. It owns authentication and security enforcement that applies across command families: scoped grants, execution-context policy, authenticated-user/API-key plumbing, denial paths, Settings > Scripting security controls, and tests proving high-risk actions cannot run without the required identity and permission category. 3. `zach/warp-cli-v2/readonly-capability-targets` — create from `zach/warp-cli-v2/auth-security`. It owns structural metadata reads, capability/action metadata, target selector parsing and resolution, opaque IDs, active window/tab/pane/session targeting, metadata-read permission checks, and read-only CLI output for these surfaces. It must not expose terminal output, input buffers, history, Drive object contents, or other underlying user data. 4. `zach/warp-cli-v2/appstate-file-drive-views` — create from `zach/warp-cli-v2/readonly-capability-targets`. It owns read-only app-state, file view, Drive view, block/input/history-style underlying-data read surfaces that are approved for the public catalog, along with the required underlying-data-read permission checks. It must keep local filesystem content reads/writes outside the v0 public catalog unless the specs are updated first. @@ -423,7 +426,7 @@ When `FeatureFlag::WarpControlCli` is disabled in the Warp app: - no `/v1/control` or `/v1/control/credentials` local server endpoints should be exposed; - command-palette/keybinding entries related specifically to installing, configuring, or using `warpctrl` should be hidden; - tests should cover both enabled and disabled flag states with the repo's normal feature-flag override helpers. -The standalone `warpctrl` binary can still exist in a build where the app feature is disabled, but it should find no compatible enabled app instance and should return a structured no-instance or feature-disabled error rather than relying on hidden server state. +The `warpctrl` wrapper may still be installed in a build where the app feature is disabled, but the hidden control-mode entrypoint should find no compatible enabled app instance and should return a structured no-instance or feature-disabled error rather than relying on hidden server state. ### Merge and review strategy Keep PR boundaries aligned with the v2 stack: - PR1: `zach/warp-cli-v2/contract-spec-sync` into `master` for specs, shared contracts, protocol, CLI skeleton, settings, bridge, module scaffolding, and first-slice smoke behavior. @@ -472,9 +475,9 @@ sequenceDiagram ## Testing and validation Map tests directly to `PRODUCT.md` behavior. - Catalog/parser implementation-status invariant: - - `ActionImplementationStatus::Implemented` means the action is complete enough for standalone CLI users in the selected build: it has a parseable `warpctrl ...` command route, generated help/completion/docs coverage, a protocol parameter mapping, and an app-side bridge handler. - - Catalog entries that have only an internal app handler, only protocol metadata, or only a planned product command must remain `Stub` until the standalone CLI route and generated surfaces ship. - - Tests must enumerate the implemented catalog and prove each implemented action has at least one parseable standalone CLI example that maps to the same `ActionKind`. + - `ActionImplementationStatus::Implemented` means the action is complete enough for wrapper-script CLI users in the selected build: it has a parseable `warpctrl ...` command route, generated help/completion/docs coverage, a protocol parameter mapping, and an app-side bridge handler. + - Catalog entries that have only an internal app handler, only protocol metadata, or only a planned product command must remain `Stub` until the wrapper-backed CLI route and generated surfaces ship. + - Tests must enumerate the implemented catalog and prove each implemented action has at least one parseable `warpctrl ...` CLI example that maps to the same `ActionKind`. - Tests must also prove each shipped parser route maps to an allowlisted catalog action and that help/completion generation includes the implemented route. `action list --implemented-only`, `capability list --implemented-only`, discovery metadata, shell completions, generated docs, and app-side bridge support must not drift silently from one another. - Security architecture: - Protected mode tests proving outside-Warp control defaults off, disabled and inside-only modes reject outside-Warp credential issuance, sensitive discovery, and mutating requests with `local_control_disabled`, the broadest mode allows outside-Warp credential issuance, and current inside-Warp credential requests are rejected with `execution_context_not_allowed`. @@ -505,17 +508,17 @@ Map tests directly to `PRODUCT.md` behavior. - Behavior 30: - Multi-process integration-style coverage using two synthetic discovery records and mock health responders, plus manual testing with multiple channel builds where practical. - Packaging: - - `--artifact cli`-style bundle smoke tests or script-level checks for each supported platform path touched by the first slice. - - Startup-path tests or focused checks confirming `warpctrl` dispatches commands without entering GUI-app launch code. + - Bundle smoke tests or script-level checks for each supported platform path touched by the first slice, proving the `warpctrl` wrapper is installed and forwards to the channel binary with `--warpctrl`. + - Startup-path tests or focused checks confirming `warpctrl` dispatches commands through early control-mode routing without entering GUI-app launch code. - Shell completions/help output checks once final command naming is selected. ### Computer-use CLI verification -Before any stacked PR is considered ready for review, run an end-to-end computer-use verification pass against a cloud built validation artifact from that branch. The validation artifact is a Warp app build and standalone `warpctrl` binary built by the validation agent from the exact commit under review, with `warp_control_cli` compiled in and `FeatureFlag::WarpControlCli` enabled at runtime. +Before any stacked PR is considered ready for review, run an end-to-end computer-use verification pass against a cloud built validation artifact from that branch. The validation artifact is a Warp app build plus its packaged `warpctrl` wrapper from the exact commit under review, with `warp_control_cli` compiled in and `FeatureFlag::WarpControlCli` enabled at runtime. The verifier must launch the built Warp app, enable the Scripting settings needed for the command category under test, and capture screenshots or screen recordings that prove both the terminal invocation and the user-visible result of each basic command family. Every `warpctrl` invocation executed during verification must have a durable screenshot captured immediately after the command runs, showing the exact command line and full terminal output in either pretty or JSON mode. Screenshots should be saved as durable artifacts and named by branch, invocation context, command family, command name, and whether the image shows terminal output or app UI state. Verification screenshots should make the cause and effect visible in a single image whenever possible. The preferred composition is a staggered two-window layout where the terminal running the `warpctrl` command remains visible and unobscured, while the target Warp window or terminal is also visible enough to prove the UI state before or after the command. For outside-Warp invocations, use one external terminal window for the CLI command and one built Warp app window, staggered so the screenshot shows both the command/output and the Warp UI result. For inside-Warp invocations, use two Warp terminal windows or panes when possible: one Warp terminal running `warpctrl` and a second Warp terminal or Warp window showing the target/result state, staggered so both are visible in the same screenshot. Avoid screenshots that show only the CLI terminal or only the Warp UI when a combined view can be captured. Before/after screenshots for visible mutations should preserve the same staggered layout so reviewers can compare the command context and UI state directly. If a single combined screenshot is not possible because of window-manager, display-size, or focus limitations, the verifier must capture paired screenshots with the same ordinal: one terminal-output screenshot that fully shows the command and output, and one UI screenshot that shows the resulting Warp state. The manifest entry should explain why the combined composition was not possible. Screenshots should not crop out the command, exit status, selected Warp target, or relevant visible UI effect. Before every computer-use scenario, the verifier must explicitly ask and answer, "What is the best way to show the impact of this CLI command?" The verifier should then put Warp into a state where the expected effect is clearly visible before running the command. For example, syntax-highlighting changes should start with recognizable text in the input editor that will visibly change; font-size and zoom changes should start with enough terminal text or UI chrome to compare scale; tab or pane rename/color commands should keep the affected tab or pane label visible; app-state mutation commands should make the target workspace, tab, pane, input box, or surface visible; and denial paths should show the relevant Settings > Scripting state or target state that makes the denial meaningful. Each manifest entry for a visible or user-facing command should describe the chosen proof setup, the expected visual effect, and any setup screenshot used to establish the before state. After each command that has a visible or user-facing result, the verifier must use computer vision on the captured screenshot or screen recording to inspect whether the visible Warp state matches the expected effect. The verifier should record the visual inspection result in the manifest, including unexpected UI changes, missing visual evidence, ambiguous screenshots, focus/onboarding artifacts, or differences between JSON success and the visible app state. JSON success alone is not sufficient for visible-effect validation; if the screenshot does not clearly prove the expected effect, the case should be marked failed or blocked with an explanation, even when the CLI response is successful. -The verifier must exercise every invocation context implemented by the branch under review. For the current foundation branch, inside-Warp verification means proving `InsideWarp` requests are rejected even though the default mode is enabled within Warp until proof verification exists. Once verified Warp-terminal support is implemented, the verifier must also run `warpctrl` from a terminal session inside the feature-flag-enabled Warp app and prove the app-issued execution-context proof is accepted and Settings > Scripting mode gates the invocation context. The outside-Warp path must run the same `warpctrl` binary from an external terminal or automation shell outside the built Warp app and prove outside-Warp control is default-off, then works only after selecting the broadest mode in Settings > Scripting. +The verifier must exercise every invocation context implemented by the branch under review. For the current foundation branch, inside-Warp verification means proving `InsideWarp` requests are rejected even though the default mode is enabled within Warp until proof verification exists. Once verified Warp-terminal support is implemented, the verifier must also run `warpctrl` from a terminal session inside the feature-flag-enabled Warp app and prove the app-issued execution-context proof is accepted and Settings > Scripting mode gates the invocation context. The outside-Warp path must run the packaged `warpctrl` wrapper from an external terminal or automation shell outside the built Warp app and prove outside-Warp control is default-off, then works only after selecting the broadest mode in Settings > Scripting. The verification matrix should cover every implemented command in `PRODUCT.md` at least once in JSON output mode. The verifier must capture a terminal-output screenshot for every command invocation, including successful calls, expected denials, unsupported stubs, and fail-closed policy gates. Where there is a visible UI effect, the verifier must also capture app UI screenshots after the command runs, and before the command when that is needed to prove a before/after state transition. At minimum: - read-only metadata commands show successful CLI output in a terminal screenshot and, for active/focus/list commands, a visible target that matches the output; - underlying data read commands show successful CLI output only when the underlying-data-read permission is enabled, plus terminal screenshots for disabled-permission denials; @@ -588,11 +591,11 @@ flowchart LR - Mitigation: allowlisted setting keys only, with private/debug/derived settings rejected. - Command execution risk: - Mitigation: keep `input.run`/typed workflow execution in the catalog but implement it only in `zach/warp-cli-v2/execution-underlying` after authenticated scripting identity, underlying-data-mutation permission, deterministic target resolution, and audit coverage are in place. -- Packaging churn due to provisional executable naming: +- Packaging churn due to provisional wrapper naming: - Mitigation: document `warpctrl` as provisional and settle final aliases before broad release workflow rollout. -- Heavyweight CLI startup caused by sharing the GUI binary's launch path: - - Mitigation: ship a separate control CLI artifact with a narrow initialization path and keep GUI-only subsystems out of ordinary CLI command execution. +- Heavyweight CLI startup caused by sharing the app binary: + - Mitigation: route `--warpctrl` before GUI launch, keep the control-mode initialization path as narrow as current Oz command dispatch, and add startup-path checks that fail if the wrapper initializes GUI-only subsystems. ## Follow-ups -- Decide the final artifact filename/channel alias scheme around the provisional `warpctrl ...` public command surface. -- Decide whether Windows should follow the current Oz wrapper pattern indefinitely or gain standalone control CLI artifacts. +- Decide the final wrapper filename/channel alias scheme around the provisional `warpctrl ...` public command surface. +- Decide whether Windows should follow the current Oz helper-wrapper pattern indefinitely or gain a different forwarding helper. - Decide whether a future subscription/watch protocol is useful for scripts that want live state changes, rather than single request/response calls only. From b97aa9072801bc3a5137be71db95af2d0c4ef2e6 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Fri, 29 May 2026 15:04:04 -0600 Subject: [PATCH 37/48] Route warpctrl through Warp binary Add hidden --warpctrl control-mode dispatch through the existing Warp app binary and remove the standalone warpctrl Rust entrypoints. Co-Authored-By: Oz <oz-agent@warp.dev> --- app/Cargo.toml | 4 -- app/src/bin/warpctrl.rs | 7 --- app/src/lib.rs | 5 ++ crates/warp_cli/src/bin/warpctrl.rs | 7 --- crates/warp_cli/src/local_control/mod.rs | 60 ++++++++++++++++++++-- crates/warp_cli/src/local_control_tests.rs | 22 ++++++++ specs/warp-control-cli/README.md | 36 +++++++------ 7 files changed, 104 insertions(+), 37 deletions(-) delete mode 100644 app/src/bin/warpctrl.rs delete mode 100644 crates/warp_cli/src/bin/warpctrl.rs diff --git a/app/Cargo.toml b/app/Cargo.toml index 2bd0091aa2..89ba84a5b7 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -26,10 +26,6 @@ test = false name = "warp" path = "src/bin/local.rs" test = false -[[bin]] -name = "warpctrl" -path = "src/bin/warpctrl.rs" -test = false [[bin]] name = "integration" diff --git a/app/src/bin/warpctrl.rs b/app/src/bin/warpctrl.rs deleted file mode 100644 index c3a4656e58..0000000000 --- a/app/src/bin/warpctrl.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Thin binary wrapper for the standalone `warpctrl` executable bundled with Warp. -use std::process::ExitCode; - -fn main() -> ExitCode { - let args = warp_cli::local_control::ControlArgs::from_env(); - warp_cli::local_control::run(args) -} diff --git a/app/src/lib.rs b/app/src/lib.rs index 2d63341e21..6df5e6341a 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -584,6 +584,11 @@ pub fn run() -> Result<()> { // Ensure feature flags are initialized before parsing command-line arguments. features::init_feature_flags(); + if let Some(args) = warp_cli::local_control::ControlArgs::from_control_mode_env() { + #[cfg(windows)] + warp_util::windows::attach_to_parent_console(); + warp_cli::local_control::run_and_exit(args); + } // Parse command-line arguments. let args = warp_cli::Args::from_env(); diff --git a/crates/warp_cli/src/bin/warpctrl.rs b/crates/warp_cli/src/bin/warpctrl.rs deleted file mode 100644 index f66e3e7774..0000000000 --- a/crates/warp_cli/src/bin/warpctrl.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Binary entry point for the standalone `warpctrl` CLI. -use std::process::ExitCode; - -fn main() -> ExitCode { - let args = warp_cli::local_control::ControlArgs::from_env(); - warp_cli::local_control::run(args) -} diff --git a/crates/warp_cli/src/local_control/mod.rs b/crates/warp_cli/src/local_control/mod.rs index 12f6cce282..4860d9c22e 100644 --- a/crates/warp_cli/src/local_control/mod.rs +++ b/crates/warp_cli/src/local_control/mod.rs @@ -3,6 +3,7 @@ mod commands; mod completions; mod output; mod selectors; +use std::ffi::OsString; use std::process::ExitCode; @@ -13,6 +14,8 @@ use clap_complete::aot::Shell; use commands::{run_app_command, run_instance_command, run_tab_command}; use completions::generate_completions_to_stdout; use output::write_control_error; +/// Hidden flag used by the channel-specific Warp app binary to enter `warpctrl` mode. +pub const CONTROL_MODE_FLAG: &str = "--warpctrl"; /// Parsed top-level arguments for `warpctrl`. #[derive(Debug, Parser)] @@ -38,12 +41,53 @@ pub struct ControlArgs { impl ControlArgs { pub fn from_env() -> Self { - let matches = Self::clap_command().get_matches(); - Self::from_arg_matches(&matches).unwrap_or_else(|err| err.exit()) + let bin_name = crate::binary_name().unwrap_or_else(|| "warpctrl".to_owned()); + Self::try_parse_from_args(std::env::args_os(), bin_name).unwrap_or_else(|err| err.exit()) + } + + pub fn from_control_mode_env() -> Option<Self> { + Self::try_parse_control_mode_from(std::env::args_os()) + .map(|result| result.unwrap_or_else(|err| err.exit())) + } + + pub fn try_parse_control_mode_from<I, T>(args: I) -> Option<Result<Self, clap::Error>> + where + I: IntoIterator<Item = T>, + T: Into<OsString>, + { + let mut stripped_args = vec![OsString::from("warpctrl")]; + let mut found_control_mode = false; + + for arg in args { + let arg = arg.into(); + if !found_control_mode { + if arg.to_str() == Some(CONTROL_MODE_FLAG) { + found_control_mode = true; + } + continue; + } + stripped_args.push(arg); + } + + found_control_mode.then(|| Self::try_parse_from_args(stripped_args, "warpctrl")) } pub fn clap_command() -> clap::Command { let bin_name = crate::binary_name().unwrap_or_else(|| "warpctrl".to_owned()); + Self::clap_command_for_bin_name(bin_name) + } + + fn try_parse_from_args<I, T>(args: I, bin_name: impl Into<String>) -> Result<Self, clap::Error> + where + I: IntoIterator<Item = T>, + T: Into<OsString> + Clone, + { + let matches = Self::clap_command_for_bin_name(bin_name).try_get_matches_from(args)?; + Self::from_arg_matches(&matches) + } + + fn clap_command_for_bin_name(bin_name: impl Into<String>) -> clap::Command { + let bin_name = bin_name.into(); <Self as CommandFactory>::command() .version(crate::version_string()) .bin_name(bin_name.clone()) @@ -135,9 +179,17 @@ pub struct TargetArgs { } pub fn run(args: ControlArgs) -> ExitCode { + ExitCode::from(run_exit_code(args)) +} + +pub fn run_and_exit(args: ControlArgs) -> ! { + std::process::exit(i32::from(run_exit_code(args))) +} + +fn run_exit_code(args: ControlArgs) -> u8 { let output_format = args.output_format; match run_inner(args) { - Ok(()) => ExitCode::SUCCESS, + Ok(()) => 0, Err(error) => { if let Err(write_error) = write_control_error(&error, output_format) { eprintln!( @@ -145,7 +197,7 @@ pub fn run(args: ControlArgs) -> ExitCode { write_error.message ); } - ExitCode::FAILURE + 1 } } } diff --git a/crates/warp_cli/src/local_control_tests.rs b/crates/warp_cli/src/local_control_tests.rs index 5fe726e447..0e7cead779 100644 --- a/crates/warp_cli/src/local_control_tests.rs +++ b/crates/warp_cli/src/local_control_tests.rs @@ -47,6 +47,28 @@ fn parses_first_slice_app_smoke_metadata_commands() { assert!(ControlArgs::try_parse_from(["warpctrl", "app", "ping"]).is_ok()); assert!(ControlArgs::try_parse_from(["warpctrl", "app", "version"]).is_ok()); } +#[test] +fn parses_control_mode_args_after_hidden_flag() { + let args = ControlArgs::try_parse_control_mode_from([ + "warp", + "--warpctrl", + "tab", + "create", + "--instance", + "inst_123", + ]) + .expect("control mode flag is present") + .expect("control mode args parse"); + let ControlCommand::Tab(TabCommand::Create(target)) = args.command else { + panic!("expected tab create command"); + }; + assert_eq!(target.instance.as_deref(), Some("inst_123")); +} + +#[test] +fn ignores_args_without_control_mode_flag() { + assert!(ControlArgs::try_parse_control_mode_from(["warp", "tab", "create"]).is_none()); +} #[test] fn parses_completion_generation_command() { diff --git a/specs/warp-control-cli/README.md b/specs/warp-control-cli/README.md index 6bf563e15c..405b76da68 100644 --- a/specs/warp-control-cli/README.md +++ b/specs/warp-control-cli/README.md @@ -1,5 +1,5 @@ # warpctrl operator README -`warpctrl` is the provisional standalone CLI for controlling an already-running local Warp app instance. It is intended for scripts, demos, agent workflows, and developer automation that need to perform allowlisted Warp UI actions without launching the GUI executable in CLI mode. +`warpctrl` is the provisional CLI entrypoint for controlling an already-running local Warp app instance. It is intended for scripts, demos, agent workflows, and developer automation that need to perform allowlisted Warp UI actions through the installed channel-specific Warp binary without launching the GUI. The first implementation slice is intentionally narrow: - discover compatible running Warp instances; - select one instance implicitly when unambiguous or explicitly with `--instance`; @@ -7,28 +7,34 @@ The first implementation slice is intentionally narrow: - create a new terminal tab with `warpctrl tab create`. The local-control protocol and catalog are broader than this slice, but commands outside the implemented capability set should fail with structured unsupported-action errors until their handlers land. ## Packaging model -`warpctrl` should be packaged as a separate CLI artifact from the Warp GUI app while reusing shared repository code: +`warpctrl` should be packaged as an Oz-style wrapper script rather than a standalone Rust binary. The wrapper should resolve the installed channel-specific Warp executable and invoke it with the hidden `--warpctrl` control-mode flag: - `crates/local_control` owns discovery records, local authentication material, client transport, protocol envelopes, action names, and error types. - `crates/warp_cli` owns command parsing conventions for local-control subcommands. +- the channel-specific app binary owns the hidden `--warpctrl` dispatch path and exits before normal GUI startup. - the app-side bridge owns the per-process loopback listener and dispatches supported actions onto the live Warp UI context. -The binary should initialize only CLI parsing, instance discovery, local authentication loading, request serialization, HTTP transport, and output formatting. It should not initialize GUI state, terminal models, rendering, workspaces, or main-app startup paths. +The control-mode path should initialize only the work needed for CLI parsing, instance discovery, local authentication loading, request serialization, HTTP transport, and output formatting. It should not initialize GUI state, terminal models, rendering, workspaces, or main-app startup paths. During the provisional naming period, release artifacts and helper names may be channelized, but operator docs and examples should use `warpctrl` unless an integration branch explicitly documents a channel-specific alias. -This branch wires the standalone binary target and the macOS/Linux bundle-script artifact selectors: -- `cargo build -p warp --bin warpctrl` -- `script/macos/bundle --artifact warpctrl ...` -- `script/linux/bundle --artifact warpctrl ...` -Windows has the native Rust binary target, but installer/release helper exposure remains follow-up packaging work. +This branch wires the core hidden dispatch contract through the existing Warp binary. Follow-up packaging work should install platform-specific wrapper scripts that call the channel binary with `--warpctrl` instead of producing or selecting a separate `warpctrl` artifact. ## Install and invocation guidance ### macOS -Build locally with `cargo build -p warp --bin warpctrl`, then run `target/debug/warpctrl` or copy/symlink that binary onto `PATH`. -For distributable standalone artifact checks, use `script/macos/bundle --artifact warpctrl` with the desired channel/signing flags. The bundle script writes a standalone `warpctrl` binary into its macOS artifact output directory instead of embedding it in the GUI app bundle. +Until the wrapper installer lands, build the local Warp binary and invoke it with the hidden control-mode flag for development checks: +```bash +cargo run -p warp --bin warp -- --warpctrl instance list +``` +For distributable checks, the installed `warpctrl` wrapper should live on `PATH` and exec the app bundle's channel-specific executable with `--warpctrl`. ### Linux -Build locally with `cargo build -p warp --bin warpctrl`, then run `target/debug/warpctrl` or copy/symlink that binary onto `PATH`. -For distributable standalone artifact checks, use `script/linux/bundle --artifact warpctrl` with the desired channel/package selection. The Linux bundle script routes packaging through the standalone control-binary artifact path; downstream package installation should place the emitted `warpctrl` binary according to that package format. +Until the wrapper installer lands, build the local Warp binary and invoke it with the hidden control-mode flag for development checks: +```bash +cargo run -p warp --bin warp -- --warpctrl instance list +``` +For distributable checks, downstream packages should install a `warpctrl` wrapper onto `PATH` that execs the installed channel-specific Warp executable with `--warpctrl`. Run `warpctrl --version` after installation to confirm the shell is resolving the expected build. ### Windows -Build locally with `cargo build -p warp --bin warpctrl`, then run `target\debug\warpctrl.exe` or copy that binary onto `PATH`. -The Windows-native binary target exists in this slice. Installer helper creation and release-artifact wiring still need a later packaging change before docs can promise an installer-provided `warpctrl` command. +Until the wrapper installer lands, build the local Warp binary and invoke it with the hidden control-mode flag for development checks: +```powershell +cargo run -p warp --bin warp -- --warpctrl instance list +``` +Installer helper creation and release-artifact wiring still need a later packaging change before docs can promise an installer-provided `warpctrl` command. ## End-to-end local test flow Use matching app and CLI bits from the same branch or release artifact so the protocol version and action catalog agree. 1. Start Warp and leave at least one window open. @@ -90,4 +96,4 @@ sequenceDiagram - Treat `warpctrl` as provisional executable naming until packaging signs off on final artifact aliases. - Keep examples scoped to discovery and `tab create` until additional app-side handlers are implemented. - Do not document catalog commands as usable just because they exist in protocol enums or parser scaffolding; operator docs should distinguish implemented commands from planned allowlist entries. -- Windows packaging may initially follow the existing helper-wrapper pattern rather than shipping a native standalone executable. Update this README when that decision is final. +- Windows packaging may initially follow the existing helper-wrapper pattern. Update this README when that decision is final. From ff18b06584caf890c3322eddcc1c2bfa7eef3bee Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Sat, 30 May 2026 15:47:42 -0600 Subject: [PATCH 38/48] Harden warpctrl local-control security Co-Authored-By: Oz <oz-agent@warp.dev> --- app/src/local_control/mod.rs | 53 +++++- app/src/local_control/mod_tests.rs | 29 +++- app/src/settings/local_control.rs | 221 ++++++++++++++++++++++-- app/src/settings/local_control_tests.rs | 98 ++++++++++- app/src/test_util/settings.rs | 7 +- specs/warp-control-cli/SECURITY.md | 21 ++- 6 files changed, 402 insertions(+), 27 deletions(-) diff --git a/app/src/local_control/mod.rs b/app/src/local_control/mod.rs index 2de66b8050..6c4debd170 100644 --- a/app/src/local_control/mod.rs +++ b/app/src/local_control/mod.rs @@ -15,9 +15,8 @@ //! requested action, and only then hands the request to the main-thread //! `LocalControlBridge`. //! -//! The Settings > Scripting gates used here are private, local-only settings. -//! Broader grants should keep using private settings unless a future action -//! class requires stronger platform-specific storage guarantees. +//! The Settings > Scripting gates used here are local-only settings backed by +//! Warp's secure storage provider. //! //! Discovery records never include raw bearer tokens: discovery only exposes //! endpoint metadata and credential broker references when outside-Warp control @@ -39,6 +38,7 @@ use ::local_control::{ }; use axum::extract::rejection::JsonRejection; use axum::extract::State; +use axum::http::header::{AUTHORIZATION, HOST, ORIGIN}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::routing::post; @@ -56,6 +56,7 @@ use permissions::{ensure_action_allowed, ensure_feature_enabled}; struct ControlServerState { bridge_spawner: ModelSpawner<LocalControlBridge>, instance_id: InstanceId, + expected_host: String, credentials: Arc<Mutex<HashMap<String, CredentialGrant>>>, } /// Process-local localhost server running inside Warp for control actions. @@ -148,6 +149,7 @@ impl LocalControlServer { let state = ControlServerState { bridge_spawner, instance_id, + expected_host: format!("{}:{}", control_endpoint.host, control_endpoint.port), credentials: Arc::default(), }; let router = Router::new() @@ -200,8 +202,16 @@ fn discovery_record_for_settings( async fn handle_credential_request( State(state): State<ControlServerState>, + headers: HeaderMap, payload: Result<Json<CredentialRequest>, JsonRejection>, ) -> Response { + if let Err(error) = validate_loopback_headers(&headers, &state.expected_host) { + return ( + StatusCode::FORBIDDEN, + Json(ErrorResponseEnvelope::new(error)), + ) + .into_response(); + } if let Err(error) = ensure_feature_enabled() { return ( StatusCode::FORBIDDEN, @@ -331,6 +341,13 @@ async fn handle_control_request( headers: HeaderMap, payload: Result<Json<RequestEnvelope>, JsonRejection>, ) -> Response { + if let Err(error) = validate_loopback_headers(&headers, &state.expected_host) { + return ( + StatusCode::FORBIDDEN, + Json(ErrorResponseEnvelope::new(error)), + ) + .into_response(); + } if let Err(error) = ensure_feature_enabled() { return ( StatusCode::FORBIDDEN, @@ -339,7 +356,7 @@ async fn handle_control_request( .into_response(); } let auth_header = headers - .get(axum::http::header::AUTHORIZATION) + .get(AUTHORIZATION) .and_then(|value| value.to_str().ok()); let auth_token = match AuthToken::from_authorization_header(auth_header) { Ok(token) => token, @@ -410,6 +427,34 @@ async fn handle_control_request( (status, Json(response)).into_response() } +pub(crate) fn validate_loopback_headers( + headers: &HeaderMap, + expected_host: &str, +) -> Result<(), ControlError> { + if headers.contains_key(ORIGIN) { + return Err(ControlError::new( + ErrorCode::UnauthorizedLocalClient, + "browser-origin local-control requests are not allowed", + )); + } + let host = headers + .get(HOST) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| { + ControlError::new( + ErrorCode::UnauthorizedLocalClient, + "Host header is required for local-control requests", + ) + })?; + if host != expected_host { + return Err(ControlError::new( + ErrorCode::UnauthorizedLocalClient, + "Host header does not match the selected local-control endpoint", + )); + } + Ok(()) +} + #[cfg(test)] pub(crate) use permissions::{ capabilities, ensure_settings_allow_action, outside_warp_control_enabled_for_settings, diff --git a/app/src/local_control/mod_tests.rs b/app/src/local_control/mod_tests.rs index ce5f104243..d1ed8735aa 100644 --- a/app/src/local_control/mod_tests.rs +++ b/app/src/local_control/mod_tests.rs @@ -4,13 +4,15 @@ use ::local_control::protocol::{ WindowTarget, }; use ::local_control::{ErrorCode, InvocationContext}; +use axum::http::header::{HOST, ORIGIN}; +use axum::http::{HeaderMap, HeaderValue}; use settings::Setting as _; use warp_core::features::FeatureFlag; use super::{ capabilities, ensure_feature_enabled, ensure_settings_allow_action, outside_warp_control_enabled_for_settings, require_active_window_id, validate_action_params, - validate_tab_create_target, + validate_loopback_headers, validate_tab_create_target, }; use crate::settings::{LocalControlMode, LocalControlModeSetting, LocalControlSettings}; @@ -104,6 +106,31 @@ fn capabilities_advertises_only_first_slice_core_actions() { ); } +#[test] +fn loopback_headers_reject_origin_and_host_mismatch() { + let expected_host = "127.0.0.1:1234"; + let mut headers = HeaderMap::new(); + headers.insert(HOST, HeaderValue::from_static(expected_host)); + + validate_loopback_headers(&headers, expected_host).expect("matching host should be accepted"); + + headers.insert(ORIGIN, HeaderValue::from_static("https://example.com")); + let err = + validate_loopback_headers(&headers, expected_host).expect_err("origin should be rejected"); + assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); + + headers.remove(ORIGIN); + headers.insert(HOST, HeaderValue::from_static("localhost:1234")); + let err = validate_loopback_headers(&headers, expected_host) + .expect_err("host mismatch should be rejected"); + assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); + + let headers = HeaderMap::new(); + let err = validate_loopback_headers(&headers, expected_host) + .expect_err("missing host should be rejected"); + assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); +} + #[test] fn outside_warp_discovery_requires_everywhere_mode() { assert!(!outside_warp_control_enabled_for_settings( diff --git a/app/src/settings/local_control.rs b/app/src/settings/local_control.rs index b2cc36ce50..ce930fc9e1 100644 --- a/app/src/settings/local_control.rs +++ b/app/src/settings/local_control.rs @@ -1,10 +1,15 @@ -//! Private local setting that gates local-control invocation contexts. +//! Secure local setting that gates local-control invocation contexts. //! //! This setting is local-only, kept out of the user-visible settings file, and -//! marked `private: true` in the settings definition. It is the authoritative +//! persisted through Warp's secure storage provider. It is the authoritative //! enablement bit for local control. +use anyhow::{anyhow, Context as _, Result}; use serde::{Deserialize, Serialize}; -use settings::{macros::define_settings_group, SupportedPlatforms, SyncToCloud}; +use settings::{macros::define_settings_group, Setting, SupportedPlatforms, SyncToCloud}; +use warpui::{AppContext, ModelContext}; +use warpui_extras::secure_storage::{self, AppContextExt as _}; + +const LOCAL_CONTROL_MODE_STORAGE_KEY: &str = "LocalControlMode"; /// User-selected local-control availability. #[derive( @@ -55,17 +60,209 @@ impl LocalControlMode { } define_settings_group!(LocalControlSettings, settings: [ - local_control_mode: LocalControlModeSetting { - type: LocalControlMode, - default: LocalControlMode::Disabled, - supported_platforms: SupportedPlatforms::DESKTOP, - sync_to_cloud: SyncToCloud::Never, - private: true, - storage_key: "LocalControlMode", - description: "Whether Warp local control is disabled, enabled within Warp, or enabled everywhere including outside Warp.", - }, + local_control_mode: LocalControlModeSetting, ]); +/// Setting wrapper for the authoritative local-control mode. +pub struct LocalControlModeSetting { + inner: LocalControlMode, + is_explicitly_set: bool, +} + +impl LocalControlModeSetting { + fn read_from_secure_storage(ctx: &AppContext) -> Option<LocalControlMode> { + let value = match ctx.secure_storage().read_value(Self::storage_key()) { + Ok(value) => value, + Err(secure_storage::Error::NotFound) => return None, + Err(err) => { + log::error!("Failed to read local-control mode from secure storage: {err:#}"); + return None; + } + }; + match serde_json::from_str(&value) { + Ok(value) => Some(value), + Err(err) => { + log::error!("Failed to deserialize local-control mode: {err:#}"); + None + } + } + } + + fn migrate_from_private_preferences(ctx: &AppContext) -> Option<LocalControlMode> { + let value = Self::read_from_preferences(Self::preferences_for_setting(ctx))?; + if let Err(err) = Self::write_value_to_secure_storage(&value, ctx) { + log::error!("Failed to migrate local-control mode to secure storage: {err:#}"); + } + if let Err(err) = Self::clear_from_preferences(Self::preferences_for_setting(ctx)) { + log::warn!( + "Failed to clear migrated local-control mode from private preferences: {err:#}" + ); + } + Some(value) + } + + fn write_value_to_secure_storage( + new_value: &LocalControlMode, + ctx: &AppContext, + ) -> Result<bool> { + let stored_value_matches = match ctx.secure_storage().read_value(Self::storage_key()) { + Ok(stored) => serde_json::from_str::<LocalControlMode>(&stored) + .is_ok_and(|stored| stored == *new_value), + Err(secure_storage::Error::NotFound) => false, + Err(err) => { + return Err(anyhow!(err)) + .context("Failed to read existing local-control mode from secure storage"); + } + }; + if stored_value_matches { + return Ok(false); + } + let serialized = serde_json::to_string(new_value) + .context("Failed to serialize local-control mode for secure storage")?; + ctx.secure_storage() + .write_value(Self::storage_key(), &serialized) + .context("Failed to write local-control mode to secure storage")?; + Ok(true) + } + + fn clear_from_secure_storage(ctx: &AppContext) -> Result<()> { + match ctx.secure_storage().remove_value(Self::storage_key()) { + Ok(()) | Err(secure_storage::Error::NotFound) => Ok(()), + Err(err) => { + Err(anyhow!(err)).context("Failed to clear local-control mode from secure storage") + } + } + } + + fn emit_changed( + ctx: &mut ModelContext<LocalControlSettings>, + change_event_reason: settings::ChangeEventReason, + ) { + ctx.emit(LocalControlSettingsChangedEvent::LocalControlModeSetting { + change_event_reason, + }); + } +} + +impl Setting for LocalControlModeSetting { + type Group = LocalControlSettings; + type Value = LocalControlMode; + + fn new(value: Option<Self::Value>) -> Self { + match value { + Some(value) => Self { + inner: value, + is_explicitly_set: true, + }, + None => Self { + inner: Self::default_value(), + is_explicitly_set: false, + }, + } + } + + fn setting_name() -> &'static str { + "LocalControlModeSetting" + } + + fn storage_key() -> &'static str { + LOCAL_CONTROL_MODE_STORAGE_KEY + } + + fn supported_platforms() -> SupportedPlatforms { + SupportedPlatforms::DESKTOP + } + + fn sync_to_cloud() -> SyncToCloud { + SyncToCloud::Never + } + + fn is_private() -> bool { + true + } + + fn value(&self) -> &Self::Value { + &self.inner + } + + fn clear_value(&mut self, ctx: &mut ModelContext<Self::Group>) -> Result<()> { + Self::clear_from_secure_storage(ctx)?; + self.inner = self.validate(Self::default_value()); + self.is_explicitly_set = false; + Self::emit_changed(ctx, settings::ChangeEventReason::Clear); + Ok(()) + } + + fn load_value( + &mut self, + new_value: Self::Value, + explicitly_set: bool, + ctx: &mut ModelContext<Self::Group>, + ) -> Result<()> { + let validated = self.validate(new_value); + if self.value() != &validated || self.is_explicitly_set != explicitly_set { + self.inner = validated; + self.is_explicitly_set = explicitly_set; + Self::emit_changed(ctx, settings::ChangeEventReason::LocalChange); + } + Ok(()) + } + + fn set_value_from_cloud_sync( + &mut self, + new_value: Self::Value, + ctx: &mut ModelContext<Self::Group>, + ) -> Result<()> { + let changed_in_storage = Self::write_value_to_secure_storage(&new_value, ctx)?; + if self.value() != &new_value || changed_in_storage { + self.inner = self.validate(new_value); + self.is_explicitly_set = true; + Self::emit_changed(ctx, settings::ChangeEventReason::CloudSync); + } + Ok(()) + } + + fn set_value( + &mut self, + new_value: Self::Value, + ctx: &mut ModelContext<Self::Group>, + ) -> Result<()> { + let changed_in_storage = Self::write_value_to_secure_storage(&new_value, ctx)?; + if self.value() != &new_value || changed_in_storage { + self.inner = self.validate(new_value); + self.is_explicitly_set = true; + Self::emit_changed(ctx, settings::ChangeEventReason::LocalChange); + } + Ok(()) + } + + fn default_value() -> Self::Value { + LocalControlMode::Disabled + } + + fn new_from_storage(ctx: &mut AppContext) -> Self { + let value = Self::read_from_secure_storage(ctx) + .or_else(|| Self::migrate_from_private_preferences(ctx)); + Self::new(value) + } + + fn is_supported_on_current_platform(&self) -> bool { + SupportedPlatforms::DESKTOP.matches_current_platform() + } + + fn is_value_explicitly_set(&self) -> bool { + self.is_explicitly_set + } +} + +impl std::ops::Deref for LocalControlModeSetting { + type Target = LocalControlMode; + + fn deref(&self) -> &Self::Target { + self.value() + } +} + impl LocalControlSettings { pub fn mode(&self) -> LocalControlMode { *self.local_control_mode diff --git a/app/src/settings/local_control_tests.rs b/app/src/settings/local_control_tests.rs index f0ef5f3f2e..817273d2b4 100644 --- a/app/src/settings/local_control_tests.rs +++ b/app/src/settings/local_control_tests.rs @@ -1,5 +1,54 @@ +use std::collections::HashMap; +use std::sync::Mutex; + use super::{LocalControlMode, LocalControlModeSetting, LocalControlSettings}; -use settings::Setting as _; +use settings::{PrivatePreferences, PublicPreferences, Setting as _, SettingsManager}; +use warpui::SingletonEntity as _; +use warpui_extras::secure_storage::{self, AppContextExt as _}; +use warpui_extras::user_preferences; + +#[derive(Default)] +struct InMemorySecureStorage { + values: Mutex<HashMap<String, String>>, +} + +impl secure_storage::SecureStorage for InMemorySecureStorage { + fn write_value(&self, key: &str, value: &str) -> Result<(), secure_storage::Error> { + match self.values.lock() { + Ok(mut values) => { + values.insert(key.to_owned(), value.to_owned()); + Ok(()) + } + Err(err) => Err(secure_storage::Error::Unknown(anyhow::anyhow!( + err.to_string() + ))), + } + } + + fn read_value(&self, key: &str) -> Result<String, secure_storage::Error> { + match self.values.lock() { + Ok(values) => values + .get(key) + .cloned() + .ok_or(secure_storage::Error::NotFound), + Err(err) => Err(secure_storage::Error::Unknown(anyhow::anyhow!( + err.to_string() + ))), + } + } + + fn remove_value(&self, key: &str) -> Result<(), secure_storage::Error> { + match self.values.lock() { + Ok(mut values) => { + values.remove(key); + Ok(()) + } + Err(err) => Err(secure_storage::Error::Unknown(anyhow::anyhow!( + err.to_string() + ))), + } + } +} fn default_settings() -> LocalControlSettings { LocalControlSettings { @@ -16,3 +65,50 @@ fn defaults_disable_warp_control() { assert!(!settings.inside_warp_control_enabled()); assert!(!settings.outside_warp_control_enabled()); } + +#[test] +fn mode_is_persisted_to_secure_storage() { + warpui::App::test((), |mut app| async move { + app.update(|ctx| { + ctx.add_singleton_model(|_| { + PublicPreferences::new( + Box::<user_preferences::in_memory::InMemoryPreferences>::default(), + ) + }); + ctx.add_singleton_model(|_| { + PrivatePreferences::new( + Box::<user_preferences::in_memory::InMemoryPreferences>::default(), + ) + }); + ctx.add_singleton_model(|_| SettingsManager::default()); + ctx.add_singleton_model(|_| -> secure_storage::Model { + Box::<InMemorySecureStorage>::default() + }); + LocalControlSettings::register(ctx); + }); + + app.update(|ctx| { + LocalControlSettings::handle(ctx).update(ctx, |settings, ctx| { + settings + .local_control_mode + .set_value(LocalControlMode::EnabledEverywhere, ctx) + }) + }) + .expect("setting update should succeed"); + + app.read(|ctx| { + let stored = ctx + .secure_storage() + .read_value(LocalControlModeSetting::storage_key()) + .expect("local-control mode should be stored securely"); + let mode = serde_json::from_str::<LocalControlMode>(&stored) + .expect("stored local-control mode should deserialize"); + assert_eq!(mode, LocalControlMode::EnabledEverywhere); + + let private_value = LocalControlModeSetting::preferences_for_setting(ctx) + .read_value(LocalControlModeSetting::storage_key()) + .expect("private preferences should be readable"); + assert!(private_value.is_none()); + }); + }); +} diff --git a/app/src/test_util/settings.rs b/app/src/test_util/settings.rs index e9bc4176bd..01949f6fdf 100644 --- a/app/src/test_util/settings.rs +++ b/app/src/test_util/settings.rs @@ -57,6 +57,10 @@ pub fn initialize_settings_for_tests_with_mode( app.update(init_and_register_user_preferences); app.add_singleton_model(|_ctx| SettingsManager::default()); app.add_singleton_model(WarpConfig::mock); + app.update(|ctx| { + // Register a no-op secure storage provider for testing. + warpui_extras::secure_storage::register_noop("test", ctx); + }); AccessibilitySettings::register(app); app.update(AISettings::register_and_subscribe_to_events); @@ -117,9 +121,6 @@ pub fn initialize_settings_for_tests_with_mode( SemanticSelection::register(app); app.update(|ctx| { - // Register a no-op secure storage provider for testing. - warpui_extras::secure_storage::register_noop("test", ctx); - // Add settings models that are backed by secure storage, not user preferences. ctx.add_singleton_model(ai::api_keys::ApiKeyManager::new); }); diff --git a/specs/warp-control-cli/SECURITY.md b/specs/warp-control-cli/SECURITY.md index 1ea2ba6077..db58db40c4 100644 --- a/specs/warp-control-cli/SECURITY.md +++ b/specs/warp-control-cli/SECURITY.md @@ -95,8 +95,8 @@ Warp control has one top-level mode setting based on invocation context: - **Enabled within Warp:** default. Controls `warpctrl` invocations from verified Warp-managed terminal sessions once proof verification exists. - **Enabled everywhere, including outside Warp:** controls verified Warp-managed terminal invocations and external terminals, scripts, launch agents, IDEs, or other same-user processes. The mode should live in a new top-level Settings pane page named **Scripting**. The Scripting page owns the user-facing controls for local scripting surfaces, including Warp control, and should explain the difference between commands run inside Warp and commands run from other apps. -The visible UI setting is not enough by itself. The authoritative mode must be stored in protected local storage that ordinary same-user apps cannot update by writing a plist, settings database row, registry key, JSON file, or synced cloud preference. This avoids turning outside-Warp control into a feature that any process can silently enable before invoking `warpctrl`. -Current foundation implementation note: the mode is represented in the typed `LocalControlSettings` group as a private, local-only setting. The implemented setting must use `private: true`, `SyncToCloud::Never`, an explicit private storage key, and no `toml_path`, so it is excluded from `settings.toml`, the generated settings schema, Settings Sync, Warp Drive, and user-editable or server-backed settings surfaces. This private-settings path is an interim storage boundary, not the final protected-storage requirement; before public shipment, this authoritative mode must move to platform protected storage where available. +The visible UI setting is not enough by itself. The authoritative mode must be stored in the most secure local storage provider available for the platform, with read/write access limited to the Warp application or Warp-owned trusted helper code where the platform supports that restriction. On macOS this means Keychain or an equivalent protected store constrained to Warp-signed code, not ordinary UserDefaults; on Windows this means Credential Manager, DPAPI-backed protected storage, or an equivalent app-controlled protected store; on Linux this means the platform secret service where available, with any owner-only file fallback explicitly documented as weaker. This avoids turning outside-Warp control into a feature that any process can silently enable before invoking `warpctrl`. +Current foundation implementation note: the mode is represented in the typed `LocalControlSettings` group but is persisted through Warp's secure storage provider rather than ordinary private preferences, `settings.toml`, SQLite, or a synced cloud preference. The implemented setting must use `SyncToCloud::Never`, remain absent from user-visible settings files, generated schemas, Settings Sync, Warp Drive, local-control settings read/write commands, and user-editable or server-backed settings surfaces, and should keep migrating any earlier private-preferences value into secure storage. This is a tamper-resistant platform storage boundary, not a claim that arbitrary same-user compromise is impossible; platforms without a secure provider must document the weaker fallback. Enablement requirements: - The mode is local-only and must not sync through Settings Sync, Warp Drive, or server-backed user preferences. - The implemented foundation setting must remain private and absent from user-visible settings files, generated schemas, local-control settings read/write commands, and any allowlisted settings mutation catalog. @@ -106,6 +106,7 @@ Enablement requirements: - Outside-Warp control requires an intentional user gesture to select the broadest mode; the UI should explain that it allows scripts and automation from other apps to control Warp. - The mode should be easy to change from the same UI, and narrowing the mode should revoke or invalidate active local-control credentials for invocation contexts no longer allowed. - If enterprise or managed-device policy is added later, policy may force-disable the mode or force a narrower default, but policy should be separate from user-editable local settings. +Local-control actions that open, focus, or view cloud-backed objects must not create unexpected cloud-synced durable side effects merely because the object was displayed through automation. If an action intentionally mutates synced state, that mutation must be classified under the appropriate state/data category and require the matching grant, authenticated-user authority, and user or policy approval where applicable. Disabled-state behavior: - Warp should not mint scoped local-control credentials for a request whose invocation context is disabled. - The control listener should reject requests from disabled contexts with a structured disabled-state error before authentication, selector resolution, or handler dispatch. @@ -205,7 +206,7 @@ sequenceDiagram Invoker->>CLI: Invoke allowlisted command CLI->>Registry: Read instance metadata - Registry-->>CLI: instance_id, endpoint, protocol version, credential reference + Registry-->>CLI: instance_id, endpoint, protocol version, broker reference CLI->>Enablement: Check inside/outside context enablement Enablement-->>CLI: Enabled or disabled alt Disabled @@ -241,7 +242,7 @@ A discovery record should contain: - channel and build metadata; - protocol version and supported capability summary; - loopback endpoint for the instance-local control listener; -- credential reference or bootstrap credential metadata, not necessarily the full control credential. +- credential broker reference that can mint a just-in-time scoped credential for a requested action, not a bearer token or reusable control credential. Discovery rules: - Records must be readable only by the owning user. - POSIX records must use owner-only permissions such as `0600` for files and a non-world-readable directory. @@ -250,9 +251,11 @@ Discovery rules: - The CLI must prune or ignore stale records whose PID is gone or whose health/protocol check fails. - If multiple compatible instances are ambiguous, the CLI must require explicit `--instance` selection. - Discovery metadata must not expose terminal contents, environment variables, auth tokens for cloud services, raw local-control credentials, or mutating capability grants. +- Discovery must not publish actionable endpoints or credential broker references for an invocation context unless the protected mode currently enables that context. Future UI should support temporary or session-scoped enablement and a quick path back to disabled so one-off control use does not leave an unexpectedly durable passive discovery surface. ## Credential model The full `warpctrl` catalog requires scoped credentials. A single shared full-power bearer token is not sufficient once automation, underlying data reads, app-state mutations, metadata/configuration mutations, and underlying data mutations are supported. ### Credential properties +Current foundation implementation note: `warpctrl` discovers an endpoint and then requests a short-lived credential from `/v1/control/credentials` for the specific action it is about to invoke. The discovery record publishes endpoint and broker metadata only; it does not contain bearer tokens, raw credential material, or a stored credential that the CLI unwraps and sends to the discovered port. A control credential should encode or reference: - issuing Warp instance; - protocol version or accepted version range; @@ -280,8 +283,8 @@ Recommended defaults: - Metadata/configuration mutations require an explicit `mutate_metadata` or `mutate_configuration` grant. - Underlying data mutations require an explicit `mutate_underlying_data` grant and should require approval or policy for unattended automation. - User-authenticated data reads or mutations require an explicit `authenticated_user` grant and an allowed authenticated action family in addition to the data-category grant. -- Integrations should receive the narrowest grant needed for the configured workflow. -The broker must not issue broad authority merely because the request came from the signed `warpctrl` binary. It should evaluate the requested permission category, target scope, configured policy, execution context, and whether user approval is required. The CLI must not mint its own authority. It can request, load, and present credentials, but the app bridge remains the enforcement point for these safety grants. +- Integrations should be granted only the narrowest authority needed for the configured workflow. +Callers should not manage low-level permission scopes directly. They request a typed action or higher-level capability, and the app-owned broker maps that request to the required permission category, target scope, configured policy, execution context, and any user approval or consent prompt. If a request exceeds the caller's current grant and is not explicitly denied by policy, the app can prompt for the narrower additional grant; if it is denied, the bridge returns a structured error. The broker must not issue broad authority merely because the request came from the signed `warpctrl` binary. The CLI must not mint its own authority. It can request and present broker-issued credentials, but the app bridge remains the enforcement point for these safety grants. ### Safety grants, not strong access control The category system should be understood as a user-intent and accident-prevention mechanism: - A user can ask an agent or script to operate with metadata-read grants so it can inspect structure but cannot read terminal content or mutate state. @@ -313,9 +316,12 @@ Mitigations: These mitigations are about routing high-risk operations through intentional `warpctrl` flows rather than exposing a reusable localhost token to any process. They should not be documented as a guarantee that arbitrary same-user applications cannot cause Warp-visible actions. ## Transport authentication The default transport is an instance-local loopback listener bound to `127.0.0.1` on an ephemeral per-process port. +The current just-in-time credential broker avoids the specific stale-record bearer-token phishing failure mode where `warpctrl` unwraps a long-lived Warp-held credential and sends it to a port squatter. If future designs add stored bootstrap credentials, server-held secrets, or reusable credential references that must be presented to the discovered endpoint, the client must verify the server's identity before sending that material, or the local transport should move to Unix domain sockets or an equivalent platform channel with peer identity checks. Transport requirements: - Bind only to loopback for local control. - Do not set permissive CORS headers. +- Reject any request carrying an `Origin` header. +- Reject any request whose `Host` header is not exactly `127.0.0.1:<selected-port>` for the selected discovery record. - Reject control requests when their inside-Warp or outside-Warp invocation context is disabled, even if the request presents an otherwise valid credential. - Authenticate every control request locally in the selected Warp app process before selector resolution or action dispatch. - Reject missing, malformed, expired, revoked, or invalid credentials with structured authentication errors. @@ -418,8 +424,11 @@ Each supported command requires: Adding a new action should be additive and reviewable: extend the protocol enum, implement validation, map the action to a state/data category, declare whether it requires an authenticated user, declare its allowed execution contexts, add a handler, and add tests for authentication, safety-policy denial, authenticated-user denial, selector failure, and success behavior. ## Browser and localhost protections Loopback is not sufficient by itself because browsers can send requests to localhost. +This section is not a browser-only defense and must not rely on CORS as the primary control. Non-browser local clients can also send HTTP requests, so the local app must enforce credentials, invocation-context gating, app-side authorization, and endpoint hardening for every request. Required protections: - No permissive CORS on control endpoints. +- Reject any request that includes an `Origin` header. +- Reject any request whose `Host` header is not exactly the selected `127.0.0.1:<port>` endpoint. - No JSONP or browser-readable fallback formats. - Valid scoped credentials required for all sensitive endpoints. - Credentials stored outside browser-readable locations. From 775601cd1509dfc30d28fb0c9274c42c8b828587 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Sat, 30 May 2026 15:53:57 -0600 Subject: [PATCH 39/48] Update common skills lock Co-Authored-By: Oz <oz-agent@warp.dev> --- skills-lock.json | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/skills-lock.json b/skills-lock.json index 89826be218..03d53ca820 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -47,7 +47,7 @@ "source": "warpdotdev/common-skills", "sourceType": "github", "skillPath": ".agents/skills/pr-walkthrough/SKILL.md", - "computedHash": "990e8549611cff4b82036d4c7cc50d23eb2cbb27272926142d3fd8ad17a5e18f" + "computedHash": "fd5ff2a22f4cc80ef8d0ab3a7bdef427d6463719f9e5fdf47f6fa1371fc4d3f9" }, "reproduce-bug-report": { "source": "warpdotdev/common-skills", @@ -61,6 +61,12 @@ "skillPath": ".agents/skills/resolve-merge-conflicts/SKILL.md", "computedHash": "5376b5692901c624e8f20a5a04aeea5f5a94f5168d29852a8a639aded6408f2e" }, + "respond-to-pr-comments-in-blocklist": { + "source": "warpdotdev/common-skills", + "sourceType": "github", + "skillPath": ".agents/skills/respond-to-pr-comments-in-blocklist/SKILL.md", + "computedHash": "f7408cf90c10397aa9048f14ab985a138641fc1e5f3245e290150437d62875f0" + }, "review-pr": { "source": "warpdotdev/common-skills", "sourceType": "github", @@ -71,7 +77,7 @@ "source": "warpdotdev/common-skills", "sourceType": "github", "skillPath": ".agents/skills/spec-driven-implementation/SKILL.md", - "computedHash": "e334d0f6f0e8fc39055314acad911f36d92d1919372b5e2973cc99d7f8c901b4" + "computedHash": "45793ca1e35b032ddfd2596f2e86fd6f6e938549373bfe4aeb74683486a179e4" }, "update-skill": { "source": "warpdotdev/common-skills", @@ -89,7 +95,7 @@ "source": "warpdotdev/common-skills", "sourceType": "github", "skillPath": ".agents/skills/write-tech-spec/SKILL.md", - "computedHash": "3b5eb4ef021112d473984eca28412d372e87d9337ad5d9754f3ad3e01f94d39b" + "computedHash": "c7913bfd1ea2be7ce38d5beb7e923b96f5689f6145250af1d81b985e8be4a882" } } } From 7761166ced52ceed6209c842beb49154f39012b8 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Sat, 30 May 2026 16:40:06 -0600 Subject: [PATCH 40/48] Align warpctrl specs with first slice implementation Co-Authored-By: Oz <oz-agent@warp.dev> --- app/src/settings/local_control.rs | 2 +- app/src/settings_view/scripting_page.rs | 11 ++++- script/linux/bundle | 21 ++++++++- script/macos/bundle | 31 +++++++++++-- specs/warp-control-cli/PRODUCT.md | 18 ++++---- specs/warp-control-cli/README.md | 60 +++++++++++++++---------- specs/warp-control-cli/SECURITY.md | 16 +++---- specs/warp-control-cli/TECH.md | 32 ++++++------- 8 files changed, 128 insertions(+), 63 deletions(-) diff --git a/app/src/settings/local_control.rs b/app/src/settings/local_control.rs index ce930fc9e1..90c0beec7d 100644 --- a/app/src/settings/local_control.rs +++ b/app/src/settings/local_control.rs @@ -54,7 +54,7 @@ impl LocalControlMode { match self { Self::Disabled => "Disabled", Self::EnabledWithinWarp => "Enabled within Warp", - Self::EnabledEverywhere => "Enabled everywhere", + Self::EnabledEverywhere => "Enabled everywhere, including outside Warp", } } } diff --git a/app/src/settings_view/scripting_page.rs b/app/src/settings_view/scripting_page.rs index d3b3bc35ba..0d07cc30cc 100644 --- a/app/src/settings_view/scripting_page.rs +++ b/app/src/settings_view/scripting_page.rs @@ -15,6 +15,7 @@ use settings::Setting as _; use std::cell::RefCell; use std::collections::HashMap; use warpui::elements::{Element, MouseStateHandle}; +use warpui::ui_components::components::UiComponent; use warpui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle}; #[derive(Clone, Debug, PartialEq)] @@ -155,11 +156,19 @@ impl SettingsWidget for LocalControlModeWidget { appearance: &Appearance, app: &AppContext, ) -> Box<dyn Element> { + let dropdown_subtext = appearance + .ui_builder() + .wrappable_text( + "Controls whether warpctrl can receive local-control credentials. Disabled blocks all invocation contexts. Enabled within Warp is reserved for verified Warp terminals once proof support lands and rejects requests today. Enabled everywhere, including outside Warp also allows local apps and scripts to request credentials.", + true, + ) + .build() + .finish(); render_dropdown_item( appearance, "warpctrl", Some("warpctrl CLI scripting"), - None, + Some(dropdown_subtext), LocalOnlyIconState::for_setting( LocalControlModeSetting::storage_key(), LocalControlModeSetting::sync_to_cloud(), diff --git a/script/linux/bundle b/script/linux/bundle index 9fea2b6f57..19c4fc03a9 100755 --- a/script/linux/bundle +++ b/script/linux/bundle @@ -190,8 +190,8 @@ if [[ "$ARTIFACT" == "cli" ]]; then BINARY_NAME="${BINARY_NAME/warp/oz}" fi elif [[ "$ARTIFACT" == "warpctrl" ]]; then - WARP_BIN="warpctrl" BINARY_NAME="warpctrl" + PACKAGES=() fi # Artifact-specific configuration @@ -244,6 +244,23 @@ else echo 'Skipping `cargo build` step due to --skip-build argument' fi +if [[ "$ARTIFACT" == "warpctrl" ]]; then + echo "Copying control-mode binary into $OUT_DIR/$WARP_BIN" + cp "$EXECUTABLE_PATH" "$OUT_DIR/$WARP_BIN" + WARPCTRL_SCRIPT_PATH="$OUT_DIR/warpctrl" + echo "Creating warpctrl wrapper script at $WARPCTRL_SCRIPT_PATH" + cat > "$WARPCTRL_SCRIPT_PATH" << EOF +#!/usr/bin/env bash +script_dir="\$(cd "\$(dirname "\${BASH_SOURCE[0]}")" && pwd)" +exec "\$script_dir/$WARP_BIN" --warpctrl "\$@" +EOF + chmod +x "$WARPCTRL_SCRIPT_PATH" +fi +BINARY_PATH="$EXECUTABLE_PATH" +if [[ "$ARTIFACT" == "warpctrl" ]]; then + BINARY_PATH="$WARPCTRL_SCRIPT_PATH" +fi + # Prepare bundled resources for CLI builds. if [[ "$ARTIFACT" == "cli" || "$ARTIFACT" == "warpctrl" ]]; then echo "Preparing CLI resources directory" @@ -257,7 +274,7 @@ fi # as the directory containing all built packages. if [ "${GITHUB_ACTIONS}" == "true" ]; then echo "::echo::on" - echo "executable_path=$EXECUTABLE_PATH" >> "$GITHUB_OUTPUT" + echo "executable_path=$BINARY_PATH" >> "$GITHUB_OUTPUT" echo "debug_executable_path=$DEBUG_EXECUTABLE_PATH" >> "$GITHUB_OUTPUT" echo "packages_dir=$OUT_DIR" >> "$GITHUB_OUTPUT" echo "bundled_resources_dir=${BUNDLED_RESOURCES_DIR:-}" >> "$GITHUB_OUTPUT" diff --git a/script/macos/bundle b/script/macos/bundle index 25289f4738..0c448ca951 100755 --- a/script/macos/bundle +++ b/script/macos/bundle @@ -315,9 +315,6 @@ elif [[ $RELEASE_CHANNEL = "oss" ]]; then # (which would otherwise pull in the Sentry framework dependency). FEATURES="release_bundle,extern_plist" fi -if [[ "$ARTIFACT" == "warpctrl" ]]; then - WARP_BIN="warpctrl" -fi OUT_DIR="target/$TARGET_PROFILE_DIR/bundle/osx" DOCK_TILE_PLUGIN_DIR="target/$TARGET_PROFILE_DIR/WarpDockTilePlugin.docktileplugin" @@ -561,6 +558,16 @@ EOF # Make the script executable chmod +x "$CLI_SCRIPT_PATH" + WARPCTRL_SCRIPT_PATH="$BUNDLED_RESOURCES_DIR/bin/warpctrl" + echo "Creating warpctrl wrapper script..." + cat > "$WARPCTRL_SCRIPT_PATH" << 'EOF' +#!/bin/bash +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec -a "$0" "$script_dir/../../MacOS/WARP_BIN_PLACEHOLDER" --warpctrl "$@" +EOF + sed -i '' "s/WARP_BIN_PLACEHOLDER/$WARP_BIN/" "$WARPCTRL_SCRIPT_PATH" + chmod +x "$WARPCTRL_SCRIPT_PATH" + # Store the built artifact locations for GitHub Actions outputs. BINARY_PATH="target/$DEFAULT_TARGET/$TARGET_PROFILE_DIR/$WARP_BIN" DMG_PATH="$OUT_DIR/$FINAL_DMG_NAME" @@ -591,6 +598,18 @@ elif [[ "$ARTIFACT" == "cli" || "$ARTIFACT" == "warpctrl" ]]; then echo "Copying binary into $OUT_DIR/$WARP_BIN" cp "target/$DEFAULT_TARGET/$TARGET_PROFILE_DIR/$WARP_BIN" "$OUT_DIR/$WARP_BIN" + if [[ "$ARTIFACT" == "warpctrl" ]]; then + WARPCTRL_SCRIPT_PATH="$OUT_DIR/warpctrl" + echo "Creating warpctrl wrapper script at $WARPCTRL_SCRIPT_PATH" + cat > "$WARPCTRL_SCRIPT_PATH" << 'EOF' +#!/bin/bash +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec -a "$0" "$script_dir/WARP_BIN_PLACEHOLDER" --warpctrl "$@" +EOF + sed -i '' "s/WARP_BIN_PLACEHOLDER/$WARP_BIN/" "$WARPCTRL_SCRIPT_PATH" + chmod +x "$WARPCTRL_SCRIPT_PATH" + fi + if [[ -n "$TARGET_ARCH" && -e "target/$DEFAULT_TARGET/$TARGET_PROFILE_DIR/$WARP_BIN.dSYM" ]]; then echo "Copying .dSYM into $OUT_DIR/$WARP_BIN.dSYM" cp -HR "target/$DEFAULT_TARGET/$TARGET_PROFILE_DIR/$WARP_BIN.dSYM" "$OUT_DIR/" @@ -602,7 +621,11 @@ elif [[ "$ARTIFACT" == "cli" || "$ARTIFACT" == "warpctrl" ]]; then # Set the primary binary path to output. - BINARY_PATH="$OUT_DIR/$WARP_BIN" + if [[ "$ARTIFACT" == "warpctrl" ]]; then + BINARY_PATH="$OUT_DIR/warpctrl" + else + BINARY_PATH="$OUT_DIR/$WARP_BIN" + fi else echo "Unsupported artifact: $ARTIFACT" >&2 exit 1 diff --git a/specs/warp-control-cli/PRODUCT.md b/specs/warp-control-cli/PRODUCT.md index be59472748..4289ab7ff8 100644 --- a/specs/warp-control-cli/PRODUCT.md +++ b/specs/warp-control-cli/PRODUCT.md @@ -29,16 +29,16 @@ Human power-user scripting is a secondary beneficiary of the same design. Script Persistent settings changes, Warp Drive creation or sharing, cross-app preference migration, terminal command execution, and other underlying-data mutations must be visibly reviewable or require stronger explicit permission than low-risk workspace organization. `warpctrl` should support full typed control over time, but each command must be progressively unlocked through action categories, target resolution, Agent Profile permissions, Scripting settings, and authenticated-user requirements rather than broad unchecked authority. ## Behavior 1. The Warp control CLI operates only on running local Warp app processes. If no compatible Warp process is available, the CLI exits non-zero with a clear “no running Warp instance found” error. -2. The CLI exposes only explicitly allowlisted actions. Unknown action names, unsupported parameter combinations, or requests for non-allowlisted capabilities fail with structured errors; they are never forwarded to arbitrary internal dispatch. +2. The CLI exposes only explicitly allowlisted actions. Protocol-level unknown action names, unsupported local-control parameter combinations, or requests for non-allowlisted capabilities fail with structured local-control errors; they are never forwarded to arbitrary internal dispatch. Clap parser usage errors, such as an unknown CLI subcommand or invalid flag syntax, may use the parser's normal CLI error behavior unless a later branch explicitly wraps them. 3. Every successful mutating request identifies: - The Warp process instance that executed it. - The resolved target, when the action addresses a window, tab, pane, terminal session, terminal block, file, Warp Drive object, surface, or other targetable noun. - A success payload suitable for JSON output. -4. Every failure identifies: +4. Every protocol or runtime local-control failure identifies: - A stable machine-readable error code. - A human-readable explanation. - Any selector that was ambiguous, missing, stale, unsupported, or invalid. -5. The CLI supports human-readable output by default and JSON output for scripts. JSON output has stable field names and is available for discovery commands, read commands, successful mutations, and failures. +5. The CLI supports human-readable output by default and JSON output for scripts. JSON output has stable field names and is available for discovery commands, read commands, successful mutations, and protocol or runtime local-control failures. 6. The CLI supports process discovery and instance selection: - `warpctrl instance list` returns all reachable local Warp app processes that support the protocol. - Each process has an opaque `instance_id`, a channel/build identity, and enough display metadata for a developer to choose it. @@ -159,6 +159,8 @@ Persistent settings changes, Warp Drive creation or sharing, cross-app preferenc Terminal command execution and typed Warp Drive object mutations are no longer excluded from the full product scope, but they belong to later authenticated underlying-data mutation branches and require stronger authenticated-user and permission gates than app-state or settings mutations. Local file content reads, writes, appends, deletes, and other filesystem-content mutations are excluded from the public `warpctrl` catalog; file/path support is limited to app-state intents such as opening a path in Warp and metadata reads of files already open in Warp. 27. CLI command names should be noun-oriented and discoverable. During the provisional wrapper-script phase, the control CLI should expose a `warpctrl ...` command surface: - `warpctrl instance list` + - `warpctrl app ping` + - `warpctrl app version` - `warpctrl app active` - `warpctrl tab create` - `warpctrl tab rename --window-id <window_id> --tab-id <tab_id> "Build logs"` @@ -209,7 +211,7 @@ The product surface must distinguish what kind of state a command touches. This - **Underlying data mutations** can change user data or cause external side effects: typed CRUD operations on Warp Drive objects, sharing Warp Drive objects to the user's team through an explicit approved command, inserting content into Warp Drive views, running allowlisted Warp Drive workflows, and running terminal commands through an explicit `input run` action. Accepted-command submission, agent-prompt submission, local file content mutation, arbitrary workflow execution, and arbitrary internal dispatch remain excluded until separately reviewed. A command that touches multiple categories must require the strongest applicable permission. For example, `file open` is an app-state mutation because it opens a visible Warp editor/view, while `input run` is an underlying data mutation because it executes a command in the target session. ### Targeting flags -All commands that address a running app target accept the same selector flags where meaningful. Generic `--window`, `--tab`, `--pane`, `--session`, and `--block` flags accept the selector grammar below; explicit typed aliases are provided so scripts can avoid string parsing ambiguity: +The full product should converge on shared selector flags for every command that addresses a running app target. The current foundation branch is not required to expose that complete CLI grammar yet: it supports instance selection with `--instance` and `--pid` for the implemented commands, while the shared window/tab/pane/session/block selector flags are deferred to the later target-selector branch that implements those target families. When the shared grammar ships, generic `--window`, `--tab`, `--pane`, `--session`, and `--block` flags accept the selector grammar below; explicit typed aliases are provided so scripts can avoid string parsing ambiguity: - `--instance <instance_id>` selects a running Warp process from `warpctrl instance list`. - `--pid <pid>` is a convenience instance selector and conflicts with `--instance`. - `--window <active|id:<id>|index:<n>|title:<title>>` selects a window inside the instance. @@ -444,15 +446,15 @@ The CLI should expose auth/status flows for both modes: This authenticated scripting protocol applies only to actions whose allowlist entry requires a true logged-in Warp user, external API-key identity, or underlying-data-mutation authority. Logged-out-safe local actions continue to use local-control credentials without requiring Warp account login or API-key setup. ### Execution context policy `warpctrl` should eventually distinguish verified invocations from inside Warp-managed terminal sessions from external invocations. The current foundation branch implements the setting shape for both contexts, supports external invocation only when the user explicitly enables the broadest mode, and must reject verified Warp-terminal claims until the proof broker is implemented. -- **Verified Warp-terminal invocation:** a `warpctrl` process started inside a Warp-managed terminal session and able to present an app-issued execution-context proof. This is allowed by the default **Enabled within Warp** mode once the proof broker exists. When the selected app has a logged-in Warp user, this context can receive authenticated-user grants if the selected mode allows the context and the action's catalog policy allows that grant. +- **Verified Warp-terminal invocation:** a `warpctrl` process started inside a Warp-managed terminal session and able to present an app-issued execution-context proof. This is allowed when the user selects **Enabled within Warp** or the broadest mode after the proof broker exists; the default disabled mode blocks it. When the selected app has a logged-in Warp user, this context can receive authenticated-user grants if the selected mode allows the context and the action's catalog policy allows that grant. - **External invocation:** a `warpctrl` process started outside Warp's terminal, such as from another terminal app, launch agent, IDE, or background script. This is allowed only by **Enabled everywhere, including outside Warp**. When disabled for the selected mode, external invocations receive no local-control credentials, including logged-out-safe metadata credentials. - The app must not trust a caller-declared label. Environment variables may help discover the context, but the broker must verify a session-bound capability or equivalent proof before issuing in-Warp-only grants. ### Settings surface Warp should add a new top-level Settings pane page named **Scripting**. This page should own settings for local scripting and automation surfaces, including Warp control. Warp control should be represented as a single private, local-only mode setting with three choices: -- **Disabled:** no local-control invocation context can receive credentials. -- **Enabled within Warp:** default. Allows only verified Warp-managed terminal invocations once the proof broker exists. In the current foundation branch, inside-Warp proof verification is not implemented yet, so requests in this mode are rejected rather than silently treated as external. +- **Disabled:** default. No local-control invocation context can receive credentials. +- **Enabled within Warp:** allows only verified Warp-managed terminal invocations once the proof broker exists. In the current foundation branch, inside-Warp proof verification is not implemented yet, so requests in this mode are rejected rather than silently treated as external. - **Enabled everywhere, including outside Warp:** allows verified Warp-managed terminal invocations and external local clients such as other terminals, scripts, IDEs, launch agents, and same-user automation to request local-control credentials. -The Scripting page should explain that the default mode scopes control to Warp-managed terminals, while the broadest mode allows other local apps and scripts to talk to Warp's control plane. Changing the mode should invalidate or prevent credentials for invocation contexts no longer allowed by the selected mode. +The Scripting page should explain that the default mode blocks local-control credentials, the within-Warp mode is reserved for verified Warp-managed terminals once proof support lands, and the broadest mode allows other local apps and scripts to talk to Warp's control plane. Changing the mode should invalidate or prevent credentials for invocation contexts no longer allowed by the selected mode. ### Local-control permission policy The Scripting settings page should not expose separate per-risk local-control toggles in the foundation stack. The single mode setting defines which invocation contexts may receive credentials. The app bridge still enforces each action's risk posture, state/data category, authenticated-user requirement, execution-context requirement, and target scope for every request. Enabling the broadest mode must not bypass catalog enforcement or imply permission to run actions that require authenticated scripting identity, logged-in user state, or future review. ### Agent Profile permissions diff --git a/specs/warp-control-cli/README.md b/specs/warp-control-cli/README.md index 405b76da68..752f9f98d5 100644 --- a/specs/warp-control-cli/README.md +++ b/specs/warp-control-cli/README.md @@ -3,7 +3,8 @@ The first implementation slice is intentionally narrow: - discover compatible running Warp instances; - select one instance implicitly when unambiguous or explicitly with `--instance`; -- send authenticated local-control requests through the per-instance discovery record; +- request brokered scoped local-control credentials for the selected instance; +- check app health and protocol compatibility with `warpctrl app ping` and `warpctrl app version`; - create a new terminal tab with `warpctrl tab create`. The local-control protocol and catalog are broader than this slice, but commands outside the implemented capability set should fail with structured unsupported-action errors until their handlers land. ## Packaging model @@ -14,20 +15,20 @@ The local-control protocol and catalog are broader than this slice, but commands - the app-side bridge owns the per-process loopback listener and dispatches supported actions onto the live Warp UI context. The control-mode path should initialize only the work needed for CLI parsing, instance discovery, local authentication loading, request serialization, HTTP transport, and output formatting. It should not initialize GUI state, terminal models, rendering, workspaces, or main-app startup paths. During the provisional naming period, release artifacts and helper names may be channelized, but operator docs and examples should use `warpctrl` unless an integration branch explicitly documents a channel-specific alias. -This branch wires the core hidden dispatch contract through the existing Warp binary. Follow-up packaging work should install platform-specific wrapper scripts that call the channel binary with `--warpctrl` instead of producing or selecting a separate `warpctrl` artifact. +This branch wires the core hidden dispatch contract through the existing Warp binary. Platform packaging should create wrapper scripts that call the channel binary with `--warpctrl` instead of producing or selecting a separate `warpctrl` binary. ## Install and invocation guidance ### macOS -Until the wrapper installer lands, build the local Warp binary and invoke it with the hidden control-mode flag for development checks: +For local development checks, build the local Warp binary and invoke it with the hidden control-mode flag: ```bash cargo run -p warp --bin warp -- --warpctrl instance list ``` -For distributable checks, the installed `warpctrl` wrapper should live on `PATH` and exec the app bundle's channel-specific executable with `--warpctrl`. +For distributable checks, use the installed `warpctrl` wrapper. The wrapper execs the app bundle's channel-specific executable with `--warpctrl`. ### Linux -Until the wrapper installer lands, build the local Warp binary and invoke it with the hidden control-mode flag for development checks: +For local development checks, build the local Warp binary and invoke it with the hidden control-mode flag: ```bash cargo run -p warp --bin warp -- --warpctrl instance list ``` -For distributable checks, downstream packages should install a `warpctrl` wrapper onto `PATH` that execs the installed channel-specific Warp executable with `--warpctrl`. +For distributable checks, use the packaged `warpctrl` wrapper. The wrapper execs the packaged channel-specific Warp executable with `--warpctrl`. Run `warpctrl --version` after installation to confirm the shell is resolving the expected build. ### Windows Until the wrapper installer lands, build the local Warp binary and invoke it with the hidden control-mode flag for development checks: @@ -42,16 +43,23 @@ Use matching app and CLI bits from the same branch or release artifact so the pr ```bash warpctrl instance list ``` -3. If exactly one compatible instance is listed, create a new terminal tab: +3. Confirm app health and protocol compatibility: + ```bash + warpctrl app ping + warpctrl app version + ``` +4. If exactly one compatible instance is listed, create a new terminal tab: ```bash warpctrl tab create ``` -4. If multiple compatible instances are listed, copy the desired `instance_id` and target it explicitly: +5. If multiple compatible instances are listed, copy the desired `instance_id` and target it explicitly: ```bash + warpctrl app ping --instance <instance_id> + warpctrl app version --instance <instance_id> warpctrl tab create --instance <instance_id> ``` -5. Verify the running app receives focus for the selected instance and a new terminal tab appears according to Warp's normal new-tab placement behavior. -6. In a future slice that implements `tab list`, inspect state before and after the mutation: +6. Verify the running app receives focus for the selected instance and a new terminal tab appears according to Warp's normal new-tab placement behavior. +7. In a future slice that implements `tab list`, inspect state before and after the mutation: ```bash warpctrl tab list --instance <instance_id> ``` @@ -63,37 +71,41 @@ Expected failures: ## Security model The local-control protocol is designed for same-user scripting, not cross-user or network access. The trust boundary is the local user account. - **Loopback-only listener.** Each Warp process binds its control server to `127.0.0.1` on an ephemeral port. The listener is not reachable from the network. -- **Per-instance bearer token.** A random token is generated at startup and written into the discovery record. Every control request must present this token in the `Authorization` header; missing or invalid tokens are rejected with HTTP 401. -- **File-permission-gated discovery.** Discovery records are stored in a per-user local-control directory. On POSIX platforms, files must be created with `0600` permissions (owner read/write only). On Windows, records must be stored under the current user's app data directory with an ACL that grants access only to the current user, Administrators, and SYSTEM. Any same-user process that can read the credential can authenticate, so the baseline security boundary is same-user process isolation. -- **Stale-record pruning.** On each `instance list` or implicit discovery call, records whose PID is no longer alive are deleted automatically, preventing stale tokens from lingering on disk. -- **No CORS.** The control endpoints do not set permissive CORS headers, so browser-origin JavaScript cannot read responses even if it guesses the port. The bearer token requirement provides a second layer since browsers cannot read the discovery file. +- **Brokered scoped credentials.** Discovery records contain instance metadata, endpoint information, and credential-broker references only when the selected Scripting mode allows that invocation context. They must not contain raw bearer tokens or reusable full-access credentials. +- **Short-lived grants.** `warpctrl` requests an action-scoped credential from `/v1/control/credentials` for the selected instance and invocation context, then presents that credential to `/v1/control`. Missing, invalid, expired, revoked, or insufficient-scope credentials are rejected before handler dispatch. +- **Protected credential material.** Raw local-control secrets live in platform secure storage where available, with owner-only local-state fallbacks documented as weaker. On POSIX platforms, discovery records and fallback local state must use owner-only permissions. On Windows, records must be stored under the current user's app data directory with an ACL that grants access only to the current user, Administrators, and SYSTEM. +- **Stale-record pruning.** On each `instance list` or implicit discovery call, records whose PID is no longer alive are deleted automatically, preventing stale endpoint or broker metadata from lingering on disk. +- **No CORS.** The control endpoints do not set permissive CORS headers, so browser-origin JavaScript cannot read responses even if it guesses the port. The credential requirement provides a second layer since browsers cannot read the brokered credential material. ```mermaid sequenceDiagram participant CLI as warpctrl participant FS as ~/.warp/local-control/ + participant Broker as Credential broker participant HTTP as Warp loopback server<br/>(127.0.0.1:ephemeral) participant Bridge as App bridge CLI->>FS: Read discovery records (user-only permissions / ACL) - FS-->>CLI: instance_id, endpoint, auth_token + FS-->>CLI: instance_id, endpoint, credential broker metadata CLI->>CLI: Prune stale PIDs, select instance - CLI->>HTTP: POST /v1/control<br/>Authorization: Bearer <token> - HTTP->>HTTP: Verify token matches instance - alt Invalid or missing token - HTTP-->>CLI: 401 Unauthorized - else Valid token + CLI->>Broker: POST /v1/control/credentials<br/>action + context + instance + Broker->>Broker: Check Settings > Scripting mode, context, scopes + alt Disabled, invalid, or insufficient scope + Broker-->>CLI: Structured denial + else Grant allowed + Broker-->>CLI: Short-lived scoped credential + CLI->>HTTP: POST /v1/control<br/>Authorization: Bearer <scoped credential> + HTTP->>HTTP: Verify grant and action scope HTTP->>Bridge: Dispatch action to app context Bridge-->>HTTP: Structured result or error HTTP-->>CLI: JSON response envelope end ``` **Known limitations and future hardening:** -- The token is stored in plaintext in the discovery JSON file. Any compromised process running as the same user can extract it. -- Tokens do not rotate or expire during a Warp session. A leaked token is valid until the process exits. - Windows local-control authentication is not complete until discovery-record ACL creation and validation are implemented. -- Once higher-risk handlers land (e.g. `input.insert`, command execution), the same-user boundary becomes a code-execution trust boundary. Consider separating the token from the discovery metadata, adding per-request nonces, or switching to a Unix domain socket with `SO_PEERCRED` for kernel-verified caller identity. +- Same-user malicious software can still invoke trusted wrappers or automate the desktop, so brokered credentials are least-privilege guardrails rather than a complete hostile same-user sandbox. +- Once higher-risk handlers land, the same-user boundary becomes more sensitive. Consider per-request nonces, stricter platform secure-storage constraints, or Unix domain sockets with `SO_PEERCRED` for stronger caller identity where available. ## Documentation review notes - Treat `warpctrl` as provisional executable naming until packaging signs off on final artifact aliases. -- Keep examples scoped to discovery and `tab create` until additional app-side handlers are implemented. +- Keep examples scoped to discovery, app health/version, and `tab create` until additional app-side handlers are implemented. - Do not document catalog commands as usable just because they exist in protocol enums or parser scaffolding; operator docs should distinguish implemented commands from planned allowlist entries. - Windows packaging may initially follow the existing helper-wrapper pattern. Update this README when that decision is final. diff --git a/specs/warp-control-cli/SECURITY.md b/specs/warp-control-cli/SECURITY.md index db58db40c4..112d4a7ba9 100644 --- a/specs/warp-control-cli/SECURITY.md +++ b/specs/warp-control-cli/SECURITY.md @@ -4,7 +4,7 @@ The correct architecture is not a single shared localhost bearer token with clie The action-category model is primarily a safety and intent mechanism, not a hard security boundary against malicious same-user software. It lets a user, script, or agent intentionally request metadata-only, data-read, app-state mutation, metadata/configuration mutation, or underlying-data mutation access so it does not accidentally mutate state, expose sensitive content, or execute commands. It should not be described as strong access control against a process that can already run arbitrary commands as the user. `warpctrl` has two distinct authorization dimensions: local-control authority and authenticated scripting authority. Local-control authority proves the request is allowed to control the local app. Authenticated scripting authority proves the Warp user or automation identity that is allowed to act on user-authenticated data such as Warp Drive objects, AI conversation traces, synced settings, cloud-backed user state, and execution-underlying actions. Logged-out users should retain a smaller local-only control surface, but authenticated-user and high-risk underlying-data mutation actions require either a verified Warp-terminal grant tied to the selected app's logged-in user or an external Warp-issued API-key grant. ## Current foundation status -The current foundation implementation stores a single local-control mode with three choices: disabled, enabled within Warp by default, and enabled everywhere including outside Warp. Verified inside-Warp invocation is specified as future work because the app-issued terminal-session proof broker, proof injection path, and session registry do not exist yet. Until those pieces land, `InvocationContext::InsideWarp` requests must be rejected with `execution_context_not_allowed`, implemented action metadata must not advertise inside-Warp support, and external requests must receive credentials only when the user selects the broadest mode. +The current foundation implementation stores a single local-control mode with three choices: disabled by default, enabled within Warp, and enabled everywhere including outside Warp. Verified inside-Warp invocation is specified as future work because the app-issued terminal-session proof broker, proof injection path, and session registry do not exist yet. Until those pieces land, `InvocationContext::InsideWarp` requests must be rejected with `execution_context_not_allowed`, implemented action metadata must not advertise inside-Warp support, and external requests must receive credentials only when the user selects the broadest mode. ## Security goals - Allow trusted local users and approved automation to control a running Warp instance through a stable, scriptable interface. - Prevent unauthenticated localhost clients from invoking read or mutating control actions. @@ -12,7 +12,7 @@ The current foundation implementation stores a single local-control mode with th - Support multiple running Warp processes without a shared global mutating port or global credential. - Separate discovery metadata from control authority so enumerating an instance does not automatically grant full control. - Require explicit in-app user enablement before local control scripting from outside Warp can issue credentials or accept control requests. -- Allow local control scripting from verified Warp-managed terminal sessions by default once proof verification exists, subject to the selected local-control mode and action policy. +- Allow local control scripting from verified Warp-managed terminal sessions once proof verification exists and the user selects a mode that permits that context, subject to action policy. - Store the authoritative local-control mode in protected local storage so external apps cannot enable outside-Warp control by editing ordinary settings. - Keep raw credential material out of plaintext discovery records and protect it with platform secure storage where available. - Distinguish verified `warpctrl` invocations that originate from a Warp-managed terminal session from external same-user invocations. @@ -91,8 +91,8 @@ Compared with these systems, `warpctrl` should combine: The resulting architecture should not claim to solve arbitrary same-user malicious-app isolation. Its real value is preventing web-origin and other-user access, preventing unauthenticated direct localhost calls, keeping raw credentials out of plaintext discovery, giving honest scripts and agents narrow safety grants, and routing high-risk operations through local Warp app validation and user/policy approval. ## Authoritative enablement model Warp control has one top-level mode setting based on invocation context: -- **Disabled:** no local-control invocation context can receive credentials. -- **Enabled within Warp:** default. Controls `warpctrl` invocations from verified Warp-managed terminal sessions once proof verification exists. +- **Disabled:** default. No local-control invocation context can receive credentials. +- **Enabled within Warp:** controls `warpctrl` invocations from verified Warp-managed terminal sessions once proof verification exists. - **Enabled everywhere, including outside Warp:** controls verified Warp-managed terminal invocations and external terminals, scripts, launch agents, IDEs, or other same-user processes. The mode should live in a new top-level Settings pane page named **Scripting**. The Scripting page owns the user-facing controls for local scripting surfaces, including Warp control, and should explain the difference between commands run inside Warp and commands run from other apps. The visible UI setting is not enough by itself. The authoritative mode must be stored in the most secure local storage provider available for the platform, with read/write access limited to the Warp application or Warp-owned trusted helper code where the platform supports that restriction. On macOS this means Keychain or an equivalent protected store constrained to Warp-signed code, not ordinary UserDefaults; on Windows this means Credential Manager, DPAPI-backed protected storage, or an equivalent app-controlled protected store; on Linux this means the platform secret service where available, with any owner-only file fallback explicitly documented as weaker. This avoids turning outside-Warp control into a feature that any process can silently enable before invoking `warpctrl`. @@ -102,7 +102,7 @@ Enablement requirements: - The implemented foundation setting must remain private and absent from user-visible settings files, generated schemas, local-control settings read/write commands, and any allowlisted settings mutation catalog. - Only the running Warp app, through the Settings > Scripting UI, should be able to change the authoritative mode. - `warpctrl`, shell scripts, config files, command-line flags, registry edits, defaults writes, and direct local-control protocol requests must not be able to enable or widen the mode. -- The default mode may allow verified Warp-terminal invocations, but turning the mode to disabled should prevent verified Warp-terminal invocations from receiving local-control grants. +- The enabled-within-Warp mode may allow verified Warp-terminal invocations once proof verification exists, but turning the mode to disabled should prevent verified Warp-terminal invocations from receiving local-control grants. - Outside-Warp control requires an intentional user gesture to select the broadest mode; the UI should explain that it allows scripts and automation from other apps to control Warp. - The mode should be easy to change from the same UI, and narrowing the mode should revoke or invalidate active local-control credentials for invocation contexts no longer allowed. - If enterprise or managed-device policy is added later, policy may force-disable the mode or force a narrower default, but policy should be separate from user-editable local settings. @@ -180,9 +180,9 @@ A valid credential for one instance or target must not imply authority over anot - Kernel, hypervisor, or administrator-level compromise. - Security semantics for remote URL control endpoints. Remote control requires a separate transport and identity design before it can ship. ## Architecture overview -The full security model has eight layers. The current foundation branch implements the single mode gate, allows outside-Warp credentials only in the broadest mode, and keeps the inside-Warp execution-context layer as a rejected future protocol concept until proof verification exists. +The full security model has eight layers. The current foundation branch implements the single mode gate with disabled as the default, allows outside-Warp credentials only in the broadest mode, and keeps the inside-Warp execution-context layer as a rejected future protocol concept until proof verification exists. The security model has eight layers: -1. **Protected enablement:** Use protected local storage for the single local-control mode, with inside-Warp allowed by default and outside-Warp off unless the broadest mode is selected. +1. **Protected enablement:** Use protected local storage for the single local-control mode, with all contexts disabled by default, inside-Warp allowed only when the user selects the within-Warp or broadest mode after proof support lands, and outside-Warp off unless the broadest mode is selected. 2. **Discovery:** Find compatible live Warp instances without granting broad authority. 3. **Secure credential storage:** Store raw secrets outside plaintext discovery records and restrict access to trusted Warp-owned code where the platform supports it. 4. **Execution context verification:** Distinguish verified Warp-terminal invocations from external same-user invocations without trusting caller-declared labels. @@ -475,7 +475,7 @@ Important errors include: The app must not downgrade these failures into broader default actions, and the CLI must preserve structured server errors in both human-readable and JSON output. ## Required controls before full catalog expansion Before shipping each action family, verify that these controls are implemented for that family: -- Local control scripting must be enabled for the request's invocation context before the action family can run; the default mode allows inside-Warp only once proof verification exists, and outside-Warp control requires the broadest mode. +- Local control scripting must be enabled for the request's invocation context before the action family can run; disabled mode blocks all contexts, the within-Warp mode allows inside-Warp only once proof verification exists, and outside-Warp control requires the broadest mode. - The authoritative mode lives under Settings > Scripting, is protected from external writes, and is local-only rather than synced. - The action has a documented state/data category and required permission category. - The action has a documented `requires_authenticated_user` value. New actions default to `true` unless explicitly reviewed as logged-out-safe. diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index 9b66bfa3c2..4ec52861ce 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -26,7 +26,7 @@ The most important constraint surfaced by this code is that the current fixed-po ### 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: -- Local control scripting has a single mode setting: disabled, enabled within Warp by default, and enabled everywhere including outside Warp. Inside-Warp control for verified Warp-managed terminal sessions can work only after the app-issued proof broker exists; outside-Warp control for external terminals, scripts, IDEs, launch agents, and other same-user processes requires the broadest mode. +- Local control scripting has a single mode setting: disabled by default, enabled within Warp, and enabled everywhere including outside Warp. Inside-Warp control for verified Warp-managed terminal sessions can work only after the app-issued proof broker exists; outside-Warp control for external terminals, scripts, IDEs, launch agents, and other same-user processes requires the broadest mode. - In the current foundation slice, the mode setting is implemented, outside-Warp credential requests are allowed only in the broadest mode, and inside-Warp credential requests must be rejected until proof verification exists. - The control lives under a new top-level Settings pane page named **Scripting**. - The authoritative mode is local-only, not Settings Sync'd, and stored in protected local storage rather than ordinary user-editable settings. @@ -279,14 +279,15 @@ Implementation references: - Pane scoping can build on the conceptual model of `PaneViewLocator` in `app/src/workspace/util.rs (12-18)`. - Existing URI intent routing in `app/src/uri/mod.rs (895-1093)` shows how to locate workspaces/windows and avoid silently acting in the wrong place. #### CLI selector grammar -`crates/warp_cli/src/local_control.rs` should expose a shared selector argument group that is flattened into every command that accepts app targets. The parser must support: +The current foundation branch only needs the instance selector flags that are implemented by the first-slice CLI: - Instance selectors: `--instance <instance_id>` and `--pid <pid>`, with clap conflicts. +The shared target selector CLI group for windows, tabs, panes, sessions, blocks, files, and Drive objects is deferred to the `zach/warp-cli-v2/readonly-capability-targets` branch, where those target families are first exposed through read-only metadata commands. That later branch should add: - Window selectors: `--window <active|id:<id>|index:<n>|title:<title>>`, `--window-id <id>`, `--window-index <n>`, and `--window-title <title>`, with one form allowed. - Tab selectors: `--tab <active|id:<id>|index:<n>|title:<title>>`, `--tab-id <id>`, `--tab-index <n>`, and `--tab-title <title>`, with one form allowed. - Pane selectors: `--pane <active|id:<id>|index:<n>>`, `--pane-id <id>`, and `--pane-index <n>`, with one form allowed. - Session selectors: `--session <active|id:<id>|index:<n>>`, `--session-id <id>`, and `--session-index <n>`, with one form allowed. - Block/file/Drive selectors only on commands that need them: `--block-id <id>`, path arguments or `--path <path>` plus `--line`/`--column`, and Drive object ID arguments or `--drive-id <id>`. -The CLI converts these flags into the protocol `TargetSelector` before sending the request. It must not rely on positional entity IDs for commands like `window close 1`; target entities are selected through the shared selector flags so command arguments remain reserved for action parameters. +As each selector family is added, the CLI converts those flags into the protocol `TargetSelector` before sending the request. It must not rely on positional entity IDs for commands like `window close 1`; target entities are selected through shared selector flags so command arguments remain reserved for action parameters. ### 8. Allowlisted handler families Use one handler module per action family. The protocol layer owns parsing/validation; handler modules own target resolution and delegation to existing app logic. Recommended modules/families: @@ -307,14 +308,14 @@ Recommended modules/families: - Warp Drive: - object listing/inspection/opening, object creation/update/delete/insert, opening the share dialog, the v0 personal-to-team share mutation, and typed workflow execution where supported. Do not use a generic “dispatch action by string” endpoint. Every handler should be reachable only through an explicit `ControlAction` variant. -#### WarpCtrlBehavior review gate +#### Future WarpCtrlBehavior review gate The public `ControlAction` catalog remains the source of truth for the wire protocol, CLI parser, permission metadata, generated documentation, and app bridge handlers. Internal app actions such as `WorkspaceAction`, `TerminalAction`, `PaneGroupAction`, settings actions, and future user-visible action enums must not become the public protocol directly because they can contain transient view locators, indices, debug-only variants, implementation-specific payloads, and unstable internal semantics. -To prevent drift between user-visible Warp behavior and the `warpctrl` catalog, every user-visible app action enum should implement a `WarpCtrlBehavior` review mapping. The mapping is a code-level forcing function, not an automatic exposure mechanism. It answers whether each internal app action is: +The exhaustive `WarpCtrlBehavior` review mapping is not a current foundation-branch requirement. It should land in a later action-review or `zach/warp-cli-v2/cli-catalog-docs` branch after the public catalog and generated docs surface are mature enough to enforce it consistently. Once added, the mapping is a code-level forcing function, not an automatic exposure mechanism. It answers whether each internal app action is: - `Exposed` through a specific public `ControlAction` kind. - `CoveredBy` an existing public `ControlAction` kind because several internal actions map to one stable CLI behavior. - `Excluded` with an explicit reason such as debug-only, unsafe/privileged, internal implementation detail, not user-visible, no deterministic targeting model, no stable public semantics, or prohibited in the initial public version. - `Deferred` with an explicit reason and tracking issue when the action might belong in `warpctrl` later but needs additional product, security, selector, or protocol design. -`WarpCtrlBehavior` implementations must use exhaustive matches without wildcard arms. Adding a new variant to a reviewed action enum should fail compilation until the developer or agent deliberately classifies its relationship to `warpctrl`. This mirrors the existing exhaustive-action-review style used by app-state saving decisions and makes “should this exist in Warp Control?” part of the ordinary code path for adding new user-visible actions. +Future `WarpCtrlBehavior` implementations must use exhaustive matches without wildcard arms. Adding a new variant to a reviewed action enum should fail compilation until the developer or agent deliberately classifies its relationship to `warpctrl`. This mirrors the existing exhaustive-action-review style used by app-state saving decisions and makes “should this exist in Warp Control?” part of the ordinary code path for adding new user-visible actions. Recommended shape: - Define a shared `WarpCtrlBehavior` trait in the local-control integration layer or another app-visible module that does not force the core `warpui::Action` blanket implementation to change. - Define review enums such as `WarpCtrlActionReview`, `WarpCtrlExclusionReason`, and `WarpCtrlDeferredReason`. @@ -326,7 +327,7 @@ The `warpui::Action` trait should not be extended for this purpose because it cu The first `warpctrl` implementation slice should land the minimum cross-cutting architecture plus a single representative tab mutation: - Shared protocol types and error envelopes. - `FeatureFlag::WarpControlCli` and Cargo feature `warp_control_cli`, with app-side runtime gating for settings, discovery, bridge registration, and local-control endpoints. -- New top-level Settings > Scripting page rendered only while `FeatureFlag::WarpControlCli` is enabled. The current foundation exposes one local-control mode with disabled, enabled within Warp, and enabled everywhere choices; verified inside-Warp proof acceptance is deferred until the proof broker exists. +- New top-level Settings > Scripting page rendered only while `FeatureFlag::WarpControlCli` is enabled. The current foundation exposes one local-control mode with disabled as the default, enabled within Warp as a reserved mode that rejects requests until proof support exists, and enabled everywhere as the only mode that allows outside-Warp credential requests. - Protected local-only mode storage where outside-Warp control defaults off unless the broadest mode is selected. - As an interim foundation step, the local-control mode lives in the typed `LocalControlSettings` group as a private setting with `SyncToCloud::Never`, an explicit private storage key, and no `toml_path`. This keeps it out of the user-visible settings file and generated settings schema while leaving the protected-storage migration as a required pre-ship hardening step. - Discovery registry and CLI instance selection. @@ -347,7 +348,7 @@ The PR should also introduce the shell-facing CLI command grammar that the remai ### 10. Follow-up slices: fill out the remaining protocol in parallel After the first slice validates discovery, auth, selector resolution, CLI syntax, and server-to-app execution, follow-up slices can add the remaining allowlisted catalog in parallelized action-family groups. The baseline code should make new action additions mostly additive: - Extend the macro-backed action catalog. -- Update the relevant `WarpCtrlBehavior` mappings for the internal app actions that implement, overlap with, exclude, or defer the behavior. +- Once the later review gate lands, update the relevant `WarpCtrlBehavior` mappings for the internal app actions that implement, overlap with, exclude, or defer the behavior. - Add typed params/results. - Add a handler. - Add validation/tests. @@ -368,7 +369,7 @@ The shipped product shape should be a bundled `warpctrl` wrapper script or helpe - Keep channelized naming consistent with the final product name decision; if non-stable channels need aliases, the aliases should still point at the same channel app binary. - Linux: - Prefer installing a small `warpctrl` wrapper or symlink/helper in the same package as the Warp app, routed to the packaged channel binary with `--warpctrl`. - - Do not add a separate `--artifact warpctrl` standalone release path unless a later product decision explicitly chooses independent CLI packages. + - Do not build a separate standalone Rust binary for `--artifact warpctrl`; if that artifact path exists, it must emit a wrapper plus the channel binary it forwards to. - Windows: - Mirror the existing installer-generated helper-wrapper pattern first. - If Windows cannot cheaply use a shell-script-style wrapper, generate the smallest possible helper that forwards to the installed channel binary with `--warpctrl` and preserves stdout/stderr behavior for scripts. @@ -386,14 +387,14 @@ Use raw git for the stack; do not use Graphite for these branches. The active durable review stack is the recovered `zach/warp-cli-v2/*` stack. This stack is the review architecture for the current implementation because it preserves the fan-in work while slicing it into branch-sized review boundaries. The older branch names in the pre-recovery plan are historical source material only and should not be used as the active PR stack. Spec ownership is part of the branch architecture. The only v2 branch that may intentionally change `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, or `README.md` is `zach/warp-cli-v2/contract-spec-sync`. After a spec change lands there, propagate it upward through every higher v2 branch with raw git rebases so those files remain byte-identical across the stack. Higher implementation branches must not make independent spec edits except when resolving a propagation conflict in a way that preserves the bottom-branch content. The intended v2 stack is: -1. `zach/warp-cli-v2/contract-spec-sync` — create from `origin/master`. It owns the product, technical, security, and README specs plus the shared contract/foundation: protocol crate shape, discovery/auth scaffolding, selector and error types, action metadata/catalog structure, Scripting settings surface, protected local-control settings, local-control server/bridge skeleton, `warpctrl` wrapper/control-mode entrypoint, packaging hooks, module split, `WarpCtrlBehavior` scaffolding, and the minimum first-slice smoke path needed to prove the end-to-end architecture. +1. `zach/warp-cli-v2/contract-spec-sync` — create from `origin/master`. It owns the product, technical, security, and README specs plus the shared contract/foundation: protocol crate shape, discovery/auth scaffolding, initial instance selector and error types, action metadata/catalog structure, Scripting settings surface, protected local-control settings, local-control server/bridge skeleton, `warpctrl` wrapper/control-mode entrypoint, packaging hooks, module split, and the minimum first-slice smoke path needed to prove the end-to-end architecture. 2. `zach/warp-cli-v2/auth-security` — create from `zach/warp-cli-v2/contract-spec-sync`. It owns authentication and security enforcement that applies across command families: scoped grants, execution-context policy, authenticated-user/API-key plumbing, denial paths, Settings > Scripting security controls, and tests proving high-risk actions cannot run without the required identity and permission category. 3. `zach/warp-cli-v2/readonly-capability-targets` — create from `zach/warp-cli-v2/auth-security`. It owns structural metadata reads, capability/action metadata, target selector parsing and resolution, opaque IDs, active window/tab/pane/session targeting, metadata-read permission checks, and read-only CLI output for these surfaces. It must not expose terminal output, input buffers, history, Drive object contents, or other underlying user data. 4. `zach/warp-cli-v2/appstate-file-drive-views` — create from `zach/warp-cli-v2/readonly-capability-targets`. It owns read-only app-state, file view, Drive view, block/input/history-style underlying-data read surfaces that are approved for the public catalog, along with the required underlying-data-read permission checks. It must keep local filesystem content reads/writes outside the v0 public catalog unless the specs are updated first. 5. `zach/warp-cli-v2/metadata-config-mutations` — create from `zach/warp-cli-v2/appstate-file-drive-views`. It owns metadata/configuration mutations: allowlisted settings changes, labels/titles/appearance/configuration updates, settings or surface-opening commands that are metadata/configuration rather than underlying-data mutations, and tests proving unallowlisted or private settings are rejected. 6. `zach/warp-cli-v2/drive-data-mutations` — create from `zach/warp-cli-v2/metadata-config-mutations`. It owns authenticated underlying-data mutations for Warp Drive objects, including typed object create/update/delete/insert and the approved v0 personal-to-team sharing path. It must use disposable resources in tests and must not implement local file content reads, writes, appends, deletes, or other filesystem-content mutations. 7. `zach/warp-cli-v2/execution-underlying` — create from `zach/warp-cli-v2/drive-data-mutations`. It owns authenticated execution-underlying actions such as `input.run` and typed workflow execution where supported. It must require underlying-data-mutation permission plus authenticated scripting identity, deterministic target resolution, audit records, and tests proving accepted-command submission and agent-prompt submission remain unavailable. -8. `zach/warp-cli-v2/cli-catalog-docs` — create from `zach/warp-cli-v2/execution-underlying`. It owns the final CLI/catalog/docs integration pass: generated or curated command catalog output, help/completion polish, user-facing docs, Agent skill updates, command-family documentation, and consistency checks that every advertised action has protocol metadata, permission metadata, parser coverage, handler coverage, and tests. +8. `zach/warp-cli-v2/cli-catalog-docs` — create from `zach/warp-cli-v2/execution-underlying`. It owns the final CLI/catalog/docs integration pass: generated or curated command catalog output, help/completion polish, user-facing docs, Agent skill updates, command-family documentation, future `WarpCtrlBehavior` action-review scaffolding if it has not landed earlier, and consistency checks that every advertised action has protocol metadata, permission metadata, parser coverage, handler coverage, and tests. 9. `zach/warp-cli-v2/fanin-finalize` — create from `zach/warp-cli-v2/cli-catalog-docs`. It owns fan-in cleanup only: conflict-resolution preservation, naming/format consistency, final test fixes, validation matrix updates, and integration fixes required for the recovered stack to compile and pass tests. It should not introduce broad new command families. Recommended raw-git setup for a clean local reconstruction: ```bash @@ -488,7 +489,7 @@ Map tests directly to `PRODUCT.md` behavior. - Authenticated-user tests proving user-authenticated actions fail without a logged-in app user or authenticated-user grant. - External API-key tests proving missing, invalid, expired, revoked, wrong-subject, and insufficient-scope keys fail before selector resolution or handler dispatch. - Settings > Scripting tests proving mode changes invalidate credentials and prevent new grants for invocation contexts no longer allowed. - - Structured-error tests for disabled, unauthenticated, expired, revoked, insufficient-scope, execution-context-denied, authenticated-user-required, authenticated-user-unavailable, unsupported, malformed, ambiguous, missing-target, stale-target, and invalid-selector requests. + - Structured-error tests for protocol and runtime local-control failures such as disabled, unauthenticated, expired, revoked, insufficient-scope, execution-context-denied, authenticated-user-required, authenticated-user-unavailable, unsupported, malformed local-control request payloads, ambiguous targets, missing targets, stale targets, and invalid selectors. Clap parser usage errors are allowed to follow the parser's normal CLI error behavior unless a later branch explicitly wraps them. - Behavior 1-6, 29-31: - Protocol version/unit tests. - Discovery-registry tests with zero, one, multiple, stale, and incompatible instance records. @@ -496,8 +497,9 @@ Map tests directly to `PRODUCT.md` behavior. - Behavior 7-13: - Selector-resolution unit tests for active, explicit ID, index, stale target, ambiguous target, and non-terminal session target. - Tests that no lower-level selector silently retargets after an explicit stale selector fails. - - CLI selector parsing tests for every generic and explicit alias form: `--window`, `--window-id`, `--window-index`, `--window-title`, `--tab`, `--tab-id`, `--tab-index`, `--tab-title`, `--pane`, `--pane-id`, `--pane-index`, `--session`, `--session-id`, and `--session-index`. - - CLI conflict tests proving only one selector form per entity family is accepted and that positional target IDs are rejected where the command expects selector flags. + - In the current foundation branch, CLI selector parsing tests only need to cover the implemented instance selectors `--instance` and `--pid` plus their conflict behavior. + - The `zach/warp-cli-v2/readonly-capability-targets` branch should add CLI selector parsing tests for every generic and explicit alias form it introduces: `--window`, `--window-id`, `--window-index`, `--window-title`, `--tab`, `--tab-id`, `--tab-index`, `--tab-title`, `--pane`, `--pane-id`, `--pane-index`, `--session`, `--session-id`, and `--session-index`. + - As each selector family lands, CLI conflict tests should prove only one selector form per entity family is accepted and that positional target IDs are rejected where the command expects selector flags. - Behavior 15-28: - Parser/serde tests for every first-slice `ControlAction` variant. - Router tests proving unknown/unallowlisted actions are rejected. @@ -518,7 +520,7 @@ Verification screenshots should make the cause and effect visible in a single im Before/after screenshots for visible mutations should preserve the same staggered layout so reviewers can compare the command context and UI state directly. If a single combined screenshot is not possible because of window-manager, display-size, or focus limitations, the verifier must capture paired screenshots with the same ordinal: one terminal-output screenshot that fully shows the command and output, and one UI screenshot that shows the resulting Warp state. The manifest entry should explain why the combined composition was not possible. Screenshots should not crop out the command, exit status, selected Warp target, or relevant visible UI effect. Before every computer-use scenario, the verifier must explicitly ask and answer, "What is the best way to show the impact of this CLI command?" The verifier should then put Warp into a state where the expected effect is clearly visible before running the command. For example, syntax-highlighting changes should start with recognizable text in the input editor that will visibly change; font-size and zoom changes should start with enough terminal text or UI chrome to compare scale; tab or pane rename/color commands should keep the affected tab or pane label visible; app-state mutation commands should make the target workspace, tab, pane, input box, or surface visible; and denial paths should show the relevant Settings > Scripting state or target state that makes the denial meaningful. Each manifest entry for a visible or user-facing command should describe the chosen proof setup, the expected visual effect, and any setup screenshot used to establish the before state. After each command that has a visible or user-facing result, the verifier must use computer vision on the captured screenshot or screen recording to inspect whether the visible Warp state matches the expected effect. The verifier should record the visual inspection result in the manifest, including unexpected UI changes, missing visual evidence, ambiguous screenshots, focus/onboarding artifacts, or differences between JSON success and the visible app state. JSON success alone is not sufficient for visible-effect validation; if the screenshot does not clearly prove the expected effect, the case should be marked failed or blocked with an explanation, even when the CLI response is successful. -The verifier must exercise every invocation context implemented by the branch under review. For the current foundation branch, inside-Warp verification means proving `InsideWarp` requests are rejected even though the default mode is enabled within Warp until proof verification exists. Once verified Warp-terminal support is implemented, the verifier must also run `warpctrl` from a terminal session inside the feature-flag-enabled Warp app and prove the app-issued execution-context proof is accepted and Settings > Scripting mode gates the invocation context. The outside-Warp path must run the packaged `warpctrl` wrapper from an external terminal or automation shell outside the built Warp app and prove outside-Warp control is default-off, then works only after selecting the broadest mode in Settings > Scripting. +The verifier must exercise every invocation context implemented by the branch under review. For the current foundation branch, inside-Warp verification means proving `InsideWarp` requests are rejected because proof verification does not exist yet, while the default disabled mode blocks all contexts until the user changes it. Once verified Warp-terminal support is implemented, the verifier must also run `warpctrl` from a terminal session inside the feature-flag-enabled Warp app and prove the app-issued execution-context proof is accepted and Settings > Scripting mode gates the invocation context. The outside-Warp path must run the packaged `warpctrl` wrapper from an external terminal or automation shell outside the built Warp app and prove outside-Warp control is default-off, then works only after selecting the broadest mode in Settings > Scripting. The verification matrix should cover every implemented command in `PRODUCT.md` at least once in JSON output mode. The verifier must capture a terminal-output screenshot for every command invocation, including successful calls, expected denials, unsupported stubs, and fail-closed policy gates. Where there is a visible UI effect, the verifier must also capture app UI screenshots after the command runs, and before the command when that is needed to prove a before/after state transition. At minimum: - read-only metadata commands show successful CLI output in a terminal screenshot and, for active/focus/list commands, a visible target that matches the output; - underlying data read commands show successful CLI output only when the underlying-data-read permission is enabled, plus terminal screenshots for disabled-permission denials; From 2c62160eef2eb4aafa1524d8ac7c91d5538fa007 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Sat, 30 May 2026 19:59:01 -0600 Subject: [PATCH 41/48] Polish warpctrl CLI settings row Co-Authored-By: Oz <oz-agent@warp.dev> --- app/src/settings_view/scripting_page.rs | 29 +++++++++---------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/app/src/settings_view/scripting_page.rs b/app/src/settings_view/scripting_page.rs index 0d07cc30cc..e2a28205d1 100644 --- a/app/src/settings_view/scripting_page.rs +++ b/app/src/settings_view/scripting_page.rs @@ -1,10 +1,10 @@ //! Settings UI for local scripting and Warp control permissions. use super::{ settings_page::{ - render_dropdown_item, LocalOnlyIconState, MatchData, PageType, SettingsPageMeta, + render_body_item, LocalOnlyIconState, MatchData, PageType, SettingsPageMeta, SettingsPageViewHandle, SettingsWidget, }, - SettingsSection, + SettingsSection, ToggleState, }; use crate::appearance::Appearance; use crate::features::FeatureFlag; @@ -14,8 +14,7 @@ use crate::view_components::{Dropdown, DropdownItem}; use settings::Setting as _; use std::cell::RefCell; use std::collections::HashMap; -use warpui::elements::{Element, MouseStateHandle}; -use warpui::ui_components::components::UiComponent; +use warpui::elements::{ChildView, Element, MouseStateHandle}; use warpui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle}; #[derive(Clone, Debug, PartialEq)] @@ -33,7 +32,7 @@ impl ScriptingSettingsPageView { pub fn new(ctx: &mut ViewContext<Self>) -> Self { let local_control_mode_dropdown = ctx.add_typed_action_view(|ctx| { let mut dropdown = Dropdown::new(ctx); - dropdown.set_top_bar_max_width(260.); + dropdown.set_top_bar_max_width(360.); dropdown }); Self::update_local_control_mode_dropdown(local_control_mode_dropdown.clone(), ctx); @@ -156,27 +155,19 @@ impl SettingsWidget for LocalControlModeWidget { appearance: &Appearance, app: &AppContext, ) -> Box<dyn Element> { - let dropdown_subtext = appearance - .ui_builder() - .wrappable_text( - "Controls whether warpctrl can receive local-control credentials. Disabled blocks all invocation contexts. Enabled within Warp is reserved for verified Warp terminals once proof support lands and rejects requests today. Enabled everywhere, including outside Warp also allows local apps and scripts to request credentials.", - true, - ) - .build() - .finish(); - render_dropdown_item( - appearance, - "warpctrl", - Some("warpctrl CLI scripting"), - Some(dropdown_subtext), + render_body_item::<ScriptingSettingsPageAction>( + "warpctrl CLI".into(), + None, LocalOnlyIconState::for_setting( LocalControlModeSetting::storage_key(), LocalControlModeSetting::sync_to_cloud(), &mut view.local_only_icon_tooltip_states.borrow_mut(), app, ), + ToggleState::Enabled, + appearance, + ChildView::new(&view.local_control_mode_dropdown).finish(), None, - &view.local_control_mode_dropdown, ) } } From 6bc74d369fc7bec28fc0754ea25f1fba332b72f4 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Wed, 3 Jun 2026 16:54:29 -0600 Subject: [PATCH 42/48] Address warpctrl review feedback Co-Authored-By: Oz <oz-agent@warp.dev> --- app/src/local_control/bridge.rs | 70 ++++++----------- app/src/local_control/handlers/metadata.rs | 4 - app/src/local_control/mod.rs | 89 +++++++++++++--------- app/src/local_control/mod_tests.rs | 12 ++- app/src/local_control/permissions.rs | 12 ++- app/src/settings/local_control.rs | 10 +-- app/src/settings/local_control_tests.rs | 49 +++++++++++- crates/local_control/src/auth.rs | 16 ++++ crates/local_control/src/auth_tests.rs | 30 ++++++++ crates/local_control/src/catalog.rs | 5 ++ script/macos/bundle | 16 ++-- specs/warp-control-cli/TECH.md | 4 +- 12 files changed, 213 insertions(+), 104 deletions(-) diff --git a/app/src/local_control/bridge.rs b/app/src/local_control/bridge.rs index 0b97f54255..37ac294a19 100644 --- a/app/src/local_control/bridge.rs +++ b/app/src/local_control/bridge.rs @@ -5,12 +5,13 @@ use ::local_control::auth::CredentialGrant; use ::local_control::{ ActionKind, ControlError, ErrorCode, InstanceId, RequestEnvelope, ResponseEnvelope, - PROTOCOL_VERSION, }; use warpui::{Entity, ModelContext, SingletonEntity}; use crate::local_control::handlers::{layout, metadata}; -use crate::local_control::permissions::{ensure_action_allowed, ensure_feature_enabled}; +use crate::local_control::permissions::{ + ensure_action_allowed, ensure_feature_enabled, ensure_protocol_version, +}; use crate::local_control::resolver::validate_action_params; /// WarpUI model that executes already-authenticated local-control actions. @@ -42,14 +43,8 @@ impl LocalControlBridge { if let Err(error) = ensure_feature_enabled() { return ResponseEnvelope::error(request.request_id, error); } - if request.protocol_version != PROTOCOL_VERSION { - return ResponseEnvelope::error( - request.request_id, - ControlError::new( - ErrorCode::ProtocolVersionUnsupported, - format!("unsupported protocol version {}", request.protocol_version), - ), - ); + if let Err(error) = ensure_protocol_version(request.protocol_version) { + return ResponseEnvelope::error(request.request_id, error); } if let Err(error) = validate_action_params(&request.action) { return ResponseEnvelope::error(request.request_id, error); @@ -69,46 +64,25 @@ impl LocalControlBridge { ), ); } + if let Err(error) = + ensure_action_allowed(grant.invocation_context, request.action.kind, ctx) + { + return ResponseEnvelope::error(request.request_id, error); + } match request.action.kind { - ActionKind::InstanceList => { - if let Err(error) = - ensure_action_allowed(grant.invocation_context, request.action.kind, ctx) - { - return ResponseEnvelope::error(request.request_id, error); - } - match metadata::instance(&self.instance_id) { - Ok(data) => ResponseEnvelope::ok(request.request_id, data), - Err(error) => ResponseEnvelope::error(request.request_id, error), - } - } - ActionKind::AppPing => { - if let Err(error) = - ensure_action_allowed(grant.invocation_context, request.action.kind, ctx) - { - return ResponseEnvelope::error(request.request_id, error); - } - match metadata::ping(&self.instance_id) { - Ok(data) => ResponseEnvelope::ok(request.request_id, data), - Err(error) => ResponseEnvelope::error(request.request_id, error), - } - } - ActionKind::AppVersion => { - if let Err(error) = - ensure_action_allowed(grant.invocation_context, request.action.kind, ctx) - { - return ResponseEnvelope::error(request.request_id, error); - } - match metadata::version(&self.instance_id) { - Ok(data) => ResponseEnvelope::ok(request.request_id, data), - Err(error) => ResponseEnvelope::error(request.request_id, error), - } - } + ActionKind::InstanceList => match metadata::instance(&self.instance_id) { + Ok(data) => ResponseEnvelope::ok(request.request_id, data), + Err(error) => ResponseEnvelope::error(request.request_id, error), + }, + ActionKind::AppPing => match metadata::ping(&self.instance_id) { + Ok(data) => ResponseEnvelope::ok(request.request_id, data), + Err(error) => ResponseEnvelope::error(request.request_id, error), + }, + ActionKind::AppVersion => match metadata::version(&self.instance_id) { + Ok(data) => ResponseEnvelope::ok(request.request_id, data), + Err(error) => ResponseEnvelope::error(request.request_id, error), + }, ActionKind::TabCreate => { - if let Err(error) = - ensure_action_allowed(grant.invocation_context, request.action.kind, ctx) - { - return ResponseEnvelope::error(request.request_id, error); - } match layout::create_terminal_tab(&self.instance_id, &request.target, ctx) { Ok(data) => ResponseEnvelope::ok(request.request_id, data), Err(error) => ResponseEnvelope::error(request.request_id, error), diff --git a/app/src/local_control/handlers/metadata.rs b/app/src/local_control/handlers/metadata.rs index cd69785238..616c43fe13 100644 --- a/app/src/local_control/handlers/metadata.rs +++ b/app/src/local_control/handlers/metadata.rs @@ -11,7 +11,6 @@ struct InstanceResponse<'a> { pid: u32, channel: String, app_id: String, - app_version: Option<&'static str>, protocol_version: u32, actions: Vec<ActionMetadata>, } @@ -31,7 +30,6 @@ struct VersionResponse<'a> { protocol_version: u32, channel: String, app_id: String, - app_version: Option<&'static str>, } pub(crate) fn instance( @@ -43,7 +41,6 @@ pub(crate) fn instance( pid: std::process::id(), channel: ChannelState::channel().to_string(), app_id: ChannelState::app_id().to_string(), - app_version: ChannelState::app_version(), protocol_version: PROTOCOL_VERSION, actions: ActionKind::implemented_metadata(), }) @@ -65,7 +62,6 @@ pub(crate) fn version(instance_id: &Option<InstanceId>) -> Result<serde_json::Va protocol_version: PROTOCOL_VERSION, channel: ChannelState::channel().to_string(), app_id: ChannelState::app_id().to_string(), - app_version: ChannelState::app_version(), }) } diff --git a/app/src/local_control/mod.rs b/app/src/local_control/mod.rs index 6c4debd170..1f0226f929 100644 --- a/app/src/local_control/mod.rs +++ b/app/src/local_control/mod.rs @@ -34,7 +34,7 @@ use ::local_control::auth::{CredentialGrant, CredentialRequest, ScopedCredential use ::local_control::{ ActionKind, AuthToken, ControlEndpoint, ControlError, ControlResponse, ErrorCode, ErrorResponseEnvelope, InstanceId, InstanceRecord, RegisteredInstance, RequestEnvelope, - ResponseEnvelope, PROTOCOL_VERSION, + ResponseEnvelope, }; use axum::extract::rejection::JsonRejection; use axum::extract::State; @@ -48,7 +48,7 @@ use warp_core::channel::ChannelState; use warpui::{Entity, ModelContext, ModelSpawner, SingletonEntity}; pub use bridge::LocalControlBridge; -use permissions::{ensure_action_allowed, ensure_feature_enabled}; +use permissions::{ensure_action_allowed, ensure_feature_enabled, ensure_protocol_version}; /// Shared state made available to Axum handlers for one localhost server /// running inside Warp. @@ -74,40 +74,58 @@ impl SingletonEntity for LocalControlServer {} impl LocalControlServer { pub fn new(ctx: &mut ModelContext<Self>) -> Self { - if !permissions::warp_control_cli_enabled() { - return Self { - _runtime: None, - control_endpoint: None, - registered_instance: None, - }; + let mut server = Self { + _runtime: None, + control_endpoint: None, + registered_instance: None, + }; + if let Err(error) = server.refresh_for_settings(ctx) { + log::warn!("Failed to refresh local-control server state: {error:#}"); } - match Self::start(ctx) { - Ok(server) => { - ctx.subscribe_to_model( - &crate::settings::LocalControlSettings::handle(ctx), - |server, _, ctx| { - if let Err(error) = server.refresh_discovery_record(ctx) { - log::warn!( - "Failed to refresh local-control discovery record: {error:#}" - ); - } - }, - ); - server - } - Err(error) => { - log::warn!("Failed to start local-control server: {error:#}"); - Self { - _runtime: None, - control_endpoint: None, - registered_instance: None, + ctx.subscribe_to_model( + &crate::settings::LocalControlSettings::handle(ctx), + |server, _, ctx| { + if let Err(error) = server.refresh_for_settings(ctx) { + log::warn!("Failed to refresh local-control server state: {error:#}"); } - } + }, + ); + server + } + + fn refresh_for_settings(&mut self, ctx: &mut ModelContext<Self>) -> Result<(), ControlError> { + if !permissions::warp_control_cli_enabled() { + self.stop(); + return Ok(()); } + let outside_warp_control_enabled = + crate::settings::LocalControlSettings::as_ref(ctx).outside_warp_control_enabled(); + if !outside_warp_control_enabled { + self.stop(); + return Ok(()); + } + if self._runtime.is_some() { + return self.refresh_discovery_record(ctx); + } + *self = Self::start(ctx)?; + Ok(()) + } + + fn stop(&mut self) { + self.registered_instance = None; + self.control_endpoint = None; + self._runtime = None; } fn start(ctx: &mut ModelContext<Self>) -> Result<Self, ControlError> { ensure_feature_enabled()?; + if !crate::settings::LocalControlSettings::as_ref(ctx).outside_warp_control_enabled() { + return Ok(Self { + _runtime: None, + control_endpoint: None, + registered_instance: None, + }); + } let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(1) .enable_io() @@ -233,13 +251,10 @@ async fn handle_credential_request( .into_response(); } }; - if request.protocol_version != PROTOCOL_VERSION { + if let Err(error) = ensure_protocol_version(request.protocol_version) { return ( StatusCode::BAD_REQUEST, - Json(ErrorResponseEnvelope::new(ControlError::new( - ErrorCode::ProtocolVersionUnsupported, - format!("unsupported protocol version {}", request.protocol_version), - ))), + Json(ErrorResponseEnvelope::new(error)), ) .into_response(); } @@ -427,6 +442,12 @@ async fn handle_control_request( (status, Json(response)).into_response() } +/// Performs browser-origin hardening for local-control endpoints. +/// +/// These checks intentionally reject browser-style `Origin` requests and stale +/// endpoint selections, but they are not an authorization boundary. Scoped +/// bearer credentials and grant validation remain the authority for control +/// requests. pub(crate) fn validate_loopback_headers( headers: &HeaderMap, expected_host: &str, diff --git a/app/src/local_control/mod_tests.rs b/app/src/local_control/mod_tests.rs index d1ed8735aa..5405299d88 100644 --- a/app/src/local_control/mod_tests.rs +++ b/app/src/local_control/mod_tests.rs @@ -10,7 +10,7 @@ use settings::Setting as _; use warp_core::features::FeatureFlag; use super::{ - capabilities, ensure_feature_enabled, ensure_settings_allow_action, + capabilities, ensure_feature_enabled, ensure_protocol_version, ensure_settings_allow_action, outside_warp_control_enabled_for_settings, require_active_window_id, validate_action_params, validate_loopback_headers, validate_tab_create_target, }; @@ -22,6 +22,16 @@ fn settings_with_mode(mode: LocalControlMode) -> LocalControlSettings { } } +#[test] +fn protocol_version_helper_rejects_unsupported_versions() { + ensure_protocol_version(::local_control::PROTOCOL_VERSION) + .expect("current version is accepted"); + + let err = ensure_protocol_version(::local_control::PROTOCOL_VERSION + 1) + .expect_err("future protocol version is rejected"); + assert_eq!(err.code, ErrorCode::ProtocolVersionUnsupported); +} + #[test] fn tab_create_accepts_default_active_and_window_targets() { validate_tab_create_target(&TargetSelector::default()).expect("default target is accepted"); diff --git a/app/src/local_control/permissions.rs b/app/src/local_control/permissions.rs index 93f9465429..838967fa46 100644 --- a/app/src/local_control/permissions.rs +++ b/app/src/local_control/permissions.rs @@ -1,7 +1,7 @@ //! Permission checks that map invocation context onto local settings. use crate::features::FeatureFlag; use crate::settings::LocalControlSettings; -use ::local_control::{ActionKind, ControlError, ErrorCode, InvocationContext}; +use ::local_control::{ActionKind, ControlError, ErrorCode, InvocationContext, PROTOCOL_VERSION}; use warpui::{ModelContext, SingletonEntity}; use crate::local_control::LocalControlBridge; @@ -10,6 +10,16 @@ pub(super) fn warp_control_cli_enabled() -> bool { FeatureFlag::WarpControlCli.is_enabled() } +pub(super) fn ensure_protocol_version(protocol_version: u32) -> Result<(), ControlError> { + if protocol_version == PROTOCOL_VERSION { + return Ok(()); + } + Err(ControlError::new( + ErrorCode::ProtocolVersionUnsupported, + format!("unsupported protocol version {protocol_version}"), + )) +} + pub(super) fn ensure_feature_enabled() -> Result<(), ControlError> { if warp_control_cli_enabled() { return Ok(()); diff --git a/app/src/settings/local_control.rs b/app/src/settings/local_control.rs index 90c0beec7d..f2ee883c85 100644 --- a/app/src/settings/local_control.rs +++ b/app/src/settings/local_control.rs @@ -210,15 +210,9 @@ impl Setting for LocalControlModeSetting { fn set_value_from_cloud_sync( &mut self, - new_value: Self::Value, - ctx: &mut ModelContext<Self::Group>, + _: Self::Value, + _: &mut ModelContext<Self::Group>, ) -> Result<()> { - let changed_in_storage = Self::write_value_to_secure_storage(&new_value, ctx)?; - if self.value() != &new_value || changed_in_storage { - self.inner = self.validate(new_value); - self.is_explicitly_set = true; - Self::emit_changed(ctx, settings::ChangeEventReason::CloudSync); - } Ok(()) } diff --git a/app/src/settings/local_control_tests.rs b/app/src/settings/local_control_tests.rs index 817273d2b4..57888909f7 100644 --- a/app/src/settings/local_control_tests.rs +++ b/app/src/settings/local_control_tests.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::sync::Mutex; use super::{LocalControlMode, LocalControlModeSetting, LocalControlSettings}; -use settings::{PrivatePreferences, PublicPreferences, Setting as _, SettingsManager}; +use settings::{PrivatePreferences, PublicPreferences, Setting as _, SettingsManager, SyncToCloud}; use warpui::SingletonEntity as _; use warpui_extras::secure_storage::{self, AppContextExt as _}; use warpui_extras::user_preferences; @@ -112,3 +112,50 @@ fn mode_is_persisted_to_secure_storage() { }); }); } + +#[test] +fn mode_is_private_and_never_cloud_synced() { + assert_eq!(LocalControlModeSetting::sync_to_cloud(), SyncToCloud::Never); + assert!(LocalControlModeSetting::is_private()); +} + +#[test] +fn cloud_sync_cannot_enable_local_control() { + warpui::App::test((), |mut app| async move { + app.update(|ctx| { + ctx.add_singleton_model(|_| { + PublicPreferences::new( + Box::<user_preferences::in_memory::InMemoryPreferences>::default(), + ) + }); + ctx.add_singleton_model(|_| { + PrivatePreferences::new( + Box::<user_preferences::in_memory::InMemoryPreferences>::default(), + ) + }); + ctx.add_singleton_model(|_| SettingsManager::default()); + ctx.add_singleton_model(|_| -> secure_storage::Model { + Box::<InMemorySecureStorage>::default() + }); + LocalControlSettings::register(ctx); + }); + + app.update(|ctx| { + LocalControlSettings::handle(ctx).update(ctx, |settings, ctx| { + settings + .local_control_mode + .set_value_from_cloud_sync(LocalControlMode::EnabledEverywhere, ctx) + }) + }) + .expect("cloud sync update should be ignored without error"); + + app.read(|ctx| { + let settings = LocalControlSettings::as_ref(ctx); + assert_eq!(settings.mode(), LocalControlMode::Disabled); + let stored = ctx + .secure_storage() + .read_value(LocalControlModeSetting::storage_key()); + assert!(matches!(stored, Err(secure_storage::Error::NotFound))); + }); + }); +} diff --git a/crates/local_control/src/auth.rs b/crates/local_control/src/auth.rs index cf51864510..026f7582c9 100644 --- a/crates/local_control/src/auth.rs +++ b/crates/local_control/src/auth.rs @@ -8,6 +8,7 @@ use uuid::Uuid; use crate::discovery::InstanceId; use crate::protocol::{ ActionKind, ControlError, ErrorCode, ExecutionContextProof, InvocationContext, + PermissionCategory, }; /// Bearer token used to authorize a single scoped local-control credential. @@ -15,6 +16,10 @@ use crate::protocol::{ pub struct AuthToken(String); impl AuthToken { + /// Generates a bearer secret from 32 bytes of operating-system CSPRNG output. + /// + /// Local-control bearer tokens are authentication material, so they use + /// `OsRng` instead of a deterministic or fast userspace PRNG. pub fn generate() -> Self { let mut bytes = [0u8; 32]; rand::rngs::OsRng.fill_bytes(&mut bytes); @@ -132,6 +137,7 @@ pub struct CredentialGrant { pub credential_id: String, pub instance_id: InstanceId, pub action: ActionKind, + pub permission_category: PermissionCategory, pub invocation_context: InvocationContext, pub authenticated_user: AuthenticatedUserGrant, pub issued_at: DateTime<Utc>, @@ -158,6 +164,7 @@ impl CredentialGrant { credential_id: format!("cred_{}", Uuid::new_v4().simple()), instance_id, action, + permission_category: metadata.permission_category, invocation_context, authenticated_user: AuthenticatedUserGrant { required: metadata.authenticated_user.required, @@ -186,6 +193,15 @@ impl CredentialGrant { )); } let metadata = action.metadata(); + if self.permission_category != metadata.permission_category { + return Err(ControlError::new( + ErrorCode::InsufficientPermissions, + format!( + "{} requires a different local-control permission category", + action.as_str() + ), + )); + } if metadata.requires_authenticated_user && self.authenticated_user.subject.is_none() { return Err(ControlError::new( ErrorCode::AuthenticatedUserRequired, diff --git a/crates/local_control/src/auth_tests.rs b/crates/local_control/src/auth_tests.rs index 849a158e63..154896c3db 100644 --- a/crates/local_control/src/auth_tests.rs +++ b/crates/local_control/src/auth_tests.rs @@ -66,6 +66,36 @@ fn scoped_credential_carries_authenticated_user_metadata() { assert!(grant.authenticated_user.subject.is_none()); } +#[test] +fn scoped_credential_carries_permission_category() { + let grant = CredentialGrant::new( + InstanceId("inst_test".to_owned()), + ActionKind::TabCreate, + InvocationContext::OutsideWarp, + Duration::minutes(5), + ); + assert_eq!( + grant.permission_category, + ActionKind::TabCreate.metadata().permission_category + ); +} + +#[test] +fn scoped_credential_rejects_permission_category_mismatch() { + let mut grant = CredentialGrant::new( + InstanceId("inst_test".to_owned()), + ActionKind::TabCreate, + InvocationContext::OutsideWarp, + Duration::minutes(5), + ); + grant.permission_category = PermissionCategory::ReadMetadata; + + let err = grant + .verify_for_action(ActionKind::TabCreate) + .expect_err("mismatched permission category is rejected"); + assert_eq!(err.code, ErrorCode::InsufficientPermissions); +} + #[test] fn authenticated_user_actions_require_subject() { let grant = CredentialGrant::new( diff --git a/crates/local_control/src/catalog.rs b/crates/local_control/src/catalog.rs index 648378bba9..980a8d77ce 100644 --- a/crates/local_control/src/catalog.rs +++ b/crates/local_control/src/catalog.rs @@ -155,6 +155,8 @@ pub enum ActionResultSpec { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ActionMetadata { pub kind: ActionKind, + /// Stable public action identifier exposed through discovery, help, and wire + /// payloads, such as `tab.create`. pub name: String, pub implementation_status: ActionImplementationStatus, pub risk_tier: RiskTier, @@ -205,6 +207,9 @@ macro_rules! define_action_catalog { } )+ $(,)?) => { /// Stable protocol name for every approved `warpctrl` action. + /// + /// These names are user-visible as CLI/API action identifiers, so they + /// should be treated as stable public contract strings. #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum ActionKind { $( diff --git a/script/macos/bundle b/script/macos/bundle index 0c448ca951..8d13381e45 100755 --- a/script/macos/bundle +++ b/script/macos/bundle @@ -558,15 +558,17 @@ EOF # Make the script executable chmod +x "$CLI_SCRIPT_PATH" - WARPCTRL_SCRIPT_PATH="$BUNDLED_RESOURCES_DIR/bin/warpctrl" - echo "Creating warpctrl wrapper script..." - cat > "$WARPCTRL_SCRIPT_PATH" << 'EOF' + if [[ ",$FEATURES," =~ ",warp_control_cli," ]]; then + WARPCTRL_SCRIPT_PATH="$BUNDLED_RESOURCES_DIR/bin/warpctrl" + echo "Creating warpctrl wrapper script..." + cat > "$WARPCTRL_SCRIPT_PATH" << 'EOF' #!/bin/bash script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" exec -a "$0" "$script_dir/../../MacOS/WARP_BIN_PLACEHOLDER" --warpctrl "$@" EOF - sed -i '' "s/WARP_BIN_PLACEHOLDER/$WARP_BIN/" "$WARPCTRL_SCRIPT_PATH" - chmod +x "$WARPCTRL_SCRIPT_PATH" + sed -i '' "s/WARP_BIN_PLACEHOLDER/$WARP_BIN/" "$WARPCTRL_SCRIPT_PATH" + chmod +x "$WARPCTRL_SCRIPT_PATH" + fi # Store the built artifact locations for GitHub Actions outputs. BINARY_PATH="target/$DEFAULT_TARGET/$TARGET_PROFILE_DIR/$WARP_BIN" @@ -599,6 +601,10 @@ elif [[ "$ARTIFACT" == "cli" || "$ARTIFACT" == "warpctrl" ]]; then cp "target/$DEFAULT_TARGET/$TARGET_PROFILE_DIR/$WARP_BIN" "$OUT_DIR/$WARP_BIN" if [[ "$ARTIFACT" == "warpctrl" ]]; then + if [[ ! ",$FEATURES," =~ ",warp_control_cli," ]]; then + echo "warpctrl artifact requires the warp_control_cli feature" >&2 + exit 1 + fi WARPCTRL_SCRIPT_PATH="$OUT_DIR/warpctrl" echo "Creating warpctrl wrapper script at $WARPCTRL_SCRIPT_PATH" cat > "$WARPCTRL_SCRIPT_PATH" << 'EOF' diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index 4ec52861ce..6e52aa4d4f 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -47,7 +47,7 @@ Required security gates: The first implementation slice should include the protected enablement gate, credential issuance checks, and app-side permission-category enforcement even if the only mutating action initially implemented is `tab.create`. Shipping `tab.create` without the enablement and validation architecture would create the wrong foundation for the full catalog. ### 1. Protocol crate and stable envelope Create a small shared protocol crate or equivalent shared module used by both the app server and the `warpctrl` command-mode client. It should define: -- Protocol version metadata. +- A request protocol version used as a defensive schema guard for stale copied JSON, stale wrappers, and future external clients, not as a normal compatibility-negotiation mechanism between separately versioned CLI and GUI binaries. - Discovery/health response types. - Execution-context proof/request types for verified Warp-terminal invocations versus external invocations. - Action metadata describing state/data category, required permission grant, `requires_authenticated_user`, allowed execution contexts, and target families. @@ -455,7 +455,7 @@ sequenceDiagram CLI->>REG: Read local instance records CLI->>PROC: Health/protocol check for candidates - PROC-->>CLI: Instance metadata + compatibility + PROC-->>CLI: Instance metadata + defensive schema status CLI->>CLI: Resolve instance selector CLI->>BROKER: Request scoped credential for action + execution context BROKER-->>CLI: Grant or structured denial From becf4dbf4bf3b806659ee2df61467a7ca960e52e Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Fri, 5 Jun 2026 09:37:21 -0600 Subject: [PATCH 43/48] Align Warp Control CLI implementation with specs Co-Authored-By: Oz <oz-agent@warp.dev> --- .gitignore | 4 - app/src/ai/agent_sdk/mod.rs | 1 - app/src/local_control/bridge.rs | 38 ++++++--- app/src/local_control/handlers/layout.rs | 20 ++++- app/src/local_control/mod.rs | 41 ++++++++- app/src/local_control/mod_tests.rs | 84 ++++++++++++++++++- app/src/local_control/resolver.rs | 7 +- app/src/settings/local_control.rs | 4 + app/src/settings/local_control_tests.rs | 58 +++++++++++++ app/src/settings_view/scripting_page.rs | 5 +- crates/local_control/src/auth.rs | 18 +++- crates/local_control/src/auth_tests.rs | 22 ++++- crates/local_control/src/catalog.rs | 2 +- crates/local_control/src/client.rs | 39 ++++++++- crates/local_control/src/client_tests.rs | 34 ++++++++ crates/local_control/src/discovery.rs | 83 +++++++++++++----- crates/local_control/src/discovery_tests.rs | 60 ++++++++++++- crates/local_control/src/protocol.rs | 2 + crates/warp_cli/src/local_control/commands.rs | 54 +++++++++++- crates/warp_cli/src/local_control/mod.rs | 2 + crates/warp_cli/src/local_control_tests.rs | 36 ++++++++ crates/warp_features/src/lib.rs | 1 - .../warpui_extras/src/secure_storage/linux.rs | 33 +++++++- .../src/secure_storage/linux_tests.rs | 24 ++++++ script/linux/bundle | 16 ++++ script/linux/test_bundle_warpctrl | 24 ++++++ skills-lock.json | 12 +-- specs/warp-control-cli/README.md | 26 +++--- specs/warp-control-cli/SECURITY.md | 4 +- specs/warp-control-cli/TECH.md | 12 +-- 30 files changed, 675 insertions(+), 91 deletions(-) create mode 100644 crates/local_control/src/client_tests.rs create mode 100755 script/linux/test_bundle_warpctrl diff --git a/.gitignore b/.gitignore index a76ae01abf..2f08b37049 100644 --- a/.gitignore +++ b/.gitignore @@ -69,7 +69,3 @@ __pycache__/ # Project notes .note/ - -# Generated local publishing artifacts -.warp/pr-walkthrough/ -.wrangler/ diff --git a/app/src/ai/agent_sdk/mod.rs b/app/src/ai/agent_sdk/mod.rs index e4b301437b..4df462a952 100644 --- a/app/src/ai/agent_sdk/mod.rs +++ b/app/src/ai/agent_sdk/mod.rs @@ -88,7 +88,6 @@ mod harness_support; mod integration; #[cfg(not(target_family = "wasm"))] mod integration_output; - mod mcp; mod mcp_config; mod model; diff --git a/app/src/local_control/bridge.rs b/app/src/local_control/bridge.rs index 37ac294a19..a3fe0f0c44 100644 --- a/app/src/local_control/bridge.rs +++ b/app/src/local_control/bridge.rs @@ -4,7 +4,7 @@ //! before routing each supported action to an app-side handler. use ::local_control::auth::CredentialGrant; use ::local_control::{ - ActionKind, ControlError, ErrorCode, InstanceId, RequestEnvelope, ResponseEnvelope, + Action, ActionKind, ControlError, ErrorCode, InstanceId, RequestEnvelope, ResponseEnvelope, }; use warpui::{Entity, ModelContext, SingletonEntity}; @@ -46,23 +46,17 @@ impl LocalControlBridge { if let Err(error) = ensure_protocol_version(request.protocol_version) { return ResponseEnvelope::error(request.request_id, error); } - if let Err(error) = validate_action_params(&request.action) { - return ResponseEnvelope::error(request.request_id, error); - } - if let Err(error) = grant.verify_for_action(request.action.kind) { - return ResponseEnvelope::error(request.request_id, error); - } - if !request.action.kind.is_implemented() { + let Some(instance_id) = &self.instance_id else { return ResponseEnvelope::error( request.request_id, ControlError::new( - ErrorCode::UnsupportedAction, - format!( - "{} is not implemented by this local-control bridge", - request.action.kind.as_str() - ), + ErrorCode::BridgeUnavailable, + "local-control bridge has no active instance identity", ), ); + }; + if let Err(error) = validate_request_authority(instance_id, &request.action, &grant) { + return ResponseEnvelope::error(request.request_id, error); } if let Err(error) = ensure_action_allowed(grant.invocation_context, request.action.kind, ctx) @@ -101,3 +95,21 @@ impl LocalControlBridge { } } } + +pub(crate) fn validate_request_authority( + instance_id: &InstanceId, + action: &Action, + grant: &CredentialGrant, +) -> Result<(), ControlError> { + grant.verify_for_action(instance_id, action.kind)?; + if !action.kind.is_implemented() { + return Err(ControlError::new( + ErrorCode::UnsupportedAction, + format!( + "{} is not implemented by this local-control bridge", + action.kind.as_str() + ), + )); + } + validate_action_params(action) +} diff --git a/app/src/local_control/handlers/layout.rs b/app/src/local_control/handlers/layout.rs index 8d9cbbf9ac..f60fd0afbb 100644 --- a/app/src/local_control/handlers/layout.rs +++ b/app/src/local_control/handlers/layout.rs @@ -24,6 +24,7 @@ struct TargetWindowResponse { #[derive(Serialize)] struct TabCountsResponse { + id: String, previous_count: usize, count: usize, active_index: usize, @@ -45,7 +46,7 @@ pub(crate) fn create_terminal_tab( "tab.create requires a workspace in the target window", ) })?; - let (previous_tab_count, tab_count, active_tab_index) = + let (tab_id, previous_tab_count, tab_count, active_tab_index) = workspace.update(ctx, |workspace, ctx| { let previous_tab_count = workspace.tab_count(); workspace.handle_action( @@ -54,12 +55,22 @@ pub(crate) fn create_terminal_tab( }, ctx, ); - ( + let tab_id = workspace + .get_pane_group_view(workspace.active_tab_index()) + .map(|tab| tab.id().to_string()) + .ok_or_else(|| { + ControlError::new( + ErrorCode::Internal, + "tab.create did not produce an active tab identifier", + ) + })?; + Ok(( + tab_id, previous_tab_count, workspace.tab_count(), workspace.active_tab_index(), - ) - }); + )) + })?; serde_json::to_value(TabCreateResponse { action: ActionKind::TabCreate.as_str(), created: true, @@ -69,6 +80,7 @@ pub(crate) fn create_terminal_tab( id: window_id.to_string(), }, tab: TabCountsResponse { + id: tab_id, previous_count: previous_tab_count, count: tab_count, active_index: active_tab_index, diff --git a/app/src/local_control/mod.rs b/app/src/local_control/mod.rs index 1f0226f929..61d4257854 100644 --- a/app/src/local_control/mod.rs +++ b/app/src/local_control/mod.rs @@ -49,6 +49,7 @@ use warpui::{Entity, ModelContext, ModelSpawner, SingletonEntity}; pub use bridge::LocalControlBridge; use permissions::{ensure_action_allowed, ensure_feature_enabled, ensure_protocol_version}; +const MAX_ACTIVE_CREDENTIALS: usize = 128; /// Shared state made available to Axum handlers for one localhost server /// running inside Warp. @@ -98,6 +99,10 @@ impl LocalControlServer { self.stop(); return Ok(()); } + if !outside_warp_publication_supported() { + self.stop(); + return Ok(()); + } let outside_warp_control_enabled = crate::settings::LocalControlSettings::as_ref(ctx).outside_warp_control_enabled(); if !outside_warp_control_enabled { @@ -119,6 +124,12 @@ impl LocalControlServer { fn start(ctx: &mut ModelContext<Self>) -> Result<Self, ControlError> { ensure_feature_enabled()?; + if !outside_warp_publication_supported() { + return Err(ControlError::new( + ErrorCode::LocalControlDisabled, + "outside-Warp local control is disabled until this platform enforces discovery-record ACLs", + )); + } if !crate::settings::LocalControlSettings::as_ref(ctx).outside_warp_control_enabled() { return Ok(Self { _runtime: None, @@ -343,7 +354,11 @@ async fn handle_credential_request( .into_response(); } }; - credentials.insert(auth_token.secret().to_owned(), grant.clone()); + insert_credential( + &mut credentials, + auth_token.secret().to_owned(), + grant.clone(), + ); Json(ScopedCredential { bearer_token: auth_token.secret().to_owned(), grant, @@ -442,6 +457,28 @@ async fn handle_control_request( (status, Json(response)).into_response() } +fn insert_credential( + credentials: &mut HashMap<String, CredentialGrant>, + secret: String, + grant: CredentialGrant, +) { + credentials.retain(|_, grant| !grant.is_expired()); + if credentials.len() >= MAX_ACTIVE_CREDENTIALS { + let oldest_secret = credentials + .iter() + .min_by_key(|(_, grant)| grant.issued_at) + .map(|(secret, _)| secret.clone()); + if let Some(oldest_secret) = oldest_secret { + credentials.remove(&oldest_secret); + } + } + credentials.insert(secret, grant); +} + +fn outside_warp_publication_supported() -> bool { + cfg!(not(target_os = "windows")) +} + /// Performs browser-origin hardening for local-control endpoints. /// /// These checks intentionally reject browser-style `Origin` requests and stale @@ -476,6 +513,8 @@ pub(crate) fn validate_loopback_headers( Ok(()) } +#[cfg(test)] +pub(crate) use bridge::validate_request_authority; #[cfg(test)] pub(crate) use permissions::{ capabilities, ensure_settings_allow_action, outside_warp_control_enabled_for_settings, diff --git a/app/src/local_control/mod_tests.rs b/app/src/local_control/mod_tests.rs index 5405299d88..c7abfb8809 100644 --- a/app/src/local_control/mod_tests.rs +++ b/app/src/local_control/mod_tests.rs @@ -1,18 +1,23 @@ +use std::collections::HashMap; + +use ::local_control::auth::CredentialGrant; use ::local_control::protocol::ActionKind; use ::local_control::protocol::{ Action, PaneSelector, PaneTarget, TabSelector, TabTarget, TargetSelector, WindowSelector, WindowTarget, }; -use ::local_control::{ErrorCode, InvocationContext}; +use ::local_control::{ErrorCode, InstanceId, InvocationContext}; use axum::http::header::{HOST, ORIGIN}; use axum::http::{HeaderMap, HeaderValue}; +use chrono::Duration; use settings::Setting as _; use warp_core::features::FeatureFlag; use super::{ capabilities, ensure_feature_enabled, ensure_protocol_version, ensure_settings_allow_action, - outside_warp_control_enabled_for_settings, require_active_window_id, validate_action_params, - validate_loopback_headers, validate_tab_create_target, + insert_credential, outside_warp_control_enabled_for_settings, require_active_window_id, + validate_action_params, validate_loopback_headers, validate_request_authority, + validate_tab_create_target, MAX_ACTIVE_CREDENTIALS, }; use crate::settings::{LocalControlMode, LocalControlModeSetting, LocalControlSettings}; @@ -237,3 +242,76 @@ fn tab_create_rejects_malformed_params() { }) .expect("empty tab.create params are accepted"); } + +#[test] +fn metadata_actions_reject_malformed_params() { + let err = validate_action_params(&Action { + kind: ActionKind::AppPing, + params: serde_json::json!({ "unexpected": true }), + }) + .expect_err("app.ping params must be empty"); + assert_eq!(err.code, ErrorCode::InvalidParams); +} + +#[test] +fn bridge_checks_grant_before_action_params() { + let instance_id = InstanceId("inst_test".to_owned()); + let grant = CredentialGrant::new( + instance_id.clone(), + ActionKind::AppPing, + InvocationContext::OutsideWarp, + Duration::minutes(5), + ); + let err = validate_request_authority( + &instance_id, + &Action { + kind: ActionKind::AppVersion, + params: serde_json::json!({ "unexpected": true }), + }, + &grant, + ) + .expect_err("wrong-action grant is rejected before params"); + assert_eq!(err.code, ErrorCode::InsufficientPermissions); +} + +#[test] +fn credential_insertion_prunes_expired_and_caps_active_grants() { + let mut credentials = HashMap::new(); + let instance_id = InstanceId("inst_test".to_owned()); + insert_credential( + &mut credentials, + "expired".to_owned(), + CredentialGrant::new( + instance_id.clone(), + ActionKind::TabCreate, + InvocationContext::OutsideWarp, + Duration::minutes(-1), + ), + ); + insert_credential( + &mut credentials, + "active".to_owned(), + CredentialGrant::new( + instance_id.clone(), + ActionKind::TabCreate, + InvocationContext::OutsideWarp, + Duration::minutes(5), + ), + ); + assert!(!credentials.contains_key("expired")); + + for index in 0..MAX_ACTIVE_CREDENTIALS { + insert_credential( + &mut credentials, + format!("active-{index}"), + CredentialGrant::new( + instance_id.clone(), + ActionKind::TabCreate, + InvocationContext::OutsideWarp, + Duration::minutes(5), + ), + ); + } + assert_eq!(credentials.len(), MAX_ACTIVE_CREDENTIALS); + assert!(credentials.contains_key(&format!("active-{}", MAX_ACTIVE_CREDENTIALS - 1))); +} diff --git a/app/src/local_control/resolver.rs b/app/src/local_control/resolver.rs index 442720d352..81ae2cb3fd 100644 --- a/app/src/local_control/resolver.rs +++ b/app/src/local_control/resolver.rs @@ -40,7 +40,7 @@ pub(crate) fn validate_tab_create_target(target: &TargetSelector) -> Result<(), /// slices add their own params and expand this validation alongside the /// corresponding action handlers. pub(crate) fn validate_action_params(action: &::local_control::Action) -> Result<(), ControlError> { - if action.kind != ActionKind::TabCreate { + if !action.kind.is_implemented() { return Ok(()); } if action @@ -52,7 +52,10 @@ pub(crate) fn validate_action_params(action: &::local_control::Action) -> Result } Err(ControlError::new( ErrorCode::InvalidParams, - "tab.create does not accept parameters in the first implementation slice", + format!( + "{} does not accept parameters in the first implementation slice", + action.kind.as_str() + ), )) } diff --git a/app/src/settings/local_control.rs b/app/src/settings/local_control.rs index f2ee883c85..5f679a5450 100644 --- a/app/src/settings/local_control.rs +++ b/app/src/settings/local_control.rs @@ -88,10 +88,14 @@ impl LocalControlModeSetting { } } + /// Preserves the weaker private-preferences value when protected storage is + /// unavailable so platforms without a working secure provider do not lose + /// an existing user choice during migration. fn migrate_from_private_preferences(ctx: &AppContext) -> Option<LocalControlMode> { let value = Self::read_from_preferences(Self::preferences_for_setting(ctx))?; if let Err(err) = Self::write_value_to_secure_storage(&value, ctx) { log::error!("Failed to migrate local-control mode to secure storage: {err:#}"); + return Some(value); } if let Err(err) = Self::clear_from_preferences(Self::preferences_for_setting(ctx)) { log::warn!( diff --git a/app/src/settings/local_control_tests.rs b/app/src/settings/local_control_tests.rs index 57888909f7..981eec34b6 100644 --- a/app/src/settings/local_control_tests.rs +++ b/app/src/settings/local_control_tests.rs @@ -50,6 +50,23 @@ impl secure_storage::SecureStorage for InMemorySecureStorage { } } +struct UnavailableSecureStorage; + +impl secure_storage::SecureStorage for UnavailableSecureStorage { + fn write_value(&self, _key: &str, _value: &str) -> Result<(), secure_storage::Error> { + Err(secure_storage::Error::Unknown(anyhow::anyhow!( + "secure storage unavailable" + ))) + } + + fn read_value(&self, _key: &str) -> Result<String, secure_storage::Error> { + Err(secure_storage::Error::NotFound) + } + + fn remove_value(&self, _key: &str) -> Result<(), secure_storage::Error> { + Err(secure_storage::Error::NotFound) + } +} fn default_settings() -> LocalControlSettings { LocalControlSettings { local_control_mode: LocalControlModeSetting::new(None), @@ -113,6 +130,47 @@ fn mode_is_persisted_to_secure_storage() { }); } +#[test] +fn migration_preserves_private_fallback_when_secure_storage_is_unavailable() { + warpui::App::test((), |mut app| async move { + app.update(|ctx| { + ctx.add_singleton_model(|_| { + PublicPreferences::new( + Box::<user_preferences::in_memory::InMemoryPreferences>::default(), + ) + }); + ctx.add_singleton_model(|_| { + PrivatePreferences::new( + Box::<user_preferences::in_memory::InMemoryPreferences>::default(), + ) + }); + ctx.add_singleton_model(|_| SettingsManager::default()); + ctx.add_singleton_model(|_| -> secure_storage::Model { + Box::new(UnavailableSecureStorage) + }); + LocalControlModeSetting::preferences_for_setting(ctx) + .write_value( + LocalControlModeSetting::storage_key(), + serde_json::to_string(&LocalControlMode::EnabledEverywhere) + .expect("mode serializes"), + ) + .expect("private fallback is writable"); + LocalControlSettings::register(ctx); + }); + + app.read(|ctx| { + assert_eq!( + LocalControlSettings::as_ref(ctx).mode(), + LocalControlMode::EnabledEverywhere + ); + let fallback = LocalControlModeSetting::preferences_for_setting(ctx) + .read_value(LocalControlModeSetting::storage_key()) + .expect("private fallback is readable"); + assert!(fallback.is_some()); + }); + }); +} + #[test] fn mode_is_private_and_never_cloud_synced() { assert_eq!(LocalControlModeSetting::sync_to_cloud(), SyncToCloud::Never); diff --git a/app/src/settings_view/scripting_page.rs b/app/src/settings_view/scripting_page.rs index e2a28205d1..a3d464a77d 100644 --- a/app/src/settings_view/scripting_page.rs +++ b/app/src/settings_view/scripting_page.rs @@ -167,7 +167,10 @@ impl SettingsWidget for LocalControlModeWidget { ToggleState::Enabled, appearance, ChildView::new(&view.local_control_mode_dropdown).finish(), - None, + Some( + "Enabling scripting allows for programmatic and agentic control of Warp. Please refer to the docs for more info." + .to_owned(), + ), ) } } diff --git a/crates/local_control/src/auth.rs b/crates/local_control/src/auth.rs index 026f7582c9..57b86cd5e3 100644 --- a/crates/local_control/src/auth.rs +++ b/crates/local_control/src/auth.rs @@ -175,13 +175,27 @@ impl CredentialGrant { } } - pub fn verify_for_action(&self, action: ActionKind) -> Result<(), ControlError> { - if Utc::now() >= self.expires_at { + pub fn is_expired(&self) -> bool { + Utc::now() >= self.expires_at + } + + pub fn verify_for_action( + &self, + instance_id: &InstanceId, + action: ActionKind, + ) -> Result<(), ControlError> { + if self.is_expired() { return Err(ControlError::new( ErrorCode::UnauthorizedLocalClient, "local-control credential has expired", )); } + if &self.instance_id != instance_id { + return Err(ControlError::new( + ErrorCode::UnauthorizedLocalClient, + "local-control credential belongs to a different Warp instance", + )); + } if self.action != action { return Err(ControlError::new( ErrorCode::InsufficientPermissions, diff --git a/crates/local_control/src/auth_tests.rs b/crates/local_control/src/auth_tests.rs index 154896c3db..cd5030913d 100644 --- a/crates/local_control/src/auth_tests.rs +++ b/crates/local_control/src/auth_tests.rs @@ -46,14 +46,28 @@ fn scoped_credential_allows_only_granted_action() { Duration::minutes(5), ); grant - .verify_for_action(ActionKind::TabCreate) + .verify_for_action(&grant.instance_id, ActionKind::TabCreate) .expect("tab.create grant is accepted"); let err = grant - .verify_for_action(ActionKind::WindowCreate) + .verify_for_action(&grant.instance_id, ActionKind::WindowCreate) .expect_err("other actions are rejected"); assert_eq!(err.code, ErrorCode::InsufficientPermissions); } +#[test] +fn scoped_credential_rejects_different_instance() { + let grant = CredentialGrant::new( + InstanceId("inst_test".to_owned()), + ActionKind::TabCreate, + InvocationContext::OutsideWarp, + Duration::minutes(5), + ); + let err = grant + .verify_for_action(&InstanceId("inst_other".to_owned()), ActionKind::TabCreate) + .expect_err("other instance is rejected"); + assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); +} + #[test] fn scoped_credential_carries_authenticated_user_metadata() { let grant = CredentialGrant::new( @@ -91,7 +105,7 @@ fn scoped_credential_rejects_permission_category_mismatch() { grant.permission_category = PermissionCategory::ReadMetadata; let err = grant - .verify_for_action(ActionKind::TabCreate) + .verify_for_action(&grant.instance_id, ActionKind::TabCreate) .expect_err("mismatched permission category is rejected"); assert_eq!(err.code, ErrorCode::InsufficientPermissions); } @@ -106,7 +120,7 @@ fn authenticated_user_actions_require_subject() { ); assert!(grant.authenticated_user.required); let err = grant - .verify_for_action(ActionKind::DriveInspect) + .verify_for_action(&grant.instance_id, ActionKind::DriveInspect) .expect_err("authenticated-user actions require a subject"); assert_eq!(err.code, ErrorCode::AuthenticatedUserRequired); } diff --git a/crates/local_control/src/catalog.rs b/crates/local_control/src/catalog.rs index 980a8d77ce..824918629a 100644 --- a/crates/local_control/src/catalog.rs +++ b/crates/local_control/src/catalog.rs @@ -353,7 +353,7 @@ define_action_catalog! { tab { TabList => { name: "tab.list", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Tab, params: None, result: TargetList }, TabInspect => { name: "tab.inspect", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Tab, params: None, result: TargetMetadata }, - TabCreate => { name: "tab.create", status: Implemented, authenticated_user: false, contexts: OutsideWarpOnly, state: AppStateMutation, target: Tab, params: TabCreate, result: Acknowledgement }, + TabCreate => { name: "tab.create", status: Implemented, authenticated_user: false, contexts: OutsideWarpOnly, state: AppStateMutation, target: Tab, params: None, result: Acknowledgement }, TabActivate => { name: "tab.activate", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Tab, params: TabActivate, result: Acknowledgement }, TabMove => { name: "tab.move", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Tab, params: Direction, result: Acknowledgement }, TabClose => { name: "tab.close", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Tab, params: TabClose, result: Acknowledgement }, diff --git a/crates/local_control/src/client.rs b/crates/local_control/src/client.rs index a2b04d9ed4..3281ecbf3d 100644 --- a/crates/local_control/src/client.rs +++ b/crates/local_control/src/client.rs @@ -2,14 +2,15 @@ use crate::auth::{CredentialRequest, ScopedCredential}; use crate::discovery::InstanceRecord; use crate::protocol::{ - ControlError, ControlResponse, ErrorCode, ErrorResponseEnvelope, InvocationContext, - RequestEnvelope, ResponseEnvelope, + Action, ActionKind, ControlError, ControlResponse, ErrorCode, ErrorResponseEnvelope, + InvocationContext, RequestEnvelope, ResponseEnvelope, }; pub fn send_request( instance: &InstanceRecord, request: &RequestEnvelope, ) -> Result<ResponseEnvelope, ControlError> { + instance.validate_local_control_authority()?; let credential = request_credential( instance, request.action.kind, @@ -63,6 +64,7 @@ pub fn request_credential( action: crate::protocol::ActionKind, invocation_context: InvocationContext, ) -> Result<ScopedCredential, ControlError> { + instance.validate_local_control_authority()?; let credential_broker = instance.credential_broker.as_ref().ok_or_else(|| { ControlError::new( ErrorCode::LocalControlDisabled, @@ -102,3 +104,36 @@ pub fn request_credential( text, )) } + +pub fn probe_instance(instance: &InstanceRecord) -> Result<(), ControlError> { + let response = send_request( + instance, + &RequestEnvelope::new(Action::new(ActionKind::AppPing)), + )?; + validate_probe_response(instance, response) +} + +fn validate_probe_response( + instance: &InstanceRecord, + response: ResponseEnvelope, +) -> Result<(), ControlError> { + let ControlResponse::Ok { data } = response.response else { + return Err(ControlError::new( + ErrorCode::TransportUnavailable, + "local-control health probe returned an error response", + )); + }; + if data.get("instance_id").and_then(serde_json::Value::as_str) + != Some(instance.instance_id.0.as_str()) + { + return Err(ControlError::new( + ErrorCode::TransportUnavailable, + "local-control health probe returned a different instance identity", + )); + } + Ok(()) +} + +#[cfg(test)] +#[path = "client_tests.rs"] +mod tests; diff --git a/crates/local_control/src/client_tests.rs b/crates/local_control/src/client_tests.rs new file mode 100644 index 0000000000..337bc2145a --- /dev/null +++ b/crates/local_control/src/client_tests.rs @@ -0,0 +1,34 @@ +use chrono::Utc; +use uuid::Uuid; + +use super::*; +use crate::discovery::{ControlEndpoint, CredentialBrokerReference, InstanceId}; + +#[test] +fn probe_rejects_mismatched_instance_identity() { + let instance = InstanceRecord { + protocol_version: crate::PROTOCOL_VERSION, + instance_id: InstanceId("inst_expected".to_owned()), + pid: std::process::id(), + channel: "local".to_owned(), + app_id: "dev.warp.WarpLocal".to_owned(), + app_version: None, + started_at: Utc::now(), + executable_path: None, + endpoint: Some(ControlEndpoint::localhost(4000)), + credential_broker: Some(CredentialBrokerReference { + endpoint: ControlEndpoint::localhost(4000), + }), + outside_warp_control_enabled: true, + actions: vec![ActionKind::AppPing.metadata()], + }; + let err = validate_probe_response( + &instance, + ResponseEnvelope::ok( + Uuid::new_v4(), + serde_json::json!({ "instance_id": "inst_other" }), + ), + ) + .expect_err("mismatched live identity is rejected"); + assert_eq!(err.code, ErrorCode::TransportUnavailable); +} diff --git a/crates/local_control/src/discovery.rs b/crates/local_control/src/discovery.rs index 870a0fa94b..17607c7b33 100644 --- a/crates/local_control/src/discovery.rs +++ b/crates/local_control/src/discovery.rs @@ -1,5 +1,7 @@ //! Filesystem discovery records for running local Warp instances. use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt as _; use std::path::{Path, PathBuf}; use chrono::{DateTime, Utc}; @@ -99,6 +101,25 @@ impl InstanceRecord { actions, } } + + pub fn validate_local_control_authority(&self) -> Result<(), ControlError> { + match ( + self.outside_warp_control_enabled, + &self.endpoint, + &self.credential_broker, + ) { + (false, None, None) => Ok(()), + (true, Some(endpoint), Some(credential_broker)) + if endpoint.host == "127.0.0.1" && credential_broker.endpoint == *endpoint => + { + Ok(()) + } + _ => Err(ControlError::new( + ErrorCode::UnauthorizedLocalClient, + "local-control discovery record contains unsafe or inconsistent endpoint authority", + )), + } + } } /// RAII registration that publishes and removes one discovery record. @@ -117,7 +138,7 @@ impl RegisteredInstance { err.to_string(), ) })?; - set_private_dir_permissions(&dir); + set_private_dir_permissions(&dir)?; let path = record_path(&dir, &record.instance_id); write_record(&path, &record)?; Ok(Self { record, path }) @@ -157,7 +178,7 @@ fn write_record(path: &Path, record: &InstanceRecord) -> Result<(), ControlError err.to_string(), ) })?; - set_private_permissions(path); + set_private_permissions(path)?; Ok(()) } @@ -185,6 +206,9 @@ pub fn discovery_dir() -> PathBuf { pub fn list_instances() -> Vec<InstanceRecord> { list_instances_from_dir(&discovery_dir()) + .into_iter() + .filter(|record| crate::client::probe_instance(record).is_ok()) + .collect() } pub fn list_instances_from_dir(dir: &Path) -> Vec<InstanceRecord> { @@ -205,6 +229,9 @@ pub fn list_instances_from_dir(dir: &Path) -> Vec<InstanceRecord> { if record.protocol_version != PROTOCOL_VERSION { continue; } + if record.validate_local_control_authority().is_err() { + continue; + } if !is_pid_alive(record.pid) { let _ = fs::remove_file(&path); continue; @@ -234,32 +261,48 @@ fn record_path(dir: &Path, instance_id: &InstanceId) -> PathBuf { } #[cfg(unix)] -fn set_private_dir_permissions(path: &Path) { - use std::os::unix::fs::PermissionsExt as _; - - if let Ok(metadata) = fs::metadata(path) { - let mut permissions = metadata.permissions(); - permissions.set_mode(0o700); - let _ = fs::set_permissions(path, permissions); - } +fn set_private_dir_permissions(path: &Path) -> Result<(), ControlError> { + let mut permissions = fs::metadata(path) + .map_err(|err| permissions_error("read local-control discovery directory", err))? + .permissions(); + permissions.set_mode(0o700); + fs::set_permissions(path, permissions) + .map_err(|err| permissions_error("protect local-control discovery directory", err)) } #[cfg(not(unix))] -fn set_private_dir_permissions(_path: &Path) {} +fn set_private_dir_permissions(_path: &Path) -> Result<(), ControlError> { + Err(ControlError::new( + ErrorCode::LocalControlDisabled, + "local-control discovery publication is disabled until this platform enforces record ACLs", + )) +} #[cfg(unix)] -fn set_private_permissions(path: &Path) { - use std::os::unix::fs::PermissionsExt as _; - - if let Ok(metadata) = fs::metadata(path) { - let mut permissions = metadata.permissions(); - permissions.set_mode(0o600); - let _ = fs::set_permissions(path, permissions); - } +fn set_private_permissions(path: &Path) -> Result<(), ControlError> { + let mut permissions = fs::metadata(path) + .map_err(|err| permissions_error("read local-control discovery record", err))? + .permissions(); + permissions.set_mode(0o600); + fs::set_permissions(path, permissions) + .map_err(|err| permissions_error("protect local-control discovery record", err)) } #[cfg(not(unix))] -fn set_private_permissions(_path: &Path) {} +fn set_private_permissions(_path: &Path) -> Result<(), ControlError> { + Err(ControlError::new( + ErrorCode::LocalControlDisabled, + "local-control discovery publication is disabled until this platform enforces record ACLs", + )) +} + +fn permissions_error(operation: &str, error: std::io::Error) -> ControlError { + ControlError::with_details( + ErrorCode::Internal, + format!("failed to {operation}"), + error.to_string(), + ) +} #[cfg(test)] #[path = "discovery_tests.rs"] diff --git a/crates/local_control/src/discovery_tests.rs b/crates/local_control/src/discovery_tests.rs index 37504e011a..86e097a84d 100644 --- a/crates/local_control/src/discovery_tests.rs +++ b/crates/local_control/src/discovery_tests.rs @@ -49,6 +49,38 @@ fn disabled_outside_warp_record_does_not_expose_actionable_authority() { assert!(record.credential_broker.is_none()); } +#[test] +fn rejects_unsafe_or_divergent_discovery_authority() { + let mut record = InstanceRecord::for_current_process( + Some(ControlEndpoint::localhost(4000)), + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + record + .validate_local_control_authority() + .expect("matching 127.0.0.1 endpoints are accepted"); + + record.endpoint.as_mut().expect("endpoint").host = "localhost".to_owned(); + let err = record + .validate_local_control_authority() + .expect_err("localhost alias is rejected"); + assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); + + record.endpoint = Some(ControlEndpoint::localhost(4000)); + record + .credential_broker + .as_mut() + .expect("credential broker") + .endpoint + .port = 4001; + let err = record + .validate_local_control_authority() + .expect_err("divergent broker endpoint is rejected"); + assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); +} + #[cfg(unix)] #[test] fn discovery_directory_is_owner_only_on_unix() { @@ -72,13 +104,35 @@ fn discovery_directory_is_owner_only_on_unix() { assert_eq!(mode, 0o700); } +#[cfg(unix)] +#[test] +fn discovery_record_is_owner_only_on_unix() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().expect("temp dir"); + let record = InstanceRecord::for_current_process( + Some(ControlEndpoint::localhost(4000)), + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + let registered = + RegisteredInstance::register_in_dir_for_test(record, dir.path()).expect("registered"); + let mode = fs::metadata(®istered.path) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); +} + impl RegisteredInstance { fn register_in_dir_for_test(record: InstanceRecord, dir: &Path) -> Result<Self, ControlError> { fs::create_dir_all(dir).expect("create dir"); - set_private_dir_permissions(dir); + set_private_dir_permissions(dir)?; let path = record_path(dir, &record.instance_id); - let bytes = serde_json::to_vec_pretty(&record).expect("serialize"); - fs::write(&path, bytes).expect("write"); + write_record(&path, &record)?; Ok(Self { record, path }) } } diff --git a/crates/local_control/src/protocol.rs b/crates/local_control/src/protocol.rs index 53f5716ca8..caab147e98 100644 --- a/crates/local_control/src/protocol.rs +++ b/crates/local_control/src/protocol.rs @@ -26,7 +26,9 @@ pub enum DriveObjectType { Prompt, Folder, AiFact, + AiRule, McpServer, + McpServerCollection, Space, Trash, } diff --git a/crates/warp_cli/src/local_control/commands.rs b/crates/warp_cli/src/local_control/commands.rs index 9cf097e416..41eab45168 100644 --- a/crates/warp_cli/src/local_control/commands.rs +++ b/crates/warp_cli/src/local_control/commands.rs @@ -40,6 +40,55 @@ impl From<local_control::discovery::InstanceRecord> for InstanceSummary { } } +fn render_human_readable(action: ActionKind, data: &serde_json::Value) -> String { + match action { + ActionKind::AppPing => format!( + "Warp instance {} is reachable (protocol version {})", + value_or_unknown(data, "instance_id"), + value_or_unknown(data, "protocol_version") + ), + ActionKind::AppVersion => format!( + "Warp instance {}\nchannel: {}\napp_id: {}\nprotocol_version: {}", + value_or_unknown(data, "instance_id"), + value_or_unknown(data, "channel"), + value_or_unknown(data, "app_id"), + value_or_unknown(data, "protocol_version") + ), + ActionKind::TabCreate => format!( + "Created tab {} in window {} (active index {}, tab count {})", + nested_value_or_unknown(data, &["tab", "id"]), + nested_value_or_unknown(data, &["window", "id"]), + nested_value_or_unknown(data, &["tab", "active_index"]), + nested_value_or_unknown(data, &["tab", "count"]) + ), + _ => serde_json::to_string_pretty(data).unwrap_or_else(|_| data.to_string()), + } +} + +fn value_or_unknown(data: &serde_json::Value, key: &str) -> String { + nested_value_or_unknown(data, &[key]) +} + +fn nested_value_or_unknown(data: &serde_json::Value, path: &[&str]) -> String { + let value = path + .iter() + .try_fold(data, |value, key| value.get(*key)) + .unwrap_or(&serde_json::Value::Null); + match value { + serde_json::Value::String(value) => value.clone(), + serde_json::Value::Null => "<unknown>".to_owned(), + value => value.to_string(), + } +} + +#[cfg(test)] +pub(crate) fn render_human_readable_for_test( + action: ActionKind, + data: &serde_json::Value, +) -> String { + render_human_readable(action, data) +} + pub(super) fn run_instance_command( command: InstanceCommand, output_format: OutputFormat, @@ -114,6 +163,9 @@ fn run_action( match output_format { OutputFormat::Json => write_json(&data), OutputFormat::Ndjson => write_json_line(&data), - OutputFormat::Pretty | OutputFormat::Text => write_json(&data), + OutputFormat::Pretty | OutputFormat::Text => { + println!("{}", render_human_readable(action, &data)); + Ok(()) + } } } diff --git a/crates/warp_cli/src/local_control/mod.rs b/crates/warp_cli/src/local_control/mod.rs index 4860d9c22e..e426e86c60 100644 --- a/crates/warp_cli/src/local_control/mod.rs +++ b/crates/warp_cli/src/local_control/mod.rs @@ -212,6 +212,8 @@ fn run_inner(args: ControlArgs) -> Result<(), local_control::protocol::ControlEr } } +#[cfg(test)] +pub(crate) use commands::render_human_readable_for_test; #[cfg(test)] pub(crate) use completions::generate_completion_string; #[cfg(test)] diff --git a/crates/warp_cli/src/local_control_tests.rs b/crates/warp_cli/src/local_control_tests.rs index 0e7cead779..b78c156e87 100644 --- a/crates/warp_cli/src/local_control_tests.rs +++ b/crates/warp_cli/src/local_control_tests.rs @@ -114,6 +114,42 @@ fn structured_error_output_uses_stable_error_code() { ); } +#[test] +fn renders_human_readable_tab_create_output() { + let rendered = render_human_readable_for_test( + local_control::protocol::ActionKind::TabCreate, + &json!({ + "tab": { + "id": "tab_123", + "active_index": 2, + "count": 3 + }, + "window": { + "id": "window_123" + } + }), + ); + assert_eq!( + rendered, + "Created tab tab_123 in window window_123 (active index 2, tab count 3)" + ); +} + +#[test] +#[serial] +fn instance_list_without_discovery_records_succeeds() { + let dir = std::env::temp_dir().join(format!( + "warpctrl-empty-discovery-{}", + uuid::Uuid::new_v4().simple() + )); + std::fs::create_dir_all(&dir).expect("temp discovery dir is created"); + let previous = set_discovery_dir(&dir); + let args = ControlArgs::try_parse_from(["warpctrl", "instance", "list"]) + .expect("instance list parses"); + let result = run_inner(args); + restore_discovery_dir(previous); + result.expect("empty instance list succeeds"); +} #[test] #[serial] fn tab_create_without_discovery_records_reports_no_instance() { diff --git a/crates/warp_features/src/lib.rs b/crates/warp_features/src/lib.rs index 6f05819ac0..8970182400 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -948,7 +948,6 @@ pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[ FeatureFlag::RemoteCodebaseIndexing, FeatureFlag::GroupedTabs, FeatureFlag::AsyncFind, - FeatureFlag::RemoteCodeReview, ]; /// Features enabled for feature preview build users (e.g.: Friends of Warp). diff --git a/crates/warpui_extras/src/secure_storage/linux.rs b/crates/warpui_extras/src/secure_storage/linux.rs index 56f0cb5d1a..7a074c0739 100644 --- a/crates/warpui_extras/src/secure_storage/linux.rs +++ b/crates/warpui_extras/src/secure_storage/linux.rs @@ -2,6 +2,9 @@ use std::cell::OnceCell; use std::collections::HashMap; +use std::fs::OpenOptions; +use std::io::Write as _; +use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}; use std::path::PathBuf; use anyhow::{anyhow, Context}; @@ -227,8 +230,34 @@ impl SecureStorage { let fallback_file = self.fallback_file(key)?; let encrypted = self.fallback_encrypt(value)?; - - std::fs::write(fallback_file, encrypted).map_err(|err| Error::Unknown(err.into())) + let Some(fallback_dir) = fallback_file.parent() else { + return Err(Error::Unknown(anyhow!( + "Invalid fallback secure-storage directory" + ))); + }; + std::fs::create_dir_all(fallback_dir).map_err(|err| Error::Unknown(err.into()))?; + let mut dir_permissions = std::fs::metadata(fallback_dir) + .map_err(|err| Error::Unknown(err.into()))? + .permissions(); + dir_permissions.set_mode(0o700); + std::fs::set_permissions(fallback_dir, dir_permissions) + .map_err(|err| Error::Unknown(err.into()))?; + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o600) + .open(&fallback_file) + .map_err(|err| Error::Unknown(err.into()))?; + file.write_all(&encrypted) + .map_err(|err| Error::Unknown(err.into()))?; + let mut file_permissions = file + .metadata() + .map_err(|err| Error::Unknown(err.into()))? + .permissions(); + file_permissions.set_mode(0o600); + file.set_permissions(file_permissions) + .map_err(|err| Error::Unknown(err.into())) } fn read_fallback_value(&self, key: &str) -> Result<String, Error> { diff --git a/crates/warpui_extras/src/secure_storage/linux_tests.rs b/crates/warpui_extras/src/secure_storage/linux_tests.rs index fa57610f7a..b90c60b611 100644 --- a/crates/warpui_extras/src/secure_storage/linux_tests.rs +++ b/crates/warpui_extras/src/secure_storage/linux_tests.rs @@ -43,3 +43,27 @@ fn test_decrypt_fails_on_malformed_data() { ); } } + +#[test] +fn fallback_value_is_owner_only() { + use std::os::unix::fs::PermissionsExt as _; + + let temp_dir = tempfile::tempdir().expect("temp dir"); + let fallback_dir = temp_dir.path().join("secure-storage"); + let storage = SecureStorage::new_with_fallback("darmok", fallback_dir.clone()); + storage + .write_fallback_value("key", "value") + .expect("fallback write"); + let dir_mode = std::fs::metadata(&fallback_dir) + .expect("directory metadata") + .permissions() + .mode() + & 0o777; + let file_mode = std::fs::metadata(storage.fallback_file("key").expect("fallback file")) + .expect("file metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(dir_mode, 0o700); + assert_eq!(file_mode, 0o600); +} diff --git a/script/linux/bundle b/script/linux/bundle index 19c4fc03a9..729fa314bc 100755 --- a/script/linux/bundle +++ b/script/linux/bundle @@ -22,6 +22,7 @@ trap cleanup EXIT # By default we build dev bundles. RELEASE_CHANNEL="dev" FEATURES="release_bundle,crash_reporting" +EXTRA_FEATURES="" PACKAGES=( appimage ) BUILD="true" BUILD_ARCH="$(uname -m)" @@ -75,6 +76,15 @@ while (( "$#" )); do PACKAGES=( $(IFS=, ; echo $2) ) shift 2 ;; + --features) + if [ -n "$2" ] && [ "${2:0:1}" != "-" ]; then + EXTRA_FEATURES="$2" + shift 2 + else + echo "Error: Argument for $1 is missing" >&2 + exit 1 + fi + ;; --artifact) if [ -n "$2" ] && [ "${2:0:1}" != "-" ]; then if [[ "$2" != "app" && "$2" != "cli" && "$2" != "warpctrl" ]]; then @@ -197,6 +207,9 @@ fi # Artifact-specific configuration if [[ "$ARTIFACT" == "cli" || "$ARTIFACT" == "warpctrl" ]]; then FEATURES="$FEATURES,standalone" + if [[ "$ARTIFACT" == "warpctrl" ]]; then + FEATURES="$FEATURES,warp_control_cli" + fi elif [[ "$ARTIFACT" == "app" ]]; then FEATURES="$FEATURES,gui" if [[ "$RELEASE_CHANNEL" == "local" || "$RELEASE_CHANNEL" == "dev" || "$RELEASE_CHANNEL" == "preview" ]]; then @@ -205,6 +218,9 @@ elif [[ "$ARTIFACT" == "app" ]]; then FEATURES="$FEATURES,nld_classifier_v1,nld_heuristic_v1" fi fi +if [[ -n "$EXTRA_FEATURES" ]]; then + FEATURES="$FEATURES,$EXTRA_FEATURES" +fi BUNDLE_ID="dev.warp.$APP_NAME" EXECUTABLE_PATH="$CARGO_TARGET_OUTPUT_DIR/$WARP_BIN" diff --git a/script/linux/test_bundle_warpctrl b/script/linux/test_bundle_warpctrl new file mode 100755 index 0000000000..7a8e16b077 --- /dev/null +++ b/script/linux/test_bundle_warpctrl @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -e + +workspace_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +temp_dir="$(mktemp -d)" +trap 'rm -rf "$temp_dir"' EXIT + +mkdir -p "$temp_dir/bin" +cat > "$temp_dir/bin/cargo" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$@" > "$CARGO_ARGS_FILE" +EOF +chmod +x "$temp_dir/bin/cargo" + +PATH="$temp_dir/bin:$PATH" \ + CARGO_ARGS_FILE="$temp_dir/cargo-args" \ + CARGO_TARGET_DIR="$temp_dir/target" \ + "$workspace_root/script/linux/bundle" \ + --check-only \ + --artifact warpctrl \ + --features smoke_feature + +grep -qx -- '--features' "$temp_dir/cargo-args" +grep -qx -- 'release_bundle,crash_reporting,agent_mode_debug,jemalloc_pprof,standalone,warp_control_cli,smoke_feature' "$temp_dir/cargo-args" diff --git a/skills-lock.json b/skills-lock.json index 03d53ca820..89826be218 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -47,7 +47,7 @@ "source": "warpdotdev/common-skills", "sourceType": "github", "skillPath": ".agents/skills/pr-walkthrough/SKILL.md", - "computedHash": "fd5ff2a22f4cc80ef8d0ab3a7bdef427d6463719f9e5fdf47f6fa1371fc4d3f9" + "computedHash": "990e8549611cff4b82036d4c7cc50d23eb2cbb27272926142d3fd8ad17a5e18f" }, "reproduce-bug-report": { "source": "warpdotdev/common-skills", @@ -61,12 +61,6 @@ "skillPath": ".agents/skills/resolve-merge-conflicts/SKILL.md", "computedHash": "5376b5692901c624e8f20a5a04aeea5f5a94f5168d29852a8a639aded6408f2e" }, - "respond-to-pr-comments-in-blocklist": { - "source": "warpdotdev/common-skills", - "sourceType": "github", - "skillPath": ".agents/skills/respond-to-pr-comments-in-blocklist/SKILL.md", - "computedHash": "f7408cf90c10397aa9048f14ab985a138641fc1e5f3245e290150437d62875f0" - }, "review-pr": { "source": "warpdotdev/common-skills", "sourceType": "github", @@ -77,7 +71,7 @@ "source": "warpdotdev/common-skills", "sourceType": "github", "skillPath": ".agents/skills/spec-driven-implementation/SKILL.md", - "computedHash": "45793ca1e35b032ddfd2596f2e86fd6f6e938549373bfe4aeb74683486a179e4" + "computedHash": "e334d0f6f0e8fc39055314acad911f36d92d1919372b5e2973cc99d7f8c901b4" }, "update-skill": { "source": "warpdotdev/common-skills", @@ -95,7 +89,7 @@ "source": "warpdotdev/common-skills", "sourceType": "github", "skillPath": ".agents/skills/write-tech-spec/SKILL.md", - "computedHash": "c7913bfd1ea2be7ce38d5beb7e923b96f5689f6145250af1d81b985e8be4a882" + "computedHash": "3b5eb4ef021112d473984eca28412d372e87d9337ad5d9754f3ad3e01f94d39b" } } } diff --git a/specs/warp-control-cli/README.md b/specs/warp-control-cli/README.md index 752f9f98d5..47c31fd6df 100644 --- a/specs/warp-control-cli/README.md +++ b/specs/warp-control-cli/README.md @@ -29,6 +29,7 @@ For local development checks, build the local Warp binary and invoke it with the cargo run -p warp --bin warp -- --warpctrl instance list ``` For distributable checks, use the packaged `warpctrl` wrapper. The wrapper execs the packaged channel-specific Warp executable with `--warpctrl`. +The standalone `script/linux/bundle --artifact warpctrl` validation artifact includes that wrapper and compiles the forwarded channel binary with `warp_control_cli`. Installing the wrapper into the normal Linux app package remains a separate packaging follow-up. Run `warpctrl --version` after installation to confirm the shell is resolving the expected build. ### Windows Until the wrapper installer lands, build the local Warp binary and invoke it with the hidden control-mode flag for development checks: @@ -39,42 +40,44 @@ Installer helper creation and release-artifact wiring still need a later packagi ## End-to-end local test flow Use matching app and CLI bits from the same branch or release artifact so the protocol version and action catalog agree. 1. Start Warp and leave at least one window open. -2. Confirm that the local-control server registered the running process: +2. Open **Settings > Scripting**. Local control is disabled by default. To run `warpctrl` from an external terminal or script in the current foundation slice, select **Enabled everywhere, including outside Warp**. Enabling scripting allows for programmatic and agentic control of Warp; refer to the docs for more info. +3. Confirm that the local-control server registered the running process: ```bash warpctrl instance list ``` -3. Confirm app health and protocol compatibility: +4. Confirm app health and protocol compatibility: ```bash warpctrl app ping warpctrl app version ``` -4. If exactly one compatible instance is listed, create a new terminal tab: +5. If exactly one compatible instance is listed, create a new terminal tab: ```bash warpctrl tab create ``` -5. If multiple compatible instances are listed, copy the desired `instance_id` and target it explicitly: +6. If multiple compatible instances are listed, copy the desired `instance_id` and target it explicitly: ```bash warpctrl app ping --instance <instance_id> warpctrl app version --instance <instance_id> warpctrl tab create --instance <instance_id> ``` -6. Verify the running app receives focus for the selected instance and a new terminal tab appears according to Warp's normal new-tab placement behavior. -7. In a future slice that implements `tab list`, inspect state before and after the mutation: +7. Verify the running app receives focus for the selected instance and a new terminal tab appears according to Warp's normal new-tab placement behavior. The success response includes the created tab's opaque ID. +8. In a future slice that implements `tab list`, inspect state before and after the mutation: ```bash warpctrl tab list --instance <instance_id> ``` Expected failures: -- no running compatible app: exits non-zero with a no-instance error; +- `warpctrl instance list` with no running compatible app: exits zero with an empty list; +- a command that needs a selected app when no compatible app is running: exits non-zero with a no-instance error; - multiple ambiguous instances: exits non-zero and asks for `--instance`; - unsupported app build or stale discovery record: exits non-zero with a protocol, stale-target, or transport error; - `tab.create` not yet implemented by the running app bridge: exits non-zero with an unsupported-action error. ## Security model The local-control protocol is designed for same-user scripting, not cross-user or network access. The trust boundary is the local user account. - **Loopback-only listener.** Each Warp process binds its control server to `127.0.0.1` on an ephemeral port. The listener is not reachable from the network. -- **Brokered scoped credentials.** Discovery records contain instance metadata, endpoint information, and credential-broker references only when the selected Scripting mode allows that invocation context. They must not contain raw bearer tokens or reusable full-access credentials. -- **Short-lived grants.** `warpctrl` requests an action-scoped credential from `/v1/control/credentials` for the selected instance and invocation context, then presents that credential to `/v1/control`. Missing, invalid, expired, revoked, or insufficient-scope credentials are rejected before handler dispatch. +- **Brokered scoped credentials.** Discovery records contain instance metadata, endpoint information, and credential-broker references only when the selected Scripting mode allows that invocation context. Published control and broker endpoints must both be exactly `127.0.0.1` and equal to each other. Records must not contain raw bearer tokens or reusable full-access credentials. +- **Short-lived grants.** `warpctrl` requests an action-scoped credential from `/v1/control/credentials` for the selected instance and invocation context, then presents that credential to `/v1/control`. Grants are instance-bound, expired entries are pruned, and the in-memory grant set is capped. Missing, invalid, expired, revoked, wrong-instance, or insufficient-scope credentials are rejected before handler dispatch. - **Protected credential material.** Raw local-control secrets live in platform secure storage where available, with owner-only local-state fallbacks documented as weaker. On POSIX platforms, discovery records and fallback local state must use owner-only permissions. On Windows, records must be stored under the current user's app data directory with an ACL that grants access only to the current user, Administrators, and SYSTEM. -- **Stale-record pruning.** On each `instance list` or implicit discovery call, records whose PID is no longer alive are deleted automatically, preventing stale endpoint or broker metadata from lingering on disk. +- **Stale-record pruning.** On each `instance list` or implicit discovery call, records whose PID is no longer alive are deleted automatically. Candidates are also health-probed and accepted only when the live app reports the expected instance identity. - **No CORS.** The control endpoints do not set permissive CORS headers, so browser-origin JavaScript cannot read responses even if it guesses the port. The credential requirement provides a second layer since browsers cannot read the brokered credential material. ```mermaid sequenceDiagram @@ -101,7 +104,8 @@ sequenceDiagram end ``` **Known limitations and future hardening:** -- Windows local-control authentication is not complete until discovery-record ACL creation and validation are implemented. +- Windows outside-Warp local-control publication is disabled until discovery-record ACL creation and validation are implemented. +- The current low-risk first slice permits reuse of an unexpired scoped grant. A replay policy is required before broader or higher-risk command families ship. - Same-user malicious software can still invoke trusted wrappers or automate the desktop, so brokered credentials are least-privilege guardrails rather than a complete hostile same-user sandbox. - Once higher-risk handlers land, the same-user boundary becomes more sensitive. Consider per-request nonces, stricter platform secure-storage constraints, or Unix domain sockets with `SO_PEERCRED` for stronger caller identity where available. ## Documentation review notes diff --git a/specs/warp-control-cli/SECURITY.md b/specs/warp-control-cli/SECURITY.md index 112d4a7ba9..5a5f6b4895 100644 --- a/specs/warp-control-cli/SECURITY.md +++ b/specs/warp-control-cli/SECURITY.md @@ -4,7 +4,7 @@ The correct architecture is not a single shared localhost bearer token with clie The action-category model is primarily a safety and intent mechanism, not a hard security boundary against malicious same-user software. It lets a user, script, or agent intentionally request metadata-only, data-read, app-state mutation, metadata/configuration mutation, or underlying-data mutation access so it does not accidentally mutate state, expose sensitive content, or execute commands. It should not be described as strong access control against a process that can already run arbitrary commands as the user. `warpctrl` has two distinct authorization dimensions: local-control authority and authenticated scripting authority. Local-control authority proves the request is allowed to control the local app. Authenticated scripting authority proves the Warp user or automation identity that is allowed to act on user-authenticated data such as Warp Drive objects, AI conversation traces, synced settings, cloud-backed user state, and execution-underlying actions. Logged-out users should retain a smaller local-only control surface, but authenticated-user and high-risk underlying-data mutation actions require either a verified Warp-terminal grant tied to the selected app's logged-in user or an external Warp-issued API-key grant. ## Current foundation status -The current foundation implementation stores a single local-control mode with three choices: disabled by default, enabled within Warp, and enabled everywhere including outside Warp. Verified inside-Warp invocation is specified as future work because the app-issued terminal-session proof broker, proof injection path, and session registry do not exist yet. Until those pieces land, `InvocationContext::InsideWarp` requests must be rejected with `execution_context_not_allowed`, implemented action metadata must not advertise inside-Warp support, and external requests must receive credentials only when the user selects the broadest mode. +The current foundation implementation stores a single local-control mode with three choices: disabled by default, enabled within Warp, and enabled everywhere including outside Warp. Verified inside-Warp invocation is specified as future work because the app-issued terminal-session proof broker, proof injection path, and session registry do not exist yet. Until those pieces land, `InvocationContext::InsideWarp` requests must be rejected with `execution_context_not_allowed`, implemented action metadata must not advertise inside-Warp support, and external requests must receive credentials only when the user selects the broadest mode. Windows outside-Warp publication remains disabled until discovery-record ACL enforcement lands. ## Security goals - Allow trusted local users and approved automation to control a running Warp instance through a stable, scriptable interface. - Prevent unauthenticated localhost clients from invoking read or mutating control actions. @@ -311,6 +311,7 @@ Mitigations: - Require explicit user approval or preconfigured policy for underlying data mutations and other sensitive grants. - Distinguish user-approved credential requests from ambient unattended invocations through explicit approval prompts, configured policy, terminal/session context, or narrow credential request flows. - Bind issued credentials to the requested instance, permission category, optional action family, optional target scope, and short expiry. +- Prune expired grants and cap the process-local active-grant set. The low-risk foundation slice may reuse an unexpired scoped grant, but a replay policy is required before broader or higher-risk action families ship. - Let `warpctrl` preflight and request credentials, but require the local app bridge to enforce scopes because direct protocol clients can bypass the CLI. - Make denials structured and non-fatal for automation so callers can request narrower or user-approved grants rather than falling back to unsafe behavior. These mitigations are about routing high-risk operations through intentional `warpctrl` flows rather than exposing a reusable localhost token to any process. They should not be documented as a guarantee that arbitrary same-user applications cannot cause Warp-visible actions. @@ -322,6 +323,7 @@ Transport requirements: - Do not set permissive CORS headers. - Reject any request carrying an `Origin` header. - Reject any request whose `Host` header is not exactly `127.0.0.1:<selected-port>` for the selected discovery record. +- Reject discovery records unless the published control and credential-broker endpoints are equal and both use exactly `127.0.0.1`. - Reject control requests when their inside-Warp or outside-Warp invocation context is disabled, even if the request presents an otherwise valid credential. - Authenticate every control request locally in the selected Warp app process before selector resolution or action dispatch. - Reject missing, malformed, expired, revoked, or invalid credentials with structured authentication errors. diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index 6e52aa4d4f..2925b660e7 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -119,13 +119,13 @@ Recommended decode-level error response shape for malformed requests that cannot { "protocol_version": 1, "error": { - "code": "invalid_params", + "code": "invalid_request", "message": "Request body could not be decoded", "details": null } } ``` -Error payloads should include stable codes defined in `SECURITY.md`, including `local_control_disabled`, `unauthorized_local_client`, `insufficient_permissions`, `authenticated_user_required`, `authenticated_user_unavailable`, `execution_context_not_allowed`, `ambiguous_instance`, `ambiguous_target`, `stale_target`, `invalid_selector`, `unsupported_action`, `not_allowlisted`, `invalid_params`, `target_state_conflict`, `missing_target`, and `no_instance`. +Error payloads should include stable codes defined in `SECURITY.md`, including `local_control_disabled`, `unauthorized_local_client`, `insufficient_permissions`, `authenticated_user_required`, `authenticated_user_unavailable`, `execution_context_not_allowed`, `ambiguous_instance`, `ambiguous_target`, `stale_target`, `invalid_request`, `invalid_selector`, `unsupported_action`, `not_allowlisted`, `invalid_params`, `target_state_conflict`, `missing_target`, and `no_instance`. Decode-level malformed JSON uses `invalid_request`; decoded actions with invalid action-specific parameters use `invalid_params`. ### 2. Per-process discovery instead of fixed-port-only routing Keep the existing fixed-port HTTP behavior intact for installation detection/profiling compatibility. Add a separate local-control listener that follows the same native Axum/Tokio pattern but supports multiple local Warp app processes. Recommended design: @@ -139,7 +139,7 @@ Recommended design: - protocol version - start timestamp - credential metadata or secure-storage references only when the selected mode allows the relevant inside-Warp or outside-Warp context -- The CLI loads discovery records, removes or ignores stale records after health checks, and chooses an instance using the product selector rules. +- The CLI loads discovery records, rejects records unless the control and credential-broker endpoints are equal and exactly `127.0.0.1`, removes or ignores stale records after health and instance-identity checks, and chooses an instance using the product selector rules. - `warpctrl instance list` is a CLI-first projection of this discovery registry plus health responses. When outside-Warp control is disabled, discovery must follow `SECURITY.md`: either publish no actionable local-control record for external clients or publish only a minimal disabled-status record with no endpoint authority or credential reference. This design preserves the current `9277` behavior while avoiding cross-process port contention for the new control API. @@ -329,7 +329,7 @@ The first `warpctrl` implementation slice should land the minimum cross-cutting - `FeatureFlag::WarpControlCli` and Cargo feature `warp_control_cli`, with app-side runtime gating for settings, discovery, bridge registration, and local-control endpoints. - New top-level Settings > Scripting page rendered only while `FeatureFlag::WarpControlCli` is enabled. The current foundation exposes one local-control mode with disabled as the default, enabled within Warp as a reserved mode that rejects requests until proof support exists, and enabled everywhere as the only mode that allows outside-Warp credential requests. - Protected local-only mode storage where outside-Warp control defaults off unless the broadest mode is selected. -- As an interim foundation step, the local-control mode lives in the typed `LocalControlSettings` group as a private setting with `SyncToCloud::Never`, an explicit private storage key, and no `toml_path`. This keeps it out of the user-visible settings file and generated settings schema while leaving the protected-storage migration as a required pre-ship hardening step. +- The local-control mode lives in the typed `LocalControlSettings` group as a private setting with `SyncToCloud::Never`, an explicit private storage key, and no `toml_path`. This keeps it out of the user-visible settings file and generated settings schema. It is persisted through Warp's secure-storage provider, migrates earlier private-preferences values only after a protected write succeeds, and allows explicitly documented weaker owner-only fallback storage on platforms whose secure provider is unavailable. - Discovery registry and CLI instance selection. - A `warpctrl` wrapper entrypoint that invokes the existing channel-specific Warp binary with a hidden `--warpctrl` control-mode flag and runs control commands without starting the GUI app runtime. - Per-process authenticated local-control server that refuses sensitive work when outside-Warp control is disabled and rejects inside-Warp credential requests until verified terminal proof support is implemented. @@ -369,7 +369,8 @@ The shipped product shape should be a bundled `warpctrl` wrapper script or helpe - Keep channelized naming consistent with the final product name decision; if non-stable channels need aliases, the aliases should still point at the same channel app binary. - Linux: - Prefer installing a small `warpctrl` wrapper or symlink/helper in the same package as the Warp app, routed to the packaged channel binary with `--warpctrl`. - - Do not build a separate standalone Rust binary for `--artifact warpctrl`; if that artifact path exists, it must emit a wrapper plus the channel binary it forwards to. + - Do not build a separate standalone Rust binary for `--artifact warpctrl`; the standalone validation artifact emits a wrapper plus the channel binary it forwards to and compiles that binary with `warp_control_cli`. + - Installing the wrapper into the normal Linux app package remains a follow-up separate from the standalone validation artifact. - Windows: - Mirror the existing installer-generated helper-wrapper pattern first. - If Windows cannot cheaply use a shell-script-style wrapper, generate the smallest possible helper that forwards to the installed channel binary with `--warpctrl` and preserves stdout/stderr behavior for scripts. @@ -494,6 +495,7 @@ Map tests directly to `PRODUCT.md` behavior. - Protocol version/unit tests. - Discovery-registry tests with zero, one, multiple, stale, and incompatible instance records. - Local-auth tests for missing, invalid, expired, revoked, and valid credentials. + - Grant-lifecycle tests for instance binding, expired-grant pruning, and the active-grant cap. Define and enforce a replay policy before broader or higher-risk command families ship. - Behavior 7-13: - Selector-resolution unit tests for active, explicit ID, index, stale target, ambiguous target, and non-terminal session target. - Tests that no lower-level selector silently retargets after an explicit stale selector fails. From f480b1036e2cfed6c88ca4aebc1d24da7a7061f7 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Fri, 5 Jun 2026 13:18:52 -0600 Subject: [PATCH 44/48] Close warpctrl contract validation gaps Co-Authored-By: Oz <oz-agent@warp.dev> --- Cargo.lock | 1 + app/src/local_control/handlers/layout.rs | 3 + .../local_control/handlers/layout_tests.rs | 36 ++ app/src/local_control/mod.rs | 356 +++++++++++------- app/src/local_control/mod_tests.rs | 157 +++++++- app/src/local_control/resolver.rs | 37 +- app/src/settings_view/scripting_page.rs | 2 +- app/src/workspace/view.rs | 2 +- app/src/workspace/view_tests.rs | 4 +- crates/local_control/Cargo.toml | 1 + crates/local_control/src/auth_tests.rs | 14 + crates/local_control/src/client.rs | 97 +++-- crates/local_control/src/client_tests.rs | 46 ++- crates/local_control/src/discovery.rs | 50 ++- crates/local_control/src/discovery_tests.rs | 127 ++++++- crates/local_control/src/protocol_tests.rs | 16 + crates/local_control/src/selection_tests.rs | 2 +- crates/warp_cli/src/local_control/mod.rs | 2 +- crates/warp_cli/src/local_control_tests.rs | 57 +++ script/linux/test_bundle_warpctrl | 27 ++ script/test_warpctrl_early_dispatch | 49 +++ specs/warp-control-cli/PRODUCT.md | 52 ++- specs/warp-control-cli/README.md | 25 +- specs/warp-control-cli/SECURITY.md | 88 ++--- specs/warp-control-cli/TECH.md | 53 +-- 25 files changed, 980 insertions(+), 324 deletions(-) create mode 100644 app/src/local_control/handlers/layout_tests.rs create mode 100755 script/test_warpctrl_early_dispatch diff --git a/Cargo.lock b/Cargo.lock index b6d519d0b4..692ce09333 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7459,6 +7459,7 @@ version = "0.0.0" dependencies = [ "base64 0.22.1", "chrono", + "command", "libc", "rand 0.8.6", "reqwest", diff --git a/app/src/local_control/handlers/layout.rs b/app/src/local_control/handlers/layout.rs index f60fd0afbb..c604dbaa29 100644 --- a/app/src/local_control/handlers/layout.rs +++ b/app/src/local_control/handlers/layout.rs @@ -1,4 +1,7 @@ //! Layout mutation handlers for local-control actions. +#[cfg(test)] +#[path = "layout_tests.rs"] +mod tests; use ::local_control::protocol::TargetSelector; use ::local_control::{ActionKind, ControlError, ErrorCode, InstanceId}; use serde::Serialize; diff --git a/app/src/local_control/handlers/layout_tests.rs b/app/src/local_control/handlers/layout_tests.rs new file mode 100644 index 0000000000..ba9f85ad6c --- /dev/null +++ b/app/src/local_control/handlers/layout_tests.rs @@ -0,0 +1,36 @@ +use ::local_control::protocol::TargetSelector; +use ::local_control::InstanceId; +use warpui::App; + +use super::create_terminal_tab; +use crate::local_control::LocalControlBridge; +use crate::workspace::view::tests::{initialize_app, mock_workspace}; + +#[test] +fn tab_create_handler_adds_and_activates_terminal_tab() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let workspace = mock_workspace(&mut app); + let previous_count = workspace.read(&app, |workspace, _| workspace.tab_count()); + let bridge = app.add_singleton_model(LocalControlBridge::new); + let instance_id = InstanceId("inst_test".to_owned()); + + let response = bridge.update(&mut app, |bridge, ctx| { + bridge.set_instance_id(instance_id.clone()); + create_terminal_tab(&Some(instance_id.clone()), &TargetSelector::default(), ctx) + .expect("tab.create handler succeeds") + }); + + workspace.read(&app, |workspace, _| { + assert_eq!(workspace.tab_count(), previous_count + 1); + assert_eq!(workspace.active_tab_index(), previous_count); + }); + assert_eq!(response["action"], "tab.create"); + assert_eq!(response["created"], true); + assert_eq!(response["instance_id"], "inst_test"); + assert_eq!(response["tab"]["previous_count"], previous_count); + assert_eq!(response["tab"]["count"], previous_count + 1); + assert_eq!(response["tab"]["active_index"], previous_count); + assert!(response["tab"]["id"].is_string()); + }); +} diff --git a/app/src/local_control/mod.rs b/app/src/local_control/mod.rs index 61d4257854..cb2e64dbd5 100644 --- a/app/src/local_control/mod.rs +++ b/app/src/local_control/mod.rs @@ -1,19 +1,17 @@ //! Local HTTP server entry point for Warp control requests. //! //! This module owns the in-process listener, discovery registration, credential -//! broker endpoint, and request handoff from Axum into the WarpUI model graph. +//! broker socket, and request handoff from Axum into the WarpUI model graph. //! -//! Authentication is split into two localhost endpoints. Clients first request a -//! short-lived scoped credential from `/v1/control/credentials`; the localhost -//! server running inside Warp checks the feature flag, requested invocation -//! context, action metadata, execution-context proof, and Settings > Scripting -//! permissions before minting a bearer token. Outside-Warp clients use the -//! localhost credential broker directly; verified inside-Warp terminal -//! credentials remain future work until the app-issued proof broker is -//! implemented. The client then presents that bearer token to `/v1/control`, -//! where the server looks up the in-memory grant, verifies it still matches the -//! requested action, and only then hands the request to the main-thread -//! `LocalControlBridge`. +//! Clients first request a short-lived scoped credential from an owner-authenticated +//! Unix-domain-socket broker. The broker checks the caller's peer UID, feature +//! flag, requested invocation context, action metadata, execution-context proof, +//! and Settings > Scripting permissions before minting a bearer token. Verified +//! inside-Warp terminal credentials remain future work until the app-issued proof +//! broker is implemented. The client then presents that bearer token to +//! `/v1/control`, where the server looks up the in-memory grant, verifies it still +//! matches the requested action, and only then hands the request to the +//! main-thread `LocalControlBridge`. //! //! The Settings > Scripting gates used here are local-only settings backed by //! Warp's secure storage provider. @@ -27,7 +25,11 @@ mod permissions; mod resolver; use std::collections::HashMap; +#[cfg(unix)] +use std::fs::Permissions; use std::net::SocketAddr; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt as _; use std::sync::{Arc, Mutex}; use ::local_control::auth::{CredentialGrant, CredentialRequest, ScopedCredential}; @@ -36,7 +38,7 @@ use ::local_control::{ ErrorResponseEnvelope, InstanceId, InstanceRecord, RegisteredInstance, RequestEnvelope, ResponseEnvelope, }; -use axum::extract::rejection::JsonRejection; +use axum::body::Bytes; use axum::extract::State; use axum::http::header::{AUTHORIZATION, HOST, ORIGIN}; use axum::http::{HeaderMap, StatusCode}; @@ -44,6 +46,8 @@ use axum::response::{IntoResponse, Response}; use axum::routing::post; use axum::{Json, Router}; use chrono::Duration; +#[cfg(unix)] +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; use warp_core::channel::ChannelState; use warpui::{Entity, ModelContext, ModelSpawner, SingletonEntity}; @@ -175,6 +179,8 @@ impl LocalControlServer { ctx.spawner() }); let registered_instance = RegisteredInstance::register(record)?; + #[cfg(unix)] + let broker_listener = bind_credential_broker(registered_instance.record())?; let state = ControlServerState { bridge_spawner, instance_id, @@ -183,13 +189,14 @@ impl LocalControlServer { }; let router = Router::new() .route("/v1/control", post(handle_control_request)) - .route("/v1/control/credentials", post(handle_credential_request)) - .with_state(state); + .with_state(state.clone()); runtime.spawn(async move { if let Err(err) = axum::serve(listener, router).await { log::warn!("local-control listener stopped: {err:#}"); } }); + #[cfg(unix)] + runtime.spawn(run_credential_broker(broker_listener, state)); Ok(Self { _runtime: Some(runtime), control_endpoint: Some(control_endpoint), @@ -209,6 +216,7 @@ impl LocalControlServer { }; let mut record = discovery_record_for_settings(ctx, control_endpoint); record.instance_id = registered_instance.record().instance_id.clone(); + record.credential_broker = registered_instance.record().credential_broker.clone(); registered_instance.update(record) } } @@ -229,111 +237,171 @@ fn discovery_record_for_settings( ) } -async fn handle_credential_request( - State(state): State<ControlServerState>, - headers: HeaderMap, - payload: Result<Json<CredentialRequest>, JsonRejection>, -) -> Response { - if let Err(error) = validate_loopback_headers(&headers, &state.expected_host) { - return ( - StatusCode::FORBIDDEN, - Json(ErrorResponseEnvelope::new(error)), - ) - .into_response(); +#[cfg(unix)] +fn bind_credential_broker( + record: &InstanceRecord, +) -> Result<tokio::net::UnixListener, ControlError> { + let socket_path = record.broker_socket_path()?; + if socket_path.exists() { + std::fs::remove_file(&socket_path).map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to remove stale local-control credential broker socket", + err.to_string(), + ) + })?; } - if let Err(error) = ensure_feature_enabled() { - return ( - StatusCode::FORBIDDEN, - Json(ErrorResponseEnvelope::new(error)), + let listener = tokio::net::UnixListener::bind(&socket_path).map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to bind owner-authenticated local-control credential broker", + err.to_string(), ) - .into_response(); + })?; + std::fs::set_permissions(&socket_path, Permissions::from_mode(0o600)).map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to protect local-control credential broker socket", + err.to_string(), + ) + })?; + Ok(listener) +} + +#[cfg(unix)] +async fn run_credential_broker(listener: tokio::net::UnixListener, state: ControlServerState) { + loop { + let Ok((stream, _)) = listener.accept().await else { + return; + }; + let state = state.clone(); + tokio::spawn(async move { + if let Err(err) = handle_credential_broker_connection(stream, state).await { + log::warn!("local-control credential broker connection failed: {err:#}"); + } + }); } - let request = match payload { - Ok(Json(request)) => request, - Err(err) => { - return ( - StatusCode::BAD_REQUEST, - Json(ErrorResponseEnvelope::new(ControlError::with_details( +} + +#[cfg(unix)] +async fn handle_credential_broker_connection( + mut stream: tokio::net::UnixStream, + state: ControlServerState, +) -> Result<(), ControlError> { + let response = match ensure_same_user_peer(&stream) { + Ok(()) => { + let mut bytes = Vec::new(); + stream.read_to_end(&mut bytes).await.map_err(|err| { + ControlError::with_details( + ErrorCode::InvalidRequest, + "failed to read local-control credential request", + err.to_string(), + ) + })?; + match serde_json::from_slice::<CredentialRequest>(&bytes) { + Ok(request) => issue_credential(&state, request) + .await + .and_then(|credential| serialize_credential_broker_response(&credential)), + Err(err) => Err(ControlError::with_details( ErrorCode::InvalidRequest, "failed to decode local-control credential request", err.to_string(), - ))), - ) - .into_response(); + )), + } } + Err(error) => Err(error), }; - if let Err(error) = ensure_protocol_version(request.protocol_version) { - return ( - StatusCode::BAD_REQUEST, - Json(ErrorResponseEnvelope::new(error)), + let bytes = match response { + Ok(bytes) => bytes, + Err(error) => serialize_credential_broker_response(&ErrorResponseEnvelope::new(error))?, + }; + stream.write_all(&bytes).await.map_err(|err| { + ControlError::with_details( + ErrorCode::TransportUnavailable, + "failed to write local-control credential response", + err.to_string(), ) - .into_response(); + }) +} + +#[cfg(unix)] +fn ensure_same_user_peer(stream: &tokio::net::UnixStream) -> Result<(), ControlError> { + ensure_peer_uid(stream, unsafe { libc::geteuid() }) +} + +#[cfg(unix)] +fn ensure_peer_uid(stream: &tokio::net::UnixStream, expected_uid: u32) -> Result<(), ControlError> { + let peer = stream.peer_cred().map_err(|err| { + ControlError::with_details( + ErrorCode::UnauthorizedLocalClient, + "failed to identify local-control credential broker peer", + err.to_string(), + ) + })?; + if peer.uid() != expected_uid { + return Err(ControlError::new( + ErrorCode::UnauthorizedLocalClient, + "local-control credential broker peer belongs to a different OS user", + )); } + Ok(()) +} + +fn serialize_credential_broker_response( + response: &impl serde::Serialize, +) -> Result<Vec<u8>, ControlError> { + serde_json::to_vec(response).map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to serialize local-control credential response", + err.to_string(), + ) + }) +} + +async fn issue_credential( + state: &ControlServerState, + request: CredentialRequest, +) -> Result<ScopedCredential, ControlError> { + ensure_feature_enabled()?; + ensure_protocol_version(request.protocol_version)?; let metadata = request.action.metadata(); if !request.action.is_implemented() { - return ( - StatusCode::BAD_REQUEST, - Json(ErrorResponseEnvelope::new(ControlError::new( - ErrorCode::UnsupportedAction, - format!( - "{} is not implemented by this local-control bridge", - request.action.as_str() - ), - ))), - ) - .into_response(); + return Err(ControlError::new( + ErrorCode::UnsupportedAction, + format!( + "{} is not implemented by this local-control bridge", + request.action.as_str() + ), + )); } if !metadata .allowed_invocation_contexts .contains(&request.invocation_context) { - return ( - StatusCode::FORBIDDEN, - Json(ErrorResponseEnvelope::new(ControlError::new( - ErrorCode::ExecutionContextNotAllowed, - format!( - "{} cannot run from the requested invocation context", - request.action.as_str() - ), - ))), - ) - .into_response(); - } - if let Err(error) = request.verify_execution_context_proof() { - return ( - StatusCode::FORBIDDEN, - Json(ErrorResponseEnvelope::new(error)), - ) - .into_response(); + return Err(ControlError::new( + ErrorCode::ExecutionContextNotAllowed, + format!( + "{} cannot run from the requested invocation context", + request.action.as_str() + ), + )); } - let settings_check = state + request.verify_execution_context_proof()?; + state .bridge_spawner .spawn({ let action = request.action; let invocation_context = request.invocation_context; move |_, ctx| ensure_action_allowed(invocation_context, action, ctx) }) - .await; - match settings_check { - Ok(Ok(())) => {} - Ok(Err(error)) => { - return ( - StatusCode::FORBIDDEN, - Json(ErrorResponseEnvelope::new(error)), - ) - .into_response(); - } - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponseEnvelope::new(ControlError::new( - ErrorCode::BridgeUnavailable, - "local-control app bridge is unavailable", - ))), + .await + .map_err(|_| { + ControlError::new( + ErrorCode::BridgeUnavailable, + "local-control app bridge is unavailable", ) - .into_response(); - } - } + })??; let auth_token = AuthToken::generate(); let grant = CredentialGrant::new( state.instance_id.clone(), @@ -341,35 +409,27 @@ async fn handle_credential_request( request.invocation_context, Duration::minutes(5), ); - let mut credentials = match state.credentials.lock() { - Ok(credentials) => credentials, - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponseEnvelope::new(ControlError::new( - ErrorCode::Internal, - "local-control credential broker is unavailable", - ))), - ) - .into_response(); - } - }; + let mut credentials = state.credentials.lock().map_err(|_| { + ControlError::new( + ErrorCode::Internal, + "local-control credential broker is unavailable", + ) + })?; insert_credential( &mut credentials, auth_token.secret().to_owned(), grant.clone(), ); - Json(ScopedCredential { + Ok(ScopedCredential { bearer_token: auth_token.secret().to_owned(), grant, }) - .into_response() } async fn handle_control_request( State(state): State<ControlServerState>, headers: HeaderMap, - payload: Result<Json<RequestEnvelope>, JsonRejection>, + payload: Bytes, ) -> Response { if let Err(error) = validate_loopback_headers(&headers, &state.expected_host) { return ( @@ -398,22 +458,8 @@ async fn handle_control_request( .into_response(); } }; - let request = match payload { - Ok(Json(request)) => request, - Err(err) => { - return ( - StatusCode::BAD_REQUEST, - Json(ErrorResponseEnvelope::new(ControlError::with_details( - ErrorCode::InvalidRequest, - "failed to decode local-control request", - err.to_string(), - ))), - ) - .into_response(); - } - }; let grant = match state.credentials.lock() { - Ok(credentials) => credentials.get(auth_token.secret()).cloned(), + Ok(mut credentials) => lookup_credential(&mut credentials, &auth_token, &state.instance_id), Err(_) => { return ( StatusCode::INTERNAL_SERVER_ERROR, @@ -425,15 +471,29 @@ async fn handle_control_request( .into_response(); } }; - let Some(grant) = grant else { - return ( - StatusCode::UNAUTHORIZED, - Json(ErrorResponseEnvelope::new(ControlError::new( - ErrorCode::UnauthorizedLocalClient, - "local-control credential is invalid", - ))), - ) - .into_response(); + let grant = match grant { + Ok(grant) => grant, + Err(error) => { + return ( + StatusCode::UNAUTHORIZED, + Json(ErrorResponseEnvelope::new(error)), + ) + .into_response(); + } + }; + let request = match serde_json::from_slice::<RequestEnvelope>(&payload) { + Ok(request) => request, + Err(err) => { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponseEnvelope::new(ControlError::with_details( + ErrorCode::InvalidRequest, + "failed to decode local-control request", + err.to_string(), + ))), + ) + .into_response(); + } }; let request_id = request.request_id; let response = match state @@ -475,6 +535,29 @@ fn insert_credential( credentials.insert(secret, grant); } +fn lookup_credential( + credentials: &mut HashMap<String, CredentialGrant>, + auth_token: &AuthToken, + instance_id: &InstanceId, +) -> Result<CredentialGrant, ControlError> { + if credentials + .get(auth_token.secret()) + .is_some_and(CredentialGrant::is_expired) + { + credentials.remove(auth_token.secret()); + } + let grant = credentials + .get(auth_token.secret()) + .cloned() + .ok_or_else(|| { + ControlError::new( + ErrorCode::UnauthorizedLocalClient, + "local-control credential is invalid", + ) + })?; + grant.verify_for_action(instance_id, grant.action)?; + Ok(grant) +} fn outside_warp_publication_supported() -> bool { cfg!(not(target_os = "windows")) } @@ -521,7 +604,8 @@ pub(crate) use permissions::{ }; #[cfg(test)] pub(crate) use resolver::{ - require_active_window_id, validate_action_params, validate_tab_create_target, + require_active_window_id, resolve_index_from_ids, resolve_title_from_matches, + validate_action_params, validate_tab_create_target, }; #[cfg(test)] diff --git a/app/src/local_control/mod_tests.rs b/app/src/local_control/mod_tests.rs index c7abfb8809..bd9c4eceff 100644 --- a/app/src/local_control/mod_tests.rs +++ b/app/src/local_control/mod_tests.rs @@ -1,23 +1,30 @@ use std::collections::HashMap; -use ::local_control::auth::CredentialGrant; +#[cfg(unix)] +use super::ensure_peer_uid; +use ::local_control::auth::{CredentialGrant, CredentialRequest}; use ::local_control::protocol::ActionKind; use ::local_control::protocol::{ Action, PaneSelector, PaneTarget, TabSelector, TabTarget, TargetSelector, WindowSelector, WindowTarget, }; -use ::local_control::{ErrorCode, InstanceId, InvocationContext}; -use axum::http::header::{HOST, ORIGIN}; +use ::local_control::{ErrorCode, InstanceId, InvocationContext, RequestEnvelope}; +use axum::body::Bytes; +use axum::extract::State; +use axum::http::header::{AUTHORIZATION, HOST, ORIGIN}; use axum::http::{HeaderMap, HeaderValue}; use chrono::Duration; use settings::Setting as _; use warp_core::features::FeatureFlag; +use warpui::SingletonEntity as _; use super::{ capabilities, ensure_feature_enabled, ensure_protocol_version, ensure_settings_allow_action, - insert_credential, outside_warp_control_enabled_for_settings, require_active_window_id, - validate_action_params, validate_loopback_headers, validate_request_authority, - validate_tab_create_target, MAX_ACTIVE_CREDENTIALS, + handle_control_request, insert_credential, issue_credential, lookup_credential, + outside_warp_control_enabled_for_settings, require_active_window_id, resolve_index_from_ids, + resolve_title_from_matches, validate_action_params, validate_loopback_headers, + validate_request_authority, validate_tab_create_target, ControlServerState, LocalControlBridge, + MAX_ACTIVE_CREDENTIALS, }; use crate::settings::{LocalControlMode, LocalControlModeSetting, LocalControlSettings}; @@ -26,6 +33,28 @@ fn settings_with_mode(mode: LocalControlMode) -> LocalControlSettings { local_control_mode: LocalControlModeSetting::new(Some(mode)), } } +#[cfg(unix)] +#[tokio::test] +async fn credential_broker_rejects_peer_from_different_user() { + let (stream, _peer) = tokio::net::UnixStream::pair().expect("socket pair"); + let actual_uid = stream.peer_cred().expect("peer credentials").uid(); + let different_uid = if actual_uid == u32::MAX { + actual_uid - 1 + } else { + actual_uid + 1 + }; + + let err = ensure_peer_uid(&stream, different_uid).expect_err("different user is rejected"); + assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); +} +#[cfg(unix)] +#[tokio::test] +async fn credential_broker_accepts_peer_from_same_user() { + let (stream, _peer) = tokio::net::UnixStream::pair().expect("socket pair"); + let actual_uid = stream.peer_cred().expect("peer credentials").uid(); + + ensure_peer_uid(&stream, actual_uid).expect("same user is accepted"); +} #[test] fn protocol_version_helper_rejects_unsupported_versions() { @@ -171,6 +200,28 @@ fn tab_create_requires_active_window() { assert_eq!(err.code, ErrorCode::MissingTarget); } +#[test] +fn window_title_resolution_distinguishes_missing_and_ambiguous_targets() { + let missing = resolve_title_from_matches(&[], ActionKind::TabCreate) + .expect_err("zero-match title is missing"); + assert_eq!(missing.code, ErrorCode::MissingTarget); + + let matches = [ + warpui::WindowId::from_usize(1), + warpui::WindowId::from_usize(2), + ]; + let ambiguous = resolve_title_from_matches(&matches, ActionKind::TabCreate) + .expect_err("multi-match title is ambiguous"); + assert_eq!(ambiguous.code, ErrorCode::AmbiguousTarget); +} + +#[test] +fn missing_window_index_returns_missing_target() { + let err = resolve_index_from_ids(std::iter::empty(), 0, ActionKind::TabCreate) + .expect_err("zero-match index is missing"); + assert_eq!(err.code, ErrorCode::MissingTarget); +} + #[test] fn feature_flag_disabled_denies_local_control() { let _flag = FeatureFlag::WarpControlCli.override_enabled(false); @@ -315,3 +366,97 @@ fn credential_insertion_prunes_expired_and_caps_active_grants() { assert_eq!(credentials.len(), MAX_ACTIVE_CREDENTIALS); assert!(credentials.contains_key(&format!("active-{}", MAX_ACTIVE_CREDENTIALS - 1))); } + +#[test] +fn expired_credential_is_rejected_and_pruned_before_request_decode() { + let mut credentials = HashMap::new(); + let token = ::local_control::AuthToken::from_secret("expired"); + credentials.insert( + token.secret().to_owned(), + CredentialGrant::new( + InstanceId("inst_test".to_owned()), + ActionKind::TabCreate, + InvocationContext::OutsideWarp, + Duration::minutes(-1), + ), + ); + + let err = lookup_credential( + &mut credentials, + &token, + &InstanceId("inst_test".to_owned()), + ) + .expect_err("expired grant is rejected"); + assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); + assert!(!credentials.contains_key(token.secret())); +} + +#[test] +fn mode_narrowing_invalidates_existing_outside_warp_grant_and_prevents_new_grants() { + let _flag = FeatureFlag::WarpControlCli.override_enabled(true); + warpui::App::test((), |mut app| async move { + crate::test_util::settings::initialize_settings_for_tests(&mut app); + app.update(|ctx| { + LocalControlSettings::handle(ctx).update(ctx, |settings, ctx| { + settings + .local_control_mode + .set_value(LocalControlMode::EnabledEverywhere, ctx) + }) + }) + .expect("outside-Warp control should enable"); + + let instance_id = InstanceId("inst_test".to_owned()); + let expected_host = "127.0.0.1:1234".to_owned(); + let bridge = app.add_singleton_model(LocalControlBridge::new); + let state = bridge.update(&mut app, |bridge, ctx| { + bridge.set_instance_id(instance_id.clone()); + ControlServerState { + bridge_spawner: ctx.spawner(), + instance_id: instance_id.clone(), + expected_host: expected_host.clone(), + credentials: Default::default(), + } + }); + let credential = issue_credential( + &state, + CredentialRequest::new(ActionKind::AppPing, InvocationContext::OutsideWarp), + ) + .await + .expect("outside-Warp credential should be issued"); + + app.update(|ctx| { + LocalControlSettings::handle(ctx).update(ctx, |settings, ctx| { + settings + .local_control_mode + .set_value(LocalControlMode::EnabledWithinWarp, ctx) + }) + }) + .expect("mode should narrow"); + + let mut headers = HeaderMap::new(); + headers.insert( + HOST, + HeaderValue::from_str(&expected_host).expect("valid host"), + ); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&credential.authorization_value()).expect("valid credential"), + ); + let request = RequestEnvelope::new(Action::new(ActionKind::AppPing)); + let response = handle_control_request( + State(state.clone()), + headers, + Bytes::from(serde_json::to_vec(&request).expect("request serializes")), + ) + .await; + assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST); + + let err = issue_credential( + &state, + CredentialRequest::new(ActionKind::AppPing, InvocationContext::OutsideWarp), + ) + .await + .expect_err("narrowed mode should prevent new outside-Warp grants"); + assert_eq!(err.code, ErrorCode::LocalControlDisabled); + }); +} diff --git a/app/src/local_control/resolver.rs b/app/src/local_control/resolver.rs index 81ae2cb3fd..61cfbfadd2 100644 --- a/app/src/local_control/resolver.rs +++ b/app/src/local_control/resolver.rs @@ -76,15 +76,7 @@ pub(super) fn target_window_id_for_target( ) }), Some(WindowTarget::Index { index }) => { - ctx.window_ids().nth(*index as usize).ok_or_else(|| { - ControlError::new( - ErrorCode::StaleTarget, - format!( - "{} cannot resolve the requested window index", - action.as_str() - ), - ) - }) + resolve_index_from_ids(ctx.window_ids(), *index, action) } Some(WindowTarget::Title { title }) => target_window_id_by_title(ctx, title, action), } @@ -137,10 +129,33 @@ fn target_window_id_by_title( matching.push(window_id); } } - match matching.as_slice() { + resolve_title_from_matches(&matching, action) +} + +pub(crate) fn resolve_index_from_ids( + ids: impl Iterator<Item = WindowId>, + index: u32, + action: ActionKind, +) -> Result<WindowId, ControlError> { + ids.into_iter().nth(index as usize).ok_or_else(|| { + ControlError::new( + ErrorCode::MissingTarget, + format!( + "{} cannot resolve the requested window index", + action.as_str() + ), + ) + }) +} + +pub(crate) fn resolve_title_from_matches( + matching: &[WindowId], + action: ActionKind, +) -> Result<WindowId, ControlError> { + match matching { [window_id] => Ok(*window_id), [] => Err(ControlError::new( - ErrorCode::StaleTarget, + ErrorCode::MissingTarget, format!( "{} cannot resolve the requested window title", action.as_str() diff --git a/app/src/settings_view/scripting_page.rs b/app/src/settings_view/scripting_page.rs index a3d464a77d..1c9c3f1a1b 100644 --- a/app/src/settings_view/scripting_page.rs +++ b/app/src/settings_view/scripting_page.rs @@ -168,7 +168,7 @@ impl SettingsWidget for LocalControlModeWidget { appearance, ChildView::new(&view.local_control_mode_dropdown).finish(), Some( - "Enabling scripting allows for programmatic and agentic control of Warp. Please refer to the docs for more info." + "Disabled blocks all Warp control. Enabled within Warp is reserved for verified Warp terminals and currently rejects requests. Enabled everywhere also allows scripts and automation from other apps to control Warp." .to_owned(), ), ) diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 169d24c8b4..db9c303592 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -15,7 +15,7 @@ pub(crate) mod right_panel; mod startup_directory; #[cfg(test)] #[path = "view_tests.rs"] -mod tests; +pub(crate) mod tests; mod vertical_tabs; #[cfg(target_family = "wasm")] mod wasm_view; diff --git a/app/src/workspace/view_tests.rs b/app/src/workspace/view_tests.rs index d32a8e245a..06c9a206d5 100644 --- a/app/src/workspace/view_tests.rs +++ b/app/src/workspace/view_tests.rs @@ -88,7 +88,7 @@ use crate::{ experiments, workspace, AgentNotificationsModel, GlobalResourceHandlesProvider, ObjectActions, }; -fn initialize_app(app: &mut App) { +pub(crate) fn initialize_app(app: &mut App) { initialize_settings_for_tests(app); // Add the necessary singleton models to the App @@ -228,7 +228,7 @@ fn initialize_app(app: &mut App) { app.update(workspace::init); } -fn mock_workspace(app: &mut App) -> ViewHandle<Workspace> { +pub(crate) fn mock_workspace(app: &mut App) -> ViewHandle<Workspace> { let global_resource_handles = GlobalResourceHandles::mock(app); let active_window_id = app.read(|ctx| ctx.windows().active_window()); let (_, workspace) = app.add_window(WindowStyle::NotStealFocus, |ctx| { diff --git a/crates/local_control/Cargo.toml b/crates/local_control/Cargo.toml index 2d4bd75a36..37d2a4e319 100644 --- a/crates/local_control/Cargo.toml +++ b/crates/local_control/Cargo.toml @@ -20,4 +20,5 @@ uuid.workspace = true libc.workspace = true [dev-dependencies] +command.workspace = true tempfile.workspace = true diff --git a/crates/local_control/src/auth_tests.rs b/crates/local_control/src/auth_tests.rs index cd5030913d..484c8f65c0 100644 --- a/crates/local_control/src/auth_tests.rs +++ b/crates/local_control/src/auth_tests.rs @@ -67,6 +67,20 @@ fn scoped_credential_rejects_different_instance() { .expect_err("other instance is rejected"); assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); } +#[test] +fn scoped_credential_rejects_expired_grant() { + let grant = CredentialGrant::new( + InstanceId("inst_test".to_owned()), + ActionKind::TabCreate, + InvocationContext::OutsideWarp, + Duration::minutes(-1), + ); + + let err = grant + .verify_for_action(&grant.instance_id, ActionKind::TabCreate) + .expect_err("expired grant is rejected"); + assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); +} #[test] fn scoped_credential_carries_authenticated_user_metadata() { diff --git a/crates/local_control/src/client.rs b/crates/local_control/src/client.rs index 3281ecbf3d..ec14e1cee7 100644 --- a/crates/local_control/src/client.rs +++ b/crates/local_control/src/client.rs @@ -5,6 +5,14 @@ use crate::protocol::{ Action, ActionKind, ControlError, ControlResponse, ErrorCode, ErrorResponseEnvelope, InvocationContext, RequestEnvelope, ResponseEnvelope, }; +#[cfg(unix)] +use std::io::{Read as _, Write as _}; +#[cfg(unix)] +use std::net::Shutdown; +#[cfg(unix)] +use std::os::unix::net::UnixStream; +#[cfg(unix)] +use std::path::Path; pub fn send_request( instance: &InstanceRecord, @@ -59,39 +67,78 @@ pub fn send_request( )) } -pub fn request_credential( +#[cfg(unix)] +fn request_credential_over_owner_ipc( instance: &InstanceRecord, - action: crate::protocol::ActionKind, - invocation_context: InvocationContext, -) -> Result<ScopedCredential, ControlError> { - instance.validate_local_control_authority()?; - let credential_broker = instance.credential_broker.as_ref().ok_or_else(|| { - ControlError::new( - ErrorCode::LocalControlDisabled, - "outside-Warp local control credential broker is disabled for this instance", + request: &CredentialRequest, +) -> Result<String, ControlError> { + let path = instance.broker_socket_path()?; + request_credential_over_socket(&path, request) +} + +#[cfg(unix)] +fn request_credential_over_socket( + path: &Path, + request: &CredentialRequest, +) -> Result<String, ControlError> { + let mut stream = UnixStream::connect(path).map_err(|err| { + ControlError::with_details( + ErrorCode::TransportUnavailable, + "failed to connect to the owner-authenticated local-control credential broker", + err.to_string(), ) })?; - let client = reqwest::blocking::Client::new(); - let request = CredentialRequest::new(action, invocation_context); - let response = client - .post(credential_broker.endpoint.credential_url()) - .json(&request) - .send() - .map_err(|err| { - ControlError::with_details( - ErrorCode::TransportUnavailable, - "failed to request local-control credential", - err.to_string(), - ) - })?; - let status = response.status(); - let text = response.text().map_err(|err| { + let request = serde_json::to_vec(request).map_err(|err| { + ControlError::with_details( + ErrorCode::InvalidRequest, + "failed to serialize local-control credential request", + err.to_string(), + ) + })?; + stream.write_all(&request).map_err(|err| { + ControlError::with_details( + ErrorCode::TransportUnavailable, + "failed to write local-control credential request", + err.to_string(), + ) + })?; + stream.shutdown(Shutdown::Write).map_err(|err| { + ControlError::with_details( + ErrorCode::TransportUnavailable, + "failed to finish local-control credential request", + err.to_string(), + ) + })?; + let mut response = String::new(); + stream.read_to_string(&mut response).map_err(|err| { ControlError::with_details( ErrorCode::TransportUnavailable, "failed to read local-control credential response", err.to_string(), ) })?; + Ok(response) +} + +#[cfg(not(unix))] +fn request_credential_over_owner_ipc( + _instance: &InstanceRecord, + _request: &CredentialRequest, +) -> Result<String, ControlError> { + Err(ControlError::new( + ErrorCode::LocalControlDisabled, + "outside-Warp local control requires an owner-authenticated credential broker", + )) +} + +pub fn request_credential( + instance: &InstanceRecord, + action: crate::protocol::ActionKind, + invocation_context: InvocationContext, +) -> Result<ScopedCredential, ControlError> { + instance.validate_local_control_authority()?; + let request = CredentialRequest::new(action, invocation_context); + let text = request_credential_over_owner_ipc(instance, &request)?; if let Ok(credential) = serde_json::from_str::<ScopedCredential>(&text) { return Ok(credential); } @@ -100,7 +147,7 @@ pub fn request_credential( } Err(ControlError::with_details( ErrorCode::TransportUnavailable, - format!("local-control credential request failed with HTTP {status}"), + "local-control credential broker returned an invalid response", text, )) } diff --git a/crates/local_control/src/client_tests.rs b/crates/local_control/src/client_tests.rs index 337bc2145a..a7ef15c17d 100644 --- a/crates/local_control/src/client_tests.rs +++ b/crates/local_control/src/client_tests.rs @@ -1,8 +1,52 @@ use chrono::Utc; +#[cfg(unix)] +use std::io::{Read as _, Write as _}; use uuid::Uuid; use super::*; +#[cfg(unix)] +use crate::auth::CredentialGrant; use crate::discovery::{ControlEndpoint, CredentialBrokerReference, InstanceId}; +#[cfg(unix)] +#[test] +fn credential_client_exchanges_request_over_broker_socket() { + let dir = tempfile::tempdir().expect("temp dir"); + let socket_path = dir.path().join("broker.sock"); + let listener = std::os::unix::net::UnixListener::bind(&socket_path).expect("broker binds"); + let grant = CredentialGrant::new( + InstanceId("inst_expected".to_owned()), + ActionKind::AppPing, + InvocationContext::OutsideWarp, + chrono::Duration::minutes(5), + ); + let credential = ScopedCredential { + bearer_token: "scoped-token".to_owned(), + grant, + }; + let expected_request = + CredentialRequest::new(ActionKind::AppPing, InvocationContext::OutsideWarp); + let server_request = expected_request.clone(); + let server_credential = credential.clone(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("broker accepts"); + let mut bytes = Vec::new(); + stream + .read_to_end(&mut bytes) + .expect("broker reads request"); + let request = serde_json::from_slice::<CredentialRequest>(&bytes).expect("request decodes"); + assert_eq!(request, server_request); + serde_json::to_writer(&mut stream, &server_credential).expect("broker writes credential"); + stream.flush().expect("broker flushes credential"); + }); + + let response = request_credential_over_socket(&socket_path, &expected_request) + .expect("credential exchange succeeds"); + server.join().expect("broker server completes"); + assert_eq!( + serde_json::from_str::<ScopedCredential>(&response).expect("response decodes"), + credential + ); +} #[test] fn probe_rejects_mismatched_instance_identity() { @@ -17,7 +61,7 @@ fn probe_rejects_mismatched_instance_identity() { executable_path: None, endpoint: Some(ControlEndpoint::localhost(4000)), credential_broker: Some(CredentialBrokerReference { - endpoint: ControlEndpoint::localhost(4000), + socket_path: "inst_expected.broker.sock".into(), }), outside_warp_control_enabled: true, actions: vec![ActionKind::AppPing.metadata()], diff --git a/crates/local_control/src/discovery.rs b/crates/local_control/src/discovery.rs index 17607c7b33..07428c74a3 100644 --- a/crates/local_control/src/discovery.rs +++ b/crates/local_control/src/discovery.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::protocol::{ActionMetadata, ControlError, ErrorCode, PROTOCOL_VERSION}; const DISCOVERY_DIR_ENV: &str = "WARP_LOCAL_CONTROL_DISCOVERY_DIR"; +const BROKER_SOCKET_SUFFIX: &str = ".broker.sock"; /// Stable identifier for one running Warp instance. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -46,16 +47,12 @@ impl ControlEndpoint { pub fn url(&self) -> String { format!("http://{}:{}/v1/control", self.host, self.port) } - - pub fn credential_url(&self) -> String { - format!("http://{}:{}/v1/control/credentials", self.host, self.port) - } } -/// Discovery reference to the endpoint that issues scoped credentials. +/// Discovery reference to the owner-authenticated socket that issues scoped credentials. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CredentialBrokerReference { - pub endpoint: ControlEndpoint, + pub socket_path: PathBuf, } /// Filesystem-published metadata for a running Warp app process. @@ -83,12 +80,13 @@ impl InstanceRecord { app_version: Option<String>, actions: Vec<ActionMetadata>, ) -> Self { - let credential_broker = endpoint - .clone() - .map(|endpoint| CredentialBrokerReference { endpoint }); + let instance_id = InstanceId::new(); + let credential_broker = endpoint.as_ref().map(|_| CredentialBrokerReference { + socket_path: broker_socket_filename(&instance_id), + }); Self { protocol_version: PROTOCOL_VERSION, - instance_id: InstanceId::new(), + instance_id, pid: std::process::id(), channel: channel.into(), app_id: app_id.into(), @@ -110,7 +108,9 @@ impl InstanceRecord { ) { (false, None, None) => Ok(()), (true, Some(endpoint), Some(credential_broker)) - if endpoint.host == "127.0.0.1" && credential_broker.endpoint == *endpoint => + if endpoint.host == "127.0.0.1" + && credential_broker.socket_path + == broker_socket_filename(&self.instance_id) => { Ok(()) } @@ -120,12 +120,24 @@ impl InstanceRecord { )), } } + + pub fn broker_socket_path(&self) -> Result<PathBuf, ControlError> { + self.validate_local_control_authority()?; + let credential_broker = self.credential_broker.as_ref().ok_or_else(|| { + ControlError::new( + ErrorCode::LocalControlDisabled, + "outside-Warp local control credential broker is disabled for this instance", + ) + })?; + Ok(discovery_dir().join(&credential_broker.socket_path)) + } } /// RAII registration that publishes and removes one discovery record. pub struct RegisteredInstance { record: InstanceRecord, path: PathBuf, + broker_socket_path: Option<PathBuf>, } impl RegisteredInstance { @@ -140,8 +152,16 @@ impl RegisteredInstance { })?; set_private_dir_permissions(&dir)?; let path = record_path(&dir, &record.instance_id); + let broker_socket_path = record + .credential_broker + .as_ref() + .map(|credential_broker| dir.join(&credential_broker.socket_path)); write_record(&path, &record)?; - Ok(Self { record, path }) + Ok(Self { + record, + path, + broker_socket_path, + }) } pub fn record(&self) -> &InstanceRecord { @@ -190,6 +210,9 @@ impl Drop for RegisteredInstance { // reference that is pruned on the next discovery scan. fn drop(&mut self) { let _ = fs::remove_file(&self.path); + if let Some(path) = &self.broker_socket_path { + let _ = fs::remove_file(path); + } } } @@ -259,6 +282,9 @@ fn is_pid_alive(pid: u32) -> bool { fn record_path(dir: &Path, instance_id: &InstanceId) -> PathBuf { dir.join(format!("{}.json", instance_id.0)) } +fn broker_socket_filename(instance_id: &InstanceId) -> PathBuf { + PathBuf::from(format!("{}{BROKER_SOCKET_SUFFIX}", instance_id.0)) +} #[cfg(unix)] fn set_private_dir_permissions(path: &Path) -> Result<(), ControlError> { diff --git a/crates/local_control/src/discovery_tests.rs b/crates/local_control/src/discovery_tests.rs index 86e097a84d..def14b99d8 100644 --- a/crates/local_control/src/discovery_tests.rs +++ b/crates/local_control/src/discovery_tests.rs @@ -1,8 +1,36 @@ +#[cfg(unix)] +use command::blocking::Command; use std::fs; use std::path::Path; use super::*; +#[test] +fn control_endpoint_composes_loopback_control_route() { + assert_eq!( + ControlEndpoint::localhost(4000).url(), + "http://127.0.0.1:4000/v1/control" + ); +} +#[test] +fn broker_socket_reference_is_bound_to_instance_identity() { + let record = InstanceRecord::for_current_process( + Some(ControlEndpoint::localhost(4000)), + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + + assert_eq!( + record + .credential_broker + .expect("credential broker") + .socket_path, + PathBuf::from(format!("{}.broker.sock", record.instance_id.0)) + ); +} + #[test] fn registered_instance_round_trips_discovery_record() { let dir = tempfile::tempdir().expect("temp dir"); @@ -19,6 +47,94 @@ fn registered_instance_round_trips_discovery_record() { assert_eq!(records, vec![record]); } +#[test] +fn incompatible_protocol_record_is_ignored() { + let dir = tempfile::tempdir().expect("temp dir"); + let mut record = InstanceRecord::for_current_process( + Some(ControlEndpoint::localhost(4000)), + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + record.protocol_version = PROTOCOL_VERSION + 1; + let _registered = + RegisteredInstance::register_in_dir_for_test(record, dir.path()).expect("registered"); + + assert!(list_instances_from_dir(dir.path()).is_empty()); +} +#[cfg(unix)] +#[test] +fn stale_process_record_is_pruned() { + let dir = tempfile::tempdir().expect("temp dir"); + let mut child = Command::new("true") + .spawn() + .expect("short-lived process starts"); + let pid = child.id(); + child.wait().expect("short-lived process exits"); + let mut record = InstanceRecord::for_current_process( + Some(ControlEndpoint::localhost(4000)), + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + record.pid = pid; + let registered = + RegisteredInstance::register_in_dir_for_test(record, dir.path()).expect("registered"); + + assert!(list_instances_from_dir(dir.path()).is_empty()); + assert!(!registered.path.exists()); +} +#[cfg(unix)] +#[test] +fn multiple_live_process_records_are_discovered() { + let dir = tempfile::tempdir().expect("temp dir"); + let mut first_process = Command::new("sleep") + .arg("10") + .spawn() + .expect("first process starts"); + let mut second_process = Command::new("sleep") + .arg("10") + .spawn() + .expect("second process starts"); + let mut first_record = InstanceRecord::for_current_process( + Some(ControlEndpoint::localhost(4000)), + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + first_record.pid = first_process.id(); + let mut second_record = InstanceRecord::for_current_process( + Some(ControlEndpoint::localhost(4001)), + "local", + "dev.warp.WarpLocal", + Some("test".to_owned()), + crate::protocol::ActionKind::implemented_metadata(), + ); + second_record.pid = second_process.id(); + let first_id = first_record.instance_id.clone(); + let second_id = second_record.instance_id.clone(); + let _first = RegisteredInstance::register_in_dir_for_test(first_record, dir.path()) + .expect("first registered"); + let _second = RegisteredInstance::register_in_dir_for_test(second_record, dir.path()) + .expect("second registered"); + + let ids = list_instances_from_dir(dir.path()) + .into_iter() + .map(|record| record.instance_id) + .collect::<Vec<_>>(); + assert_eq!(ids.len(), 2); + assert!(ids.contains(&first_id)); + assert!(ids.contains(&second_id)); + + first_process.kill().expect("first process stops"); + first_process.wait().expect("first process reaped"); + second_process.kill().expect("second process stops"); + second_process.wait().expect("second process reaped"); +} + #[test] fn serialized_discovery_record_does_not_contain_raw_credential_material() { let raw_secret = "raw-secret-token-material"; @@ -73,11 +189,10 @@ fn rejects_unsafe_or_divergent_discovery_authority() { .credential_broker .as_mut() .expect("credential broker") - .endpoint - .port = 4001; + .socket_path = "different.broker.sock".into(); let err = record .validate_local_control_authority() - .expect_err("divergent broker endpoint is rejected"); + .expect_err("divergent broker socket is rejected"); assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient); } @@ -133,6 +248,10 @@ impl RegisteredInstance { set_private_dir_permissions(dir)?; let path = record_path(dir, &record.instance_id); write_record(&path, &record)?; - Ok(Self { record, path }) + Ok(Self { + record, + path, + broker_socket_path: None, + }) } } diff --git a/crates/local_control/src/protocol_tests.rs b/crates/local_control/src/protocol_tests.rs index 9bd9ae26a8..f821a05285 100644 --- a/crates/local_control/src/protocol_tests.rs +++ b/crates/local_control/src/protocol_tests.rs @@ -96,6 +96,22 @@ fn core_smoke_metadata_has_explicit_read_metadata_category() { assert_eq!(metadata.target_scope, TargetScope::Instance); } } +#[test] +fn implemented_catalog_is_exactly_the_first_slice() { + let actions = ActionKind::implemented_metadata() + .into_iter() + .map(|metadata| metadata.kind) + .collect::<Vec<_>>(); + assert_eq!( + actions, + vec![ + ActionKind::InstanceList, + ActionKind::AppPing, + ActionKind::AppVersion, + ActionKind::TabCreate, + ] + ); +} #[test] fn action_metadata_serializes_security_categories() { diff --git a/crates/local_control/src/selection_tests.rs b/crates/local_control/src/selection_tests.rs index 4a4ba4ca4b..9399ac06fe 100644 --- a/crates/local_control/src/selection_tests.rs +++ b/crates/local_control/src/selection_tests.rs @@ -16,7 +16,7 @@ fn record(id: &str, pid: u32) -> InstanceRecord { executable_path: None, endpoint: Some(ControlEndpoint::localhost(4000)), credential_broker: Some(CredentialBrokerReference { - endpoint: ControlEndpoint::localhost(4000), + socket_path: format!("{id}.broker.sock").into(), }), outside_warp_control_enabled: true, actions: vec![ActionKind::TabCreate.metadata()], diff --git a/crates/warp_cli/src/local_control/mod.rs b/crates/warp_cli/src/local_control/mod.rs index e426e86c60..5f1de3eeca 100644 --- a/crates/warp_cli/src/local_control/mod.rs +++ b/crates/warp_cli/src/local_control/mod.rs @@ -155,7 +155,7 @@ pub enum AppCommand { /// Check that the selected local Warp app responds. Ping(TargetArgs), - /// Print protocol and app version metadata for the selected local Warp app. + /// Print protocol and build identity metadata for the selected local Warp app. Version(TargetArgs), } diff --git a/crates/warp_cli/src/local_control_tests.rs b/crates/warp_cli/src/local_control_tests.rs index b78c156e87..db993b7650 100644 --- a/crates/warp_cli/src/local_control_tests.rs +++ b/crates/warp_cli/src/local_control_tests.rs @@ -31,6 +31,29 @@ fn parses_first_slice_tab_create() { }; assert_eq!(target.instance.as_deref(), Some("inst_123")); } +#[test] +fn rejects_conflicting_instance_selectors() { + let err = ControlArgs::try_parse_from([ + "warpctrl", + "tab", + "create", + "--instance", + "inst_123", + "--pid", + "123", + ]) + .expect_err("instance and pid conflict"); + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); +} +#[test] +fn parses_pid_instance_selector() { + let args = ControlArgs::try_parse_from(["warpctrl", "app", "ping", "--pid", "123"]) + .expect("pid selector parses"); + let ControlCommand::App(AppCommand::Ping(target)) = args.command else { + panic!("expected app ping command"); + }; + assert_eq!(target.pid, Some(123)); +} #[test] fn parses_first_slice_instance_list() { @@ -47,6 +70,40 @@ fn parses_first_slice_app_smoke_metadata_commands() { assert!(ControlArgs::try_parse_from(["warpctrl", "app", "ping"]).is_ok()); assert!(ControlArgs::try_parse_from(["warpctrl", "app", "version"]).is_ok()); } + +#[test] +fn every_implemented_catalog_action_has_a_parser_route() { + let routes = [ + ( + local_control::protocol::ActionKind::InstanceList, + vec!["warpctrl", "instance", "list"], + ), + ( + local_control::protocol::ActionKind::AppPing, + vec!["warpctrl", "app", "ping"], + ), + ( + local_control::protocol::ActionKind::AppVersion, + vec!["warpctrl", "app", "version"], + ), + ( + local_control::protocol::ActionKind::TabCreate, + vec!["warpctrl", "tab", "create"], + ), + ]; + let implemented = local_control::protocol::ActionKind::implemented_metadata() + .into_iter() + .map(|metadata| metadata.kind) + .collect::<Vec<_>>(); + + assert_eq!( + implemented, + routes.iter().map(|(action, _)| *action).collect::<Vec<_>>() + ); + for (_, args) in routes { + ControlArgs::try_parse_from(args).expect("implemented action parser route exists"); + } +} #[test] fn parses_control_mode_args_after_hidden_flag() { let args = ControlArgs::try_parse_control_mode_from([ diff --git a/script/linux/test_bundle_warpctrl b/script/linux/test_bundle_warpctrl index 7a8e16b077..9764708542 100755 --- a/script/linux/test_bundle_warpctrl +++ b/script/linux/test_bundle_warpctrl @@ -22,3 +22,30 @@ PATH="$temp_dir/bin:$PATH" \ grep -qx -- '--features' "$temp_dir/cargo-args" grep -qx -- 'release_bundle,crash_reporting,agent_mode_debug,jemalloc_pprof,standalone,warp_control_cli,smoke_feature' "$temp_dir/cargo-args" + +profile_dir="$temp_dir/target/release-cli-debug_assertions" +mkdir -p "$profile_dir" +cat > "$profile_dir/dev" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$@" > "$FORWARDED_ARGS_FILE" +EOF +chmod +x "$profile_dir/dev" + +CARGO_TARGET_DIR="$temp_dir/target" \ + NO_LICENSES=1 \ + SKIP_SETTINGS_SCHEMA=1 \ + "$workspace_root/script/linux/bundle" \ + --skip-build \ + --artifact warpctrl + +FORWARDED_ARGS_FILE="$temp_dir/forwarded-args" \ + "$profile_dir/bundle/linux/warpctrl" tab create --instance "inst 123" + +cat > "$temp_dir/expected-forwarded-args" <<'EOF' +--warpctrl +tab +create +--instance +inst 123 +EOF +cmp "$temp_dir/expected-forwarded-args" "$temp_dir/forwarded-args" diff --git a/script/test_warpctrl_early_dispatch b/script/test_warpctrl_early_dispatch new file mode 100755 index 0000000000..646a293981 --- /dev/null +++ b/script/test_warpctrl_early_dispatch @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -e + +workspace_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +discovery_dir="$(mktemp -d)" +trap 'rm -rf "$discovery_dir"' EXIT + +python3 - "$workspace_root" "$discovery_dir" <<'PY' +import os +import pathlib +import subprocess +import sys + +workspace_root = pathlib.Path(sys.argv[1]) +discovery_dir = sys.argv[2] +environment = os.environ.copy() +environment["WARP_LOCAL_CONTROL_DISCOVERY_DIR"] = discovery_dir +result = subprocess.run( + [ + "cargo", + "run", + "-p", + "warp", + "--bin", + "warp", + "--features", + "warp_control_cli", + "--", + "--warpctrl", + "--output-format", + "json", + "instance", + "list", + ], + cwd=workspace_root, + env=environment, + capture_output=True, + text=True, + timeout=180, +) +if result.returncode != 0: + sys.stderr.write(result.stderr) + sys.stderr.write(result.stdout) + raise SystemExit(result.returncode) +if result.stdout.strip() != "[]": + sys.stderr.write(result.stderr) + sys.stderr.write(f"unexpected warpctrl output: {result.stdout!r}\n") + raise SystemExit(1) +PY diff --git a/specs/warp-control-cli/PRODUCT.md b/specs/warp-control-cli/PRODUCT.md index 4289ab7ff8..7f29ae6f79 100644 --- a/specs/warp-control-cli/PRODUCT.md +++ b/specs/warp-control-cli/PRODUCT.md @@ -85,7 +85,7 @@ Persistent settings changes, Warp Drive creation or sharing, cross-app preferenc 15. The complete allowlisted action catalog should be organized around stable public nouns rather than internal view/action names. The target taxonomy includes instances, windows, tabs, panes, terminal sessions, terminal blocks, input buffers, command history entries, file/path intents, Warp Drive spaces, folders, notebooks, workflows, agent-mode workflows/prompts, environment variable collections, AI facts/rules, MCP servers, MCP server collections, settings, themes, keybindings, command surfaces such as the command palette and command search, panels/surfaces such as Warp Drive, resource center, AI assistant, code review, left/right panels, and vertical tabs, plus action/capability metadata. The initial implementation may expose only a subset, but new command families should extend this noun taxonomy instead of inventing unrelated selector conventions. 16. Discovery and read-only state actions: - List instances. - - Get protocol/app version information for one instance. + - Get protocol and build identity metadata for one instance. - List windows, tabs, panes, and sessions. - Get the currently active instance/window/tab/pane/session chain when available. - Inspect enough target metadata to let a script decide what to address next. @@ -229,7 +229,7 @@ The full product should converge on shared selector flags for every command that - `--output-format <pretty|json|ndjson|text>` controls output shape and remains globally available. Within a selector family, specifying more than one form is invalid. For example, `--tab-id` conflicts with `--tab-index`, `--tab-title`, and `--tab`. Omitted lower-level selectors use active defaults only when the resolved target is unambiguous; window-scoped mutations may use the sole existing window when no active window is reported. Explicit IDs must resolve exactly or fail with `stale_target`; index/title/name/path selectors that match zero targets fail with `missing_target`, and selectors that match multiple targets fail with `ambiguous_target`. ### Read-only command set -The read-only branches should implement the following commands before mutating catalog expansion begins: `zach/warp-cli-readonly-metadata` owns structural metadata reads, and `zach/warp-cli-readonly-data-settings` owns underlying-data reads plus read-only settings/appearance/docs. Read-only does not mean one permission: metadata reads and underlying data reads are separate grant categories. +The read-only v2 follow-up branches should implement the following commands before mutating catalog expansion begins. `zach/warp-cli-v2/readonly-capability-targets` owns structural metadata and targeting, while `zach/warp-cli-v2/appstate-file-drive-views` owns approved underlying-data reads and app/file/Drive view surfaces. Read-only does not mean one permission: metadata reads and underlying data reads are separate grant categories. Metadata and capability reads: - `warpctrl instance list` - `warpctrl instance inspect [--instance <id>|--pid <pid>]` @@ -269,18 +269,12 @@ Authenticated read-only Warp Drive metadata and data reads, enabled only when th - `warpctrl drive list --type <workflow|notebook|env-var-collection|prompt|folder|ai-fact|mcp-server|space|trash> [selectors]` - `warpctrl drive inspect <id> [selectors]` ### Authenticated scripting command set -The full product requires two authenticated scripting modes before high-risk underlying-data mutations ship: -- **Verified Warp-terminal authenticated scripting:** `warpctrl` runs inside a Warp-managed terminal, presents the app-issued terminal proof described in `TECH.md`, and may receive authenticated-user grants only when the selected app is logged into Warp and Settings > Scripting allows authenticated actions from verified Warp terminals. -- **External API-key authenticated scripting:** `warpctrl` runs outside Warp or in a pure automation environment and presents a Warp-issued API key or derived short-lived exchange token to the selected app's local broker. The broker verifies the key, scopes, expiry, and user subject before issuing local authenticated-user grants. This path is separate from the local-control bearer credential and is required for unattended scripts that need Drive or execution authority. -Recommended CLI surface for API-key setup and inspection: -- `warpctrl auth status [selectors]` reports local-control auth state, selected app login state, authenticated grant availability, and whether an external API-key identity is configured. +Authenticated actions in the selected public contract are available only to verified Warp-terminal invocations. `warpctrl` presents the app-issued terminal proof described in `TECH.md` and may receive authenticated-user grants only when the selected app is logged into Warp and Settings > Scripting allows authenticated actions from verified Warp terminals. External API-key authenticated scripting and `auth.api_key.*` commands are not allowlisted; adding them requires a separate product/security review and catalog change. +Recommended CLI surface for app-backed authenticated status: +- `warpctrl auth status [selectors]` reports local-control auth state, selected app login state, and verified Warp-terminal authenticated grant availability. - `warpctrl auth login [selectors]` focuses the selected Warp app's sign-in UI for interactive app-login flows. -- `warpctrl auth api-key set --key-env <env_var>|--key-stdin [selectors]` stores or references an external scripting API key in platform secure storage without printing it. -- `warpctrl auth api-key status [selectors]` reports key subject/scope metadata without revealing the key. -- `warpctrl auth api-key revoke [selectors]` deletes the local stored reference and, where supported, revokes the server-side key. -The API-key path must support non-interactive scripts through an environment variable or secret manager reference, but raw keys must never be written to discovery records, logs, JSON output, shell completions, or repo config. ### Mutating command set -The mutating branches should build on the read-only and authenticated-scripting stack. `zach/warp-cli-mutating-layout` owns app/window/tab/pane layout mutations. `zach/warp-cli-mutating-input-settings-surfaces` owns input/session/settings/surface mutations. `zach/warp-cli-mutating-drive-data` owns Warp Drive underlying-data mutations. `zach/warp-cli-mutating-execution-underlying` owns terminal command execution and other approved execution-underlying actions. Mutating commands are split by what they mutate: app-state, metadata/configuration, or underlying data. Underlying data mutations require a separate and stronger permission plus an authenticated scripting identity. +The mutating v2 follow-up branches should build on the shared contract, auth/security, read-only, and targeting layers. `zach/warp-cli-v2/metadata-config-mutations` owns metadata/configuration mutations, `zach/warp-cli-v2/drive-data-mutations` owns Warp Drive underlying-data mutations, and `zach/warp-cli-v2/execution-underlying` owns terminal command execution and other approved execution-underlying actions. Approved app-state mutations and views land in the earliest v2 branch that owns their required targeting and permission prerequisites. Mutating commands are split by what they mutate: app-state, metadata/configuration, or underlying data. Underlying data mutations require a separate and stronger permission plus an authenticated scripting identity. App-state mutations for app, window, and surfaces: - `warpctrl app focus [selectors]` - `warpctrl window create [--shell <name>] [selectors]` @@ -366,16 +360,17 @@ These are underlying-data mutations because they can modify user data, execute c ### Excluded from the public command surface The command surface must continue to exclude debug-only, crash-only, auth-token, heap-dump, and arbitrary internal dispatch actions even when those actions are available in command palette or keybinding registries. Examples that remain excluded are app crash/panic helpers, access-token copy helpers, heap profile dumps, debug reset actions, raw view-tree debugging, and broad internal action-by-string execution. ## Branch stacking and delivery model -The Warp Control CLI work should ship as a raw-git branch stack so the combined specs/foundation slice, read-only expansion, and mutating expansion remain reviewable independently: -- `zach/warp-cli-core-foundation` is the bottom review branch and targets `master`. It owns `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, and supporting docs alongside the first implementation slice: shared protocol, discovery/auth scaffolding, outside-Warp Settings > Scripting gates, local-control bridge/server, `warpctrl` wrapper entrypoint, packaging hooks, and the smallest safe end-to-end action. Verified inside-Warp invocation is documented for future implementation but is not supported by this branch. -- `zach/warp-cli-readonly-metadata` stacks on `zach/warp-cli-core-foundation` and implements structural metadata reads, including instance/app health, active-chain, windows, tabs, panes, sessions, and action metadata. -- `zach/warp-cli-readonly-data-settings` stacks on `zach/warp-cli-readonly-metadata` and fills in underlying-data reads plus read-only settings/appearance/docs, including terminal block output, input-buffer reads, history reads, and allowlisted settings metadata. -- `zach/warp-cli-authenticated-scripting` stacks on `zach/warp-cli-readonly-data-settings` and implements authenticated-user grant plumbing for both verified Warp-terminal invocations and external API-key scripting identities. It does not broaden action support by itself; it makes later high-risk branches enforceable. -- `zach/warp-cli-mutating-layout` stacks on `zach/warp-cli-authenticated-scripting` and implements app/window/tab/pane layout mutations. -- `zach/warp-cli-mutating-input-settings-surfaces` stacks on `zach/warp-cli-mutating-layout` and fills in approved input/session/settings/surface mutating command families while preserving the prohibition on accepted-command submission and agent-prompt submission. -- `zach/warp-cli-mutating-drive-data` stacks on `zach/warp-cli-mutating-input-settings-surfaces` and implements authenticated Warp Drive underlying-data mutations from the approved allowlist, including object creation/update/delete/insert and the v0 personal-to-team sharing path. -- `zach/warp-cli-mutating-execution-underlying` stacks on `zach/warp-cli-mutating-drive-data` and implements authenticated execution-underlying actions such as `input run` and typed workflow execution where supported. -The previous `zach/warp-cli-specs` branch is retained only as migration-source/history material. New spec changes originate on `zach/warp-cli-core-foundation` and are propagated upward through the stack with raw git so all higher implementation branches reflect the same product/security contract. Graphite is not part of this stack. If a lower branch merges first, higher branches should rebase onto the merged successor while preserving the approved spec content. +The Warp Control CLI work should ship as the active raw-git v2 branch stack so the shared contract, security enforcement, read-only expansion, mutating expansion, and final integration remain reviewable independently: +- `zach/warp-cli-v2/contract-spec-sync` is the bottom review branch and targets `master`. It exclusively owns the product, technical, security, and operator specs plus the shared contract/foundation and minimum first-slice smoke path. +- `zach/warp-cli-v2/auth-security` stacks on the contract branch and owns authentication and security enforcement shared across command families. +- `zach/warp-cli-v2/readonly-capability-targets` stacks on auth/security and owns structural metadata, capability/action metadata, selectors, opaque IDs, and read-only target resolution. +- `zach/warp-cli-v2/appstate-file-drive-views` stacks on read-only targeting and owns approved app-state, file-view, Drive-view, and underlying-data-read surfaces without adding local filesystem content operations. +- `zach/warp-cli-v2/metadata-config-mutations` stacks on the approved view/read surfaces and owns allowlisted metadata and configuration mutations. +- `zach/warp-cli-v2/drive-data-mutations` stacks on metadata/configuration mutations and owns authenticated Warp Drive underlying-data mutations. +- `zach/warp-cli-v2/execution-underlying` stacks on Drive mutations and owns authenticated execution-underlying actions. +- `zach/warp-cli-v2/cli-catalog-docs` stacks on the action-family branches and owns final CLI, catalog, completion, documentation, and action-review consistency. +- `zach/warp-cli-v2/fanin-finalize` is the final integration branch used for end-to-end validation and review handoff. +Older pre-recovery branch names are historical source material only and must not be used as the active PR stack. New spec changes originate on `zach/warp-cli-v2/contract-spec-sync` and are propagated upward through raw-git rebases so all higher v2 branches reflect the same product/security contract. Graphite is not part of this stack. If a lower branch merges first, higher branches should rebase onto the merged successor while preserving the approved spec content. ## Built-in Warp Agent skill Warp should include a built-in Agent skill for `warpctrl`, analogous to the bundled `oz-platform` skill. The skill should teach Warp Agent when to use `warpctrl`, how to discover and target running instances, how to prefer read-only commands before mutating commands, how to request explicit user approval for underlying data mutations, and how to interpret structured errors. The skill should document the stable command hierarchy above, include concise recipes for common automation tasks, and avoid instructing agents to bypass the CLI by calling local-control HTTP endpoints directly. ## CLI implementation and documentation conventions @@ -402,9 +397,9 @@ Every action definition must include: - any target-scope restrictions. By default, new actions require an authenticated Warp user. An action may be marked logged-out-safe only after deliberate review confirms it does not touch Warp Drive, AI conversation traces, synced settings, team/account data, cloud-backed user state, terminal content, or other user-sensitive data. ### Authenticated scripting model -Authenticated scripting is required for any command that acts on a true Warp user identity or performs underlying-data mutation. Local-control credentials prove that a process may talk to the selected app; authenticated scripting credentials prove which Warp user or automation identity is allowed to request user-backed or high-risk actions. +Authenticated scripting is required for any command that acts on a true Warp user identity or performs underlying-data mutation. Local-control credentials prove that a process may talk to the selected app; authenticated scripting credentials prove which logged-in Warp user is allowed to request user-backed or high-risk actions. Inside Warp, authenticated scripting uses the verified terminal proof flow: the selected app is already logged in, the terminal proof binds the CLI to a live Warp-managed session, and the broker may mint an authenticated-user grant for that app user when Settings > Scripting allows it. -Outside Warp, authenticated scripting uses a Warp-issued API key or exchanged short-lived token. The API key must be scoped for scripting/local control, optionally constrained to action categories or resource families, and tied to a Warp user subject. The selected app must either be logged in as the same subject or be able to validate that the API key's subject is authorized for the requested local action without exporting cloud auth tokens to the script. External API-key grants default off in Settings > Scripting and should be separable from ordinary outside-Warp logged-out-safe control. +Outside-Warp invocations are limited to actions explicitly classified as logged-out-safe. External authenticated scripting is not part of the selected public contract. ### Permission categories Every action in the catalog belongs to exactly one of the following permission categories, from least to most sensitive: 1. **Read-only / metadata.** Actions that return local app structure, app state, or configuration metadata without exposing terminal content, file content, Warp Drive object content, AI conversation content, or other user data. @@ -436,14 +431,13 @@ The allowlist must clearly indicate `requires_authenticated_user` for every acti - `true` for actions that execute user-authored Warp Drive content, even if the execution target is a local terminal session. If an authenticated-user action is invoked while the selected app has no logged-in user, the CLI reports a structured authenticated-user error. It must not silently return partial logged-out data as success. ### Warp Control authenticated scripting protocol -`warpctrl` has two authenticated scripting modes. Interactive inside-Warp use relies on the logged-in user in the selected Warp app and verified terminal proof. External or pure scripting use relies on a Warp-issued API key that is separate from local-control credentials and is exchanged for short-lived authenticated grants. -The CLI should expose auth/status flows for both modes: +Authenticated scripting relies on the logged-in user in the selected Warp app and verified terminal proof. The CLI should expose app-backed auth/status flows: - `warpctrl auth status [selectors]` reports whether the selected Warp app is logged in and returns a stable, non-secret user subject/identity summary when the caller has the required local-control grant. - `warpctrl auth login [selectors]` does not collect credentials in the CLI or mint a separate CLI account session. It focuses or opens the selected Warp app's normal sign-in UI and waits, or exits with instructions, until the user completes sign-in in that app. -- After app login completes, the app-side credential broker may mint an app-user grant only for the same user subject that is currently logged in to the selected app. For external API-key mode, the broker may mint an API-key-backed grant only after validating the key, scopes, subject, and local Scripting permissions. +- After app login completes, the app-side credential broker may mint an app-user grant only for the same user subject that is currently logged in to the selected app and a verified Warp-terminal invocation. - Authenticated credentials are bound to the selected app instance, subject, grant mode, scopes, expiry, and optional target/resource restrictions. If the app logs out, switches users, loses auth state, or the grant's subject no longer matches a grant that requires the selected app's logged-in subject, authenticated actions fail with a structured authenticated-user error rather than using stale authority. -- Raw Firebase, server, OAuth, cloud API tokens, and raw scripting API keys are never exported to `warpctrl` output, shell scripts, generated docs, logs, discovery records, or JSON responses. -This authenticated scripting protocol applies only to actions whose allowlist entry requires a true logged-in Warp user, external API-key identity, or underlying-data-mutation authority. Logged-out-safe local actions continue to use local-control credentials without requiring Warp account login or API-key setup. +- Raw Firebase, server, OAuth, and cloud API tokens are never exported to `warpctrl` output, shell scripts, generated docs, logs, discovery records, or JSON responses. +This authenticated scripting protocol applies only to actions whose allowlist entry requires a true logged-in Warp user or underlying-data-mutation authority. Logged-out-safe local actions continue to use local-control credentials without requiring Warp account login. ### Execution context policy `warpctrl` should eventually distinguish verified invocations from inside Warp-managed terminal sessions from external invocations. The current foundation branch implements the setting shape for both contexts, supports external invocation only when the user explicitly enables the broadest mode, and must reject verified Warp-terminal claims until the proof broker is implemented. - **Verified Warp-terminal invocation:** a `warpctrl` process started inside a Warp-managed terminal session and able to present an app-issued execution-context proof. This is allowed when the user selects **Enabled within Warp** or the broadest mode after the proof broker exists; the default disabled mode blocks it. When the selected app has a logged-in Warp user, this context can receive authenticated-user grants if the selected mode allows the context and the action's catalog policy allows that grant. diff --git a/specs/warp-control-cli/README.md b/specs/warp-control-cli/README.md index 47c31fd6df..cc71241f09 100644 --- a/specs/warp-control-cli/README.md +++ b/specs/warp-control-cli/README.md @@ -4,9 +4,9 @@ The first implementation slice is intentionally narrow: - discover compatible running Warp instances; - select one instance implicitly when unambiguous or explicitly with `--instance`; - request brokered scoped local-control credentials for the selected instance; -- check app health and protocol compatibility with `warpctrl app ping` and `warpctrl app version`; +- check app health and get protocol and build identity metadata with `warpctrl app ping` and `warpctrl app version`; - create a new terminal tab with `warpctrl tab create`. -The local-control protocol and catalog are broader than this slice, but commands outside the implemented capability set should fail with structured unsupported-action errors until their handlers land. +The local-control protocol and catalog are broader than this slice. Protocol requests for actions outside the implemented capability set fail with structured unsupported-action errors until their handlers land; command names that do not have a shipped CLI parser route use Clap's normal parser error behavior. ## Packaging model `warpctrl` should be packaged as an Oz-style wrapper script rather than a standalone Rust binary. The wrapper should resolve the installed channel-specific Warp executable and invoke it with the hidden `--warpctrl` control-mode flag: - `crates/local_control` owns discovery records, local authentication material, client transport, protocol envelopes, action names, and error types. @@ -45,7 +45,7 @@ Use matching app and CLI bits from the same branch or release artifact so the pr ```bash warpctrl instance list ``` -4. Confirm app health and protocol compatibility: +4. Confirm app health and inspect protocol and build identity metadata: ```bash warpctrl app ping warpctrl app version @@ -74,9 +74,9 @@ Expected failures: ## Security model The local-control protocol is designed for same-user scripting, not cross-user or network access. The trust boundary is the local user account. - **Loopback-only listener.** Each Warp process binds its control server to `127.0.0.1` on an ephemeral port. The listener is not reachable from the network. -- **Brokered scoped credentials.** Discovery records contain instance metadata, endpoint information, and credential-broker references only when the selected Scripting mode allows that invocation context. Published control and broker endpoints must both be exactly `127.0.0.1` and equal to each other. Records must not contain raw bearer tokens or reusable full-access credentials. -- **Short-lived grants.** `warpctrl` requests an action-scoped credential from `/v1/control/credentials` for the selected instance and invocation context, then presents that credential to `/v1/control`. Grants are instance-bound, expired entries are pruned, and the in-memory grant set is capped. Missing, invalid, expired, revoked, wrong-instance, or insufficient-scope credentials are rejected before handler dispatch. -- **Protected credential material.** Raw local-control secrets live in platform secure storage where available, with owner-only local-state fallbacks documented as weaker. On POSIX platforms, discovery records and fallback local state must use owner-only permissions. On Windows, records must be stored under the current user's app data directory with an ACL that grants access only to the current user, Administrators, and SYSTEM. +- **Brokered scoped credentials.** Discovery records contain instance metadata, loopback control-endpoint information, and an instance-bound Unix-domain-socket broker reference only when the selected Scripting mode allows outside-Warp control. The broker authenticates the connecting OS user with kernel peer credentials before decoding the credential request or issuing an action-scoped credential. Records do not contain bearer tokens or reusable full-access credentials. +- **Short-lived grants.** `warpctrl` requests an action-scoped credential over the owner-authenticated broker socket for the selected instance and invocation context, then presents that credential to `/v1/control`. Grants are instance-bound, expired entries are pruned, and the in-memory grant set is capped. Missing, invalid, expired, revoked, or wrong-instance credentials are rejected before request decoding. After decoding identifies the requested action, insufficient-scope credentials are rejected before selector resolution or handler dispatch. +- **Protected local state.** The authoritative Scripting mode uses platform secure storage where available. During migration, an existing private-preferences value may remain as an explicitly weaker owner-only fallback when secure storage is unavailable; it remains private, local-only, and never cloud-synced. On POSIX platforms, discovery records, broker sockets, and fallback local state use owner-only permissions. On Windows, outside-Warp publication remains disabled until equivalent ACL and broker protections are implemented. - **Stale-record pruning.** On each `instance list` or implicit discovery call, records whose PID is no longer alive are deleted automatically. Candidates are also health-probed and accepted only when the live app reports the expected instance identity. - **No CORS.** The control endpoints do not set permissive CORS headers, so browser-origin JavaScript cannot read responses even if it guesses the port. The credential requirement provides a second layer since browsers cannot read the brokered credential material. ```mermaid @@ -88,16 +88,17 @@ sequenceDiagram participant Bridge as App bridge CLI->>FS: Read discovery records (user-only permissions / ACL) - FS-->>CLI: instance_id, endpoint, credential broker metadata + FS-->>CLI: instance_id, loopback endpoint, broker socket reference CLI->>CLI: Prune stale PIDs, select instance - CLI->>Broker: POST /v1/control/credentials<br/>action + context + instance - Broker->>Broker: Check Settings > Scripting mode, context, scopes + CLI->>Broker: Connect to Unix socket<br/>action + context + instance + Broker->>Broker: Authenticate peer OS user before decode;<br/>check Settings > Scripting mode, context, scopes alt Disabled, invalid, or insufficient scope Broker-->>CLI: Structured denial else Grant allowed Broker-->>CLI: Short-lived scoped credential CLI->>HTTP: POST /v1/control<br/>Authorization: Bearer <scoped credential> - HTTP->>HTTP: Verify grant and action scope + HTTP->>HTTP: Verify credential expiry + instance binding before decode + HTTP->>HTTP: Decode typed request; verify action scope HTTP->>Bridge: Dispatch action to app context Bridge-->>HTTP: Structured result or error HTTP-->>CLI: JSON response envelope @@ -107,9 +108,9 @@ sequenceDiagram - Windows outside-Warp local-control publication is disabled until discovery-record ACL creation and validation are implemented. - The current low-risk first slice permits reuse of an unexpired scoped grant. A replay policy is required before broader or higher-risk command families ship. - Same-user malicious software can still invoke trusted wrappers or automate the desktop, so brokered credentials are least-privilege guardrails rather than a complete hostile same-user sandbox. -- Once higher-risk handlers land, the same-user boundary becomes more sensitive. Consider per-request nonces, stricter platform secure-storage constraints, or Unix domain sockets with `SO_PEERCRED` for stronger caller identity where available. +- Once higher-risk handlers land, the same-user boundary becomes more sensitive. Consider per-request nonces, stricter platform secure-storage constraints, and stronger approval or policy gates. ## Documentation review notes - Treat `warpctrl` as provisional executable naming until packaging signs off on final artifact aliases. -- Keep examples scoped to discovery, app health/version, and `tab create` until additional app-side handlers are implemented. +- Keep examples scoped to discovery, app health, protocol and build identity metadata, and `tab create` until additional app-side handlers are implemented. - Do not document catalog commands as usable just because they exist in protocol enums or parser scaffolding; operator docs should distinguish implemented commands from planned allowlist entries. - Windows packaging may initially follow the existing helper-wrapper pattern. Update this README when that decision is final. diff --git a/specs/warp-control-cli/SECURITY.md b/specs/warp-control-cli/SECURITY.md index 5a5f6b4895..b2df4d25bf 100644 --- a/specs/warp-control-cli/SECURITY.md +++ b/specs/warp-control-cli/SECURITY.md @@ -1,10 +1,10 @@ # warpctrl security architecture `warpctrl` is a local-control CLI for an already-running Warp app instance. Its security architecture is designed to support the control catalog: discovery, structural metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, underlying data mutations, input-buffer staging, file/path app-state intents, Warp Drive operations, and explicitly authenticated execution-underlying actions. Accepted-command submission and agent-prompt submission remain future high-risk capabilities that require separate product/security review. Local file content operations are intentionally excluded from the public `warpctrl` catalog because native agent file tools are the preferred surface for file content reads and writes. -The correct architecture is not a single shared localhost bearer token with client-side conventions. The CLI, app bridge, and protocol must treat security as a local app-enforced capability system: discovery finds compatible instances, secure storage protects raw credential material, broker-issued credentials identify the granted scopes, the running Warp app's local-control bridge enforces action categories before dispatch, and target resolution never silently retargets a request. +The correct architecture is not a single shared localhost bearer token with client-side conventions. The CLI, app bridge, and protocol must treat security as a local app-enforced capability system: discovery finds compatible instances, protected storage safeguards the authoritative mode and any future long-lived proof secrets, broker-issued credentials identify the granted scopes, the running Warp app's local-control bridge enforces action categories before dispatch, and target resolution never silently retargets a request. The action-category model is primarily a safety and intent mechanism, not a hard security boundary against malicious same-user software. It lets a user, script, or agent intentionally request metadata-only, data-read, app-state mutation, metadata/configuration mutation, or underlying-data mutation access so it does not accidentally mutate state, expose sensitive content, or execute commands. It should not be described as strong access control against a process that can already run arbitrary commands as the user. -`warpctrl` has two distinct authorization dimensions: local-control authority and authenticated scripting authority. Local-control authority proves the request is allowed to control the local app. Authenticated scripting authority proves the Warp user or automation identity that is allowed to act on user-authenticated data such as Warp Drive objects, AI conversation traces, synced settings, cloud-backed user state, and execution-underlying actions. Logged-out users should retain a smaller local-only control surface, but authenticated-user and high-risk underlying-data mutation actions require either a verified Warp-terminal grant tied to the selected app's logged-in user or an external Warp-issued API-key grant. +`warpctrl` has two distinct authorization dimensions: local-control authority and authenticated scripting authority. Local-control authority proves the request is allowed to control the local app. Authenticated scripting authority proves the logged-in Warp user that is allowed to act on user-authenticated data such as Warp Drive objects, AI conversation traces, synced settings, cloud-backed user state, and execution-underlying actions. Logged-out users should retain a smaller local-only control surface, but authenticated-user and high-risk underlying-data mutation actions require a verified Warp-terminal grant tied to the selected app's logged-in user. ## Current foundation status -The current foundation implementation stores a single local-control mode with three choices: disabled by default, enabled within Warp, and enabled everywhere including outside Warp. Verified inside-Warp invocation is specified as future work because the app-issued terminal-session proof broker, proof injection path, and session registry do not exist yet. Until those pieces land, `InvocationContext::InsideWarp` requests must be rejected with `execution_context_not_allowed`, implemented action metadata must not advertise inside-Warp support, and external requests must receive credentials only when the user selects the broadest mode. Windows outside-Warp publication remains disabled until discovery-record ACL enforcement lands. +The current foundation implementation stores a single local-control mode with three choices: disabled by default, enabled within Warp, and enabled everywhere including outside Warp. Verified inside-Warp invocation is specified as future work because the app-issued terminal-session proof broker, proof injection path, and session registry do not exist yet. Until those pieces land, `InvocationContext::InsideWarp` requests must be rejected with `execution_context_not_allowed`, implemented action metadata must not advertise inside-Warp support, and external requests must receive credentials only when the user selects the broadest mode. On Unix, the broker authenticates the connecting OS user through kernel peer credentials before decoding credential requests, then mints short-lived scoped credentials in memory without a stored or bootstrap local-control secret. The current broker therefore trusts the owning OS user rather than authenticating Warp-signed client code. Windows outside-Warp publication remains disabled until discovery-record ACL enforcement and an equivalent authenticated broker transport land. ## Security goals - Allow trusted local users and approved automation to control a running Warp instance through a stable, scriptable interface. - Prevent unauthenticated localhost clients from invoking read or mutating control actions. @@ -14,13 +14,13 @@ The current foundation implementation stores a single local-control mode with th - Require explicit in-app user enablement before local control scripting from outside Warp can issue credentials or accept control requests. - Allow local control scripting from verified Warp-managed terminal sessions once proof verification exists and the user selects a mode that permits that context, subject to action policy. - Store the authoritative local-control mode in protected local storage so external apps cannot enable outside-Warp control by editing ordinary settings. -- Keep raw credential material out of plaintext discovery records and protect it with platform secure storage where available. +- Keep credentials out of plaintext discovery records, mint the current short-lived local-control credentials in memory, and protect any future long-lived proof or bootstrap secrets with platform secure storage where available. - Distinguish verified `warpctrl` invocations that originate from a Warp-managed terminal session from external same-user invocations. - When the broadest mode enables outside-Warp control, allow external invocations only for the action set explicitly allowed by the action catalog and granted credential. - Allow in-Warp invocations to receive authenticated-user grants when the selected Warp app has a true logged-in user and the user's local-control mode and action policy permit that grant. - Support least-privilege safety modes for automation and interactive use without relying on an unenforceable identity label. - Classify every action by state/data category and enforce the required permission category in the local app bridge, not in the CLI frontend. -- Classify every action by whether it requires an authenticated Warp user. New actions should default to requiring an authenticated user unless they are deliberately reviewed as safe for logged-out or external use. +- Classify every action by whether it requires an authenticated Warp user. New actions should default to requiring an authenticated user unless they are deliberately reviewed as logged-out-safe and therefore eligible for external use. - Prevent `warpctrl` from becoming an ambient full-power confused deputy that any same-user process can invoke for high-risk actions. - Require authenticated scripting identity, underlying-data-mutation permission, explicit target resolution, and audit records before terminal command execution or typed workflow execution can ship; accepted-command submission and agent-prompt submission remain prohibited until separately reviewed. - Preserve deterministic targeting so a request never silently mutates or reads the wrong window, tab, pane, session, file/path intent, or Warp Drive object. @@ -37,11 +37,11 @@ The local-control design can provide meaningful protection for those cases by bi The boundary is much weaker for a different local app running as the same OS user. Same-user local apps may already have access to user-owned files such as logs, may be able to observe the screen or UI through OS permissions such as Accessibility or Screen Recording, and can often invoke user-installed command-line tools. `warpctrl` should not imply strong isolation from such software. For same-user local apps, the realistic goal is narrower: - do not leave a raw bearer token in plaintext discovery records; -- prevent arbitrary direct HTTP calls to the localhost control listener by requiring a credential those apps cannot simply read; -- use platform secure storage, such as macOS Keychain, so raw credentials are accessible only to Warp-owned signed code where practical; +- prevent ambient direct HTTP calls to the localhost control listener by requiring a just-in-time broker-issued scoped credential; +- use platform secure storage, such as macOS Keychain, for future long-lived proof or bootstrap secrets so they are accessible only to Warp-owned signed code where practical; - make high-risk operations go through `warpctrl` or a Warp-owned helper where user approval, configured policy, and safety grants can be applied; - avoid giving `warpctrl` ambient non-interactive full-control authority. -In other words, the security model can make arbitrary direct localhost protocol calls fail, and it can make direct credential theft harder. It cannot make a same-user malicious app safe if that app can invoke `warpctrl`, automate the user's desktop, read other local state, or wait for the user to approve prompts. +In other words, the security model can make ambient direct localhost protocol calls fail, and future protected secrets can make direct credential theft harder. The current Unix broker still allows any process running as the owning OS user to request eligible scoped credentials. It cannot make a same-user malicious app safe if that app can invoke `warpctrl`, connect to the broker, automate the user's desktop, read other local state, or wait for the user to approve prompts. ## Comparison with other local scripting models Other developer tools expose local automation through a few recurring patterns. The `warpctrl` design should borrow the parts that match Warp's needs while avoiding designs that assume localhost or same-user access is enough by itself. ### VS Code @@ -96,7 +96,7 @@ Warp control has one top-level mode setting based on invocation context: - **Enabled everywhere, including outside Warp:** controls verified Warp-managed terminal invocations and external terminals, scripts, launch agents, IDEs, or other same-user processes. The mode should live in a new top-level Settings pane page named **Scripting**. The Scripting page owns the user-facing controls for local scripting surfaces, including Warp control, and should explain the difference between commands run inside Warp and commands run from other apps. The visible UI setting is not enough by itself. The authoritative mode must be stored in the most secure local storage provider available for the platform, with read/write access limited to the Warp application or Warp-owned trusted helper code where the platform supports that restriction. On macOS this means Keychain or an equivalent protected store constrained to Warp-signed code, not ordinary UserDefaults; on Windows this means Credential Manager, DPAPI-backed protected storage, or an equivalent app-controlled protected store; on Linux this means the platform secret service where available, with any owner-only file fallback explicitly documented as weaker. This avoids turning outside-Warp control into a feature that any process can silently enable before invoking `warpctrl`. -Current foundation implementation note: the mode is represented in the typed `LocalControlSettings` group but is persisted through Warp's secure storage provider rather than ordinary private preferences, `settings.toml`, SQLite, or a synced cloud preference. The implemented setting must use `SyncToCloud::Never`, remain absent from user-visible settings files, generated schemas, Settings Sync, Warp Drive, local-control settings read/write commands, and user-editable or server-backed settings surfaces, and should keep migrating any earlier private-preferences value into secure storage. This is a tamper-resistant platform storage boundary, not a claim that arbitrary same-user compromise is impossible; platforms without a secure provider must document the weaker fallback. +Current foundation implementation note: the mode is represented in the typed `LocalControlSettings` group and prefers Warp's secure storage provider over ordinary private preferences, `settings.toml`, SQLite, or a synced cloud preference. The implemented setting uses `SyncToCloud::Never` and remains absent from user-visible settings files, generated schemas, Settings Sync, Warp Drive, local-control settings read/write commands, and user-editable or server-backed settings surfaces. During migration, an earlier private-preferences value is cleared only after the protected write succeeds; when secure storage is unavailable, the value is intentionally preserved as an explicitly weaker owner-only private-preferences fallback. This is a tamper-resistant platform storage preference, not a claim that arbitrary same-user compromise is impossible. Enablement requirements: - The mode is local-only and must not sync through Settings Sync, Warp Drive, or server-backed user preferences. - The implemented foundation setting must remain private and absent from user-visible settings files, generated schemas, local-control settings read/write commands, and any allowlisted settings mutation catalog. @@ -116,12 +116,12 @@ Disabled-state behavior: These enablement gates do not create perfect same-user malicious-app isolation. A hostile process with Accessibility or Screen Recording permission might still try to automate the Warp UI. The outside-Warp gate is still important because it closes the much easier paths where external apps silently edit local preferences, call a config CLI, or write synced settings to enable a powerful control surface. ### Permission categories and grants The foundation stack should not expose separate per-risk toggles under Settings > Scripting. Once the selected mode allows a request context, the broker and app bridge still enforce each action's catalog classification and the credential's grants: -- **Metadata reads:** inspect non-sensitive local app structure and configuration metadata such as instances, windows, tabs, panes, app version, theme names, setting keys, action metadata, and Drive object IDs/names/types without content. +- **Metadata reads:** inspect non-sensitive local app structure and configuration metadata such as instances, windows, tabs, panes, protocol and build identity metadata, theme names, setting keys, action metadata, and Drive object IDs/names/types without content. - **Underlying data reads:** read terminal output, scrollback, input buffers, command history, session traces, Warp Drive object contents, AI conversation content, and other content-bearing state. - **App-state mutations:** change local UI/layout/focus such as opening windows, creating tabs, closing tabs, focusing panes, splitting panes, opening panels, opening files/views, and staging text in the input buffer without executing it. - **Metadata/configuration mutations:** change persistent metadata or configuration such as tab/pane names, tab colors, themes, font size, zoom, allowlisted settings, and keybindings. - **Underlying data mutations:** mutate Warp Drive objects, share personal objects to a team, mutate AI conversation data, run terminal commands, run typed workflows, or perform any other allowlisted action that can change user data or cause external side effects. -The single mode setting is an invocation-context gate, not a replacement for action classification. App-state mutation permission must not imply metadata/configuration mutation or underlying data mutation permission. Authenticated-user actions remain separately gated by verified Warp-terminal or external API-key identity and by the selected app's logged-in user state where required. +The single mode setting is an invocation-context gate, not a replacement for action classification. App-state mutation permission must not imply metadata/configuration mutation or underlying data mutation permission. Authenticated-user actions remain separately gated by verified Warp-terminal identity and by the selected app's logged-in user state where required. ## Trust boundaries `warpctrl` has several distinct trust boundaries. ### Operating-system user boundary @@ -135,27 +135,21 @@ Acceptable designs include a short-lived per-session capability, an app-owned br Verified in-Warp context can raise the maximum eligible grant set, especially for authenticated-user actions. It does not by itself bypass the selected local-control mode, action categories, target scopes, or logged-in-user requirements. ### Authenticated scripting boundary Actions that touch user-authenticated Warp data or perform high-risk underlying-data mutations require authenticated scripting authority. This includes Warp Drive object contents, object mutation, the v0 personal-to-team sharing path, AI conversation traces, cloud-backed user settings, team/account data, typed workflow execution, terminal command execution, and any other surface whose normal app access depends on the user's Warp account or can cause external side effects. -There are two supported authenticated scripting modes: -- **Verified Warp-terminal mode:** `warpctrl` presents an app-issued terminal-session proof. If the selected app is logged into Warp and Settings > Scripting mode plus action policy permit authenticated actions from verified Warp terminals, the broker may mint an authenticated-user grant tied to the selected app's current user subject. -- **External API-key mode:** `warpctrl` presents a Warp-issued scripting API key or a short-lived token exchanged from that key. If the broadest mode and external authenticated grants are enabled, the broker verifies the key, scopes, expiry, revocation state, and user subject before minting a local authenticated-user grant. -For app-backed authenticated actions, the app bridge should execute on behalf of the selected app's logged-in user through existing app auth state. For explicitly API-key-backed actions, the API key subject and scopes must be recorded in the local grant and the handler must not export raw Firebase, server, OAuth, or cloud API tokens to shell scripts. If the selected app logs out, switches users, or no longer matches a grant that requires app-user identity, authenticated actions fail with structured errors rather than falling back to logged-out behavior. +Authenticated scripting uses verified Warp-terminal mode: `warpctrl` presents an app-issued terminal-session proof. If the selected app is logged into Warp and Settings > Scripting mode plus action policy permit authenticated actions from verified Warp terminals, the broker may mint an authenticated-user grant tied to the selected app's current user subject. External API-key authenticated scripting is not part of the selected public contract and requires a separate product/security review before it can be allowlisted. +For app-backed authenticated actions, the app bridge should execute on behalf of the selected app's logged-in user through existing app auth state. If the selected app logs out, switches users, or no longer matches a grant that requires app-user identity, authenticated actions fail with structured errors rather than falling back to logged-out behavior. Logged-out users may still use the smaller local-only action set explicitly marked as not requiring authenticated scripting authority. ### Authenticated scripting protocol -`warpctrl` should provide auth/status flows for both interactive app login and external API-key automation. The CLI must not collect Warp passwords and must not print or persist raw API keys outside approved secret storage. +`warpctrl` should provide auth/status flows for interactive app login. The CLI must not collect Warp passwords. Requirements: -- `warpctrl auth status [selectors]` reports whether the selected app instance is logged in, whether verified Warp-terminal authenticated grants are available, and whether an external API-key identity is configured. It may return stable, non-secret subject/scope metadata when the caller has the required grant. +- `warpctrl auth status [selectors]` reports whether the selected app instance is logged in and whether verified Warp-terminal authenticated grants are available. It may return stable, non-secret subject/scope metadata when the caller has the required grant. - `warpctrl auth login [selectors]` focuses or opens the selected Warp app's normal sign-in UI and waits, or exits with actionable instructions, until the user signs in through Warp itself. -- `warpctrl auth api-key set --key-env <env_var>|--key-stdin [selectors]` stores or references a Warp-issued scripting API key in platform secure storage. Non-interactive scripts may provide the key through a secret-manager-injected environment variable. -- `warpctrl auth api-key status [selectors]` reports non-secret subject, expiry, and scope metadata for the configured API key. -- `warpctrl auth api-key revoke [selectors]` removes the local key reference and revokes the server-side key where supported. - The credential broker may mint an app-user authenticated grant only after confirming the selected app has a true logged-in Warp user and the selected mode plus action policy allow the verified invocation context. -- The credential broker may mint an external API-key grant only after validating the key or exchanging it for a short-lived assertion, confirming that the broadest mode and external authenticated grants are enabled, and checking that the key scope covers the requested action family and permission category. - Authenticated credentials are bound to the selected instance, subject, grant mode, scopes, expiry, and optional target/resource restrictions. If the app logs out, switches users, loses authenticated state, or the presented credential subject no longer matches a grant that requires app-user identity, authenticated actions fail with `authenticated_user_mismatch` or another structured authenticated-user error. -- Raw Firebase, server, OAuth, cloud API tokens, and raw API keys must not be exported to `warpctrl` output, shell completions, generated docs, logs, discovery records, or local-control JSON responses. +- Raw Firebase, server, OAuth, and cloud API tokens must not be exported to `warpctrl` output, shell completions, generated docs, logs, discovery records, or local-control JSON responses. Logged-out-safe actions continue to use local-control credentials without requiring authenticated scripting identity. ### Application identity boundary -On platforms with secure credential storage, especially macOS, the raw local-control credential should be readable only by Warp-owned, correctly signed code. On macOS this means storing raw credential material in Keychain with access constrained by Warp's signing identity, designated requirement, Keychain access group, or equivalent platform mechanism. This narrows token extraction from “any same-user process can read a file” to “only trusted Warp-signed code can unwrap the secret.” -This boundary protects the credential from direct theft and prevents arbitrary apps from making authenticated raw HTTP requests to the local-control listener. It also lets the authoritative mode be stored somewhere harder to modify than ordinary user preferences. It does not prove that the user personally intended the specific action. Any same-user process may still be able to invoke the trusted `warpctrl` binary or automate the Warp UI. That confused-deputy risk is reduced by explicit in-app enablement, scoped credential issuance, action-category policy, and local app-side bridge enforcement, but it is not eliminated as a hard same-user security boundary. +On platforms with secure credential storage, especially macOS, future long-lived proof or bootstrap secrets should be readable only by Warp-owned, correctly signed code. On macOS this means storing that material in Keychain with access constrained by Warp's signing identity, designated requirement, Keychain access group, or equivalent platform mechanism. This narrows extraction from “any same-user process can read a file” to “only trusted Warp-signed code can unwrap the secret.” +This future boundary protects stored secrets from direct theft and can prevent arbitrary apps from using those secrets to make authenticated raw HTTP requests to the local-control listener. It also lets the authoritative mode be stored somewhere harder to modify than ordinary user preferences. The current Unix foundation does not implement this application-identity boundary for local-control credential issuance: it verifies the broker peer's OS user and mints short-lived credentials in memory. Neither model proves that the user personally intended the specific action. Any same-user process may still be able to invoke the trusted `warpctrl` binary or automate the Warp UI. That confused-deputy risk is reduced by explicit in-app enablement, scoped credential issuance, action-category policy, and local app-side bridge enforcement, but it is not eliminated as a hard same-user security boundary. ### Action boundary Every action belongs to a state/data category. The bridge must map the requested action to a required permission category and compare that category to the presented credential before selector resolution or handler dispatch. ### Target boundary @@ -184,7 +178,7 @@ The full security model has eight layers. The current foundation branch implemen The security model has eight layers: 1. **Protected enablement:** Use protected local storage for the single local-control mode, with all contexts disabled by default, inside-Warp allowed only when the user selects the within-Warp or broadest mode after proof support lands, and outside-Warp off unless the broadest mode is selected. 2. **Discovery:** Find compatible live Warp instances without granting broad authority. -3. **Secure credential storage:** Store raw secrets outside plaintext discovery records and restrict access to trusted Warp-owned code where the platform supports it. +3. **Secret handling:** Mint the current short-lived local-control credentials in memory, keep all secrets outside plaintext discovery records, and restrict future stored proof or bootstrap secrets to trusted Warp-owned code where the platform supports it. 4. **Execution context verification:** Distinguish verified Warp-terminal invocations from external same-user invocations without trusting caller-declared labels. 5. **Credential issuance:** Issue scope-specific credentials with explicit grants and lifetimes only when the selected mode allows the request's invocation context and the requested action/category is allowed. 6. **Transport authentication:** Reject disabled or unauthenticated requests before reading or mutating app state. @@ -198,7 +192,6 @@ sequenceDiagram participant Enablement as Protected enablement state participant Context as Execution context proof participant Broker as Credential broker - participant Store as Secure credential storage participant Auth as App auth state participant HTTP as Warp control listener participant Bridge as App bridge + safety policy @@ -219,8 +212,7 @@ sequenceDiagram Broker->>Auth: Verify logged-in Warp user + setting Auth-->>Broker: User subject or unavailable end - Broker->>Store: Load or unwrap raw secret with Warp-signed access - Store-->>Broker: Raw secret or credential capability + Broker->>Broker: Mint short-lived scoped credential in memory Broker-->>CLI: Scoped credential with grants, context, user scope, expiry CLI->>HTTP: Authenticated typed request HTTP->>Bridge: Verify credential and protocol envelope @@ -255,7 +247,7 @@ Discovery rules: ## Credential model The full `warpctrl` catalog requires scoped credentials. A single shared full-power bearer token is not sufficient once automation, underlying data reads, app-state mutations, metadata/configuration mutations, and underlying data mutations are supported. ### Credential properties -Current foundation implementation note: `warpctrl` discovers an endpoint and then requests a short-lived credential from `/v1/control/credentials` for the specific action it is about to invoke. The discovery record publishes endpoint and broker metadata only; it does not contain bearer tokens, raw credential material, or a stored credential that the CLI unwraps and sends to the discovered port. +Current foundation implementation note: `warpctrl` discovers a loopback control endpoint and an instance-bound Unix-domain-socket broker reference, then requests a short-lived credential over that socket for the specific action it is about to invoke. The broker authenticates the connecting peer's OS user before decoding the request. The discovery record does not contain bearer tokens, raw credential material, or a stored credential that the CLI unwraps and sends to the discovered port. A control credential should encode or reference: - issuing Warp instance; - protocol version or accepted version range; @@ -274,9 +266,9 @@ Warp should issue credentials through an app-owned local broker or equivalent tr Recommended defaults: - Credential issuance is unavailable unless the protected enablement state allows the request's invocation context: inside Warp or outside Warp. - Commands should start from least privilege and request only the grant needed for the requested action. -- External same-user invocations should default to the smaller logged-out-safe local action set unless policy or explicit approval grants more. +- External same-user invocations are limited to the smaller logged-out-safe local action set. Policy or explicit approval may grant narrower or broader permission categories only within that set; neither can grant authenticated-user authority to an external invocation. - Verified Warp-terminal invocations may receive broader local-control grants when the selected mode and action policy allow them. -- App-user authenticated grants are available only when the selected Warp app has a true logged-in Warp user and the requested execution context is allowed by local-control settings. External API-key authenticated grants are available only after key validation/exchange and only when external authenticated scripting is enabled. +- App-user authenticated grants are available only when the selected Warp app has a true logged-in Warp user and a verified Warp-terminal execution context allowed by local-control settings. - Metadata reads require an explicit `read_metadata` grant. - Underlying data reads require an explicit `read_underlying_data` grant. - App-state mutations require an explicit `mutate_app_state` grant. @@ -294,7 +286,7 @@ The category system should be understood as a user-intent and accident-preventio - Underlying data mutations can require explicit approval or configured policy so surprising operations pause before they execute commands or change user data. This model does not make untrusted same-user software safe. A malicious local process may invoke `warpctrl`, simulate user workflows, or use other OS-level capabilities outside `warpctrl`. The category model is still valuable because it lets honest clients, agents, and scripts constrain themselves and gives Warp a structured point to prompt, deny, or audit risky actions. ### Credential storage -Credential storage should be platform-appropriate: +The current Unix foundation stores no bootstrap or long-lived local-control secret; it mints short-lived scoped credentials in memory. Future credential and proof storage should be platform-appropriate: - Local discovery may store a credential reference rather than the credential itself. - The authoritative local-control mode should use the same class of protected local storage as raw credential material, but it should be accessible to the Warp app for the Settings > Scripting UI and not writable by `warpctrl` or arbitrary external apps. - Raw long-lived credentials should prefer platform-secure storage such as macOS Keychain or Windows Credential Manager when practical. @@ -303,7 +295,7 @@ Credential storage should be platform-appropriate: - Short-lived credentials may be stored in owner-only local state if their lifetime and scope are narrow. - Credentials must never be printed in human-readable output, JSON output, logs, errors, or shell completion data. ### Confused-deputy mitigation -Secure storage prevents arbitrary apps from reading the token; it does not prevent arbitrary apps from asking trusted Warp code to use the token on their behalf. +When future secrets use application-identity-constrained secure storage, it can prevent arbitrary apps from reading the token; it does not prevent arbitrary apps from asking trusted Warp code to use the token on their behalf. The current owner-authenticated Unix broker provides no Warp-signed-code boundary against same-user clients. For example, if `warpctrl` can silently unwrap a full-power credential and execute any action, another same-user process can invoke `warpctrl input run ...` without reading the credential directly. That makes `warpctrl` a confused deputy. Mitigations: - Do not give `warpctrl` ambient non-interactive access to an unrestricted full-control credential. @@ -317,13 +309,13 @@ Mitigations: These mitigations are about routing high-risk operations through intentional `warpctrl` flows rather than exposing a reusable localhost token to any process. They should not be documented as a guarantee that arbitrary same-user applications cannot cause Warp-visible actions. ## Transport authentication The default transport is an instance-local loopback listener bound to `127.0.0.1` on an ephemeral per-process port. -The current just-in-time credential broker avoids the specific stale-record bearer-token phishing failure mode where `warpctrl` unwraps a long-lived Warp-held credential and sends it to a port squatter. If future designs add stored bootstrap credentials, server-held secrets, or reusable credential references that must be presented to the discovered endpoint, the client must verify the server's identity before sending that material, or the local transport should move to Unix domain sockets or an equivalent platform channel with peer identity checks. +The current just-in-time credential broker avoids the specific stale-record bearer-token phishing failure mode where `warpctrl` unwraps a long-lived Warp-held credential and sends it to a port squatter. It uses an instance-bound Unix-domain socket inside the owner-only discovery directory and checks the peer OS user before reading the credential request. If future designs add stored bootstrap credentials, server-held secrets, or reusable credential references that must be presented to the discovered endpoint, the client must verify the server's identity before sending that material. Transport requirements: - Bind only to loopback for local control. - Do not set permissive CORS headers. - Reject any request carrying an `Origin` header. - Reject any request whose `Host` header is not exactly `127.0.0.1:<selected-port>` for the selected discovery record. -- Reject discovery records unless the published control and credential-broker endpoints are equal and both use exactly `127.0.0.1`. +- Reject discovery records unless the published control endpoint uses exactly `127.0.0.1` and the broker socket reference is the selected instance's expected filename inside the owner-only discovery directory. - Reject control requests when their inside-Warp or outside-Warp invocation context is disabled, even if the request presents an otherwise valid credential. - Authenticate every control request locally in the selected Warp app process before selector resolution or action dispatch. - Reject missing, malformed, expired, revoked, or invalid credentials with structured authentication errors. @@ -332,7 +324,7 @@ Transport requirements: Remote URL support is a separate future transport mode. It should not reuse the local same-user credential model without additional identity, encryption, replay protection, and remote approval/policy design. ## Logged-in user requirements Local-control validation always begins with local protocol state: discovery records, secure local credential references, scoped safety grants, execution-context proof, protocol version, request shape, allowlisted actions, typed parameters, and deterministic target selectors. -Some actions additionally require authenticated scripting authority: either a true logged-in Warp user in the selected app or an external API-key-backed subject with sufficient scopes. The action allowlist must declare this explicitly with a `requires_authenticated_user` or equivalent authenticated-scripting requirement field. +Some actions additionally require authenticated scripting authority from a true logged-in Warp user in the selected app and a verified Warp-terminal invocation. The action allowlist must declare this explicitly with a `requires_authenticated_user` or equivalent authenticated-scripting requirement field. Default rule for new actions: - New actions require an authenticated Warp user unless the implementer deliberately classifies them as logged-out-safe. - The logged-out-safe set should remain meaningfully smaller and limited to local app structure, local appearance metadata, and other surfaces that do not depend on the user's cloud-backed Warp identity. @@ -340,22 +332,21 @@ Default rule for new actions: - Actions that execute user-authored cloud-backed content, such as running typed Warp Drive workflows, require both authenticated scripting authority and the appropriate high-risk action category. Agent-prompt submission remains excluded until separately reviewed. When an authenticated-user or authenticated-scripting action is requested: - app-user mode requires the selected app to have an active logged-in Warp user; -- API-key mode requires a validated key or exchanged assertion with sufficient scopes, subject, expiry, and revocation state; -- the presented local-control credential must include an authenticated grant for that user or API-key-backed subject; -- the selected mode, action policy, and authenticated-scripting policy must allow authenticated actions for the verified execution context or external API-key mode; +- the presented local-control credential must include an authenticated grant for that user; +- the selected mode, action policy, and authenticated-scripting policy must allow authenticated actions for the verified Warp-terminal execution context; - the app bridge should execute app-user actions through the app's existing authenticated state rather than exporting raw cloud auth credentials to `warpctrl`. If these conditions are not met, the app returns a structured error. It must not fall back to logged-out behavior or silently omit user-authenticated data from a result that claims success. ## Safety policy model Safety grants are enforced in the app bridge after transport authentication and before target resolution or handler dispatch. This provides consistent “do not accidentally do more than requested” behavior for honest clients, not a sandbox for hostile same-user code. -The bridge must: -1. Parse the typed request envelope. -2. Verify protocol version compatibility. -3. Authenticate the credential. +The bridge path must: +1. Authenticate the transport credential before decoding the typed request envelope. +2. Parse the typed request envelope. +3. Verify protocol version compatibility. 4. Determine granted permission categories, execution context, target scopes, and authenticated-user grants. 5. Map the requested action to a required permission category, action family, execution-context requirement, and authenticated-user requirement. 6. Check optional target-family restrictions. 7. Reject requests that exceed the credential's grants with `insufficient_permissions`. -8. Reject authenticated-user or API-key-backed actions without the required app-user login, API-key validation, scopes, or authenticated grant with a structured authenticated-user/API-key error. +8. Reject authenticated-user actions without the required app-user login or authenticated grant with a structured authenticated-user error. 9. Only then resolve selectors and invoke the allowlisted handler. The CLI frontend may provide helpful preflight errors, but those checks are advisory. Local app-side bridge enforcement is mandatory because other tools can bypass the official CLI and speak the protocol directly. ## Action permission categories @@ -461,8 +452,7 @@ Important errors include: - `local_control_disabled` when the relevant inside-Warp or outside-Warp scripting context is disabled in Settings > Scripting or has been disabled after credentials were issued; - `unauthorized_local_client` for missing, malformed, expired, revoked, or invalid credentials; - `insufficient_permissions` for valid credentials that lack the requested permission category or target scope; -- `authenticated_user_required` when an action requires authenticated scripting authority but the credential lacks an authenticated-user or API-key-backed grant; -- `api_key_required`, `api_key_invalid`, `api_key_expired`, `api_key_revoked`, and `api_key_insufficient_scope` for external API-key scripting failures, or equivalent structured variants if consolidated under existing authenticated-user errors; +- `authenticated_user_required` when an action requires authenticated scripting authority but the credential lacks an authenticated-user grant; - `authenticated_user_unavailable` when the selected Warp app has no logged-in Warp user or cannot access the required authenticated user state; - `authenticated_user_mismatch` when an authenticated-user credential is bound to a different user subject than the user currently logged in to the selected Warp app; - `execution_context_not_allowed` when the action or requested grant is not allowed from the verified invocation context, such as an external client attempting an in-Warp-only authenticated-user action; @@ -497,9 +487,9 @@ Before shipping each action family, verify that these controls are implemented f ## Platform requirements ### macOS and Linux Discovery files must be stored in a per-user directory with owner-only permissions. -On macOS, raw credential material and the authoritative local-control mode should live in Keychain, not in the discovery record or an ordinary preferences file. Keychain access should be constrained to Warp-owned signed binaries or helpers using code-signing based access control. The mode should be writable by the Warp app's Settings > Scripting flow and not writable by `warpctrl`. The discovery record should hold only metadata and a credential reference when the selected mode allows the relevant invocation context. -On Linux, raw credentials and the authoritative mode should prefer platform-secure storage where available; otherwise short-lived scoped credentials may live in owner-only local state with strict file and directory permissions. If the mode falls back to owner-only local state, the weaker same-user protection should be documented. -Unix domain sockets with peer credential checks may be considered for stronger same-machine identity than bearer tokens alone. +On macOS, the authoritative local-control mode and any future long-lived proof or bootstrap secrets should live in Keychain, not in the discovery record or an ordinary preferences file. Keychain access should be constrained to Warp-owned signed binaries or helpers using code-signing based access control. The mode should be writable by the Warp app's Settings > Scripting flow and not writable by `warpctrl`. The discovery record should hold only metadata and a credential reference when the selected mode allows the relevant invocation context. +On Linux, the authoritative mode and any future long-lived proof or bootstrap secrets should prefer platform-secure storage where available; otherwise short-lived scoped credentials may live in owner-only local state with strict file and directory permissions. If the mode falls back to owner-only local state, the weaker same-user protection should be documented. +The current Unix foundation uses an instance-bound Unix-domain-socket credential broker with peer credential checks before request decoding. ### Windows Discovery records and credential material must live under the current user's app data directory with ACLs restricted to the current user, Administrators, and SYSTEM. The authoritative mode should use Credential Manager, DPAPI-backed protected storage, or an equivalent app-controlled protected store rather than normal registry settings that arbitrary same-user processes can write. diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index 2925b660e7..f0b855c320 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -34,9 +34,9 @@ Required security gates: - `warpctrl`, direct protocol requests, shell scripts, config files, registry/plist edits, defaults writes, and server-backed preferences must not be able to enable or widen the mode. - Discovery records do not publish actionable endpoints or credential references for disabled outside-Warp control. - Credential issuance is unavailable when the request's invocation context is disabled. -- Raw credential material is kept out of plaintext discovery records and stored in platform secure storage where available. +- The current foundation keeps credentials out of plaintext discovery records and mints short-lived local-control credentials in memory without a stored or bootstrap secret; any future long-lived proof or bootstrap secrets use platform secure storage where available. - The broker distinguishes verified Warp-terminal invocations from external invocations using an app-issued execution-context proof, not a caller-declared label. Until that broker exists, `InsideWarp` is a reserved protocol concept that must not receive credentials. -- External invocations default to a smaller logged-out-safe action set that does not touch user-authenticated data. +- External invocations are limited to a smaller logged-out-safe action set that does not touch user-authenticated data and cannot receive authenticated-user authority. - Verified Warp-terminal invocations may receive authenticated-user grants only when the selected app has a true logged-in Warp user and local-control mode plus action policy allow authenticated-user actions from Warp terminals. - The app rejects disabled, unauthenticated, expired, revoked, insufficient-scope, unsupported, malformed, ambiguous, missing-target, and stale-target requests with structured errors. - Every action has a documented state/data category and the app bridge enforces the required permission category locally before selector resolution or handler dispatch. @@ -138,8 +138,8 @@ Recommended design: - control-listener endpoint - protocol version - start timestamp - - credential metadata or secure-storage references only when the selected mode allows the relevant inside-Warp or outside-Warp context -- The CLI loads discovery records, rejects records unless the control and credential-broker endpoints are equal and exactly `127.0.0.1`, removes or ignores stale records after health and instance-identity checks, and chooses an instance using the product selector rules. + - an instance-bound owner-authenticated broker-socket reference only when the selected mode allows outside-Warp control +- The CLI loads discovery records, rejects records unless the control endpoint is exactly `127.0.0.1` and the broker socket is the selected instance's expected filename inside the owner-only discovery directory, removes or ignores stale records after health and instance-identity checks, and chooses an instance using the product selector rules. - `warpctrl instance list` is a CLI-first projection of this discovery registry plus health responses. When outside-Warp control is disabled, discovery must follow `SECURITY.md`: either publish no actionable local-control record for external clients or publish only a minimal disabled-status record with no endpoint authority or credential reference. This design preserves the current `9277` behavior while avoiding cross-process port contention for the new control API. @@ -149,7 +149,7 @@ Recommended local trust model: - No browser-readable CORS allowance on control endpoints. - The relevant Scripting mode must allow the request context before credentials are minted or sensitive control requests are accepted. In the current foundation branch that means outside-Warp only when the broadest mode is selected; future inside-Warp support must also verify the terminal proof. - The authoritative mode must live in protected local storage and must not be writable by `warpctrl` or ordinary same-user preference/config edits. -- Per-instance raw credential material must be kept out of plaintext discovery records and stored in platform secure storage where practical. +- Per-instance raw credential material must be kept out of plaintext discovery records. The current foundation broker mints short-lived scoped credentials in memory only after authenticating the connecting Unix-socket peer. - The CLI may load or request scoped credentials through an app-owned broker/helper, but it must not mint authority itself. - The broker verifies whether the invocation originated from a Warp-managed terminal session before issuing in-Warp-only grants. - The broker issues authenticated-user grants only when the selected app has a true logged-in Warp user and the selected mode plus action policy allow the grant. @@ -165,38 +165,25 @@ Minimum implementable design: - When Warp creates or Warpifies a terminal session, the app creates a high-entropy per-session capability and records verifier state in an app-owned terminal-session registry. - The registry entry is bound to the selected app instance, terminal/session identifier, issuing process generation, expiry, and revocation state. - The shell receives only proof material needed by `warpctrl`, such as an opaque handle plus a short-lived token or challenge-response input. Plain environment variables may carry handles or hints, but a caller-set variable must not be sufficient authority. -- `warpctrl` invoked from that terminal sends `InvocationContext::InsideWarp` and `ExecutionContextProof::VerifiedWarpTerminal` to `/v1/control/credentials` when it has proof material. Without proof material it must use `OutsideWarp`. +- `warpctrl` invoked from that terminal sends `InvocationContext::InsideWarp` and `ExecutionContextProof::VerifiedWarpTerminal` to the owner-authenticated credential broker when it has proof material. Without proof material it must use `OutsideWarp`. - The broker verifies the proof against the app-owned registry, including app instance, session liveness, expiry, revocation, and nonce or challenge binding before minting any inside-Warp scoped credential. - The broker then checks Settings > Scripting mode and permission-category policy for the requested action. A valid proof raises the maximum eligible grant set; it does not bypass user settings, action metadata, authenticated-user requirements, target scopes, or bridge enforcement. - The minted credential records `invocation_context: InsideWarp`, the granted permission category, expiry, instance, and any authenticated-user subject. The app bridge revalidates the grant and current app policy on every control request. -Hardened follow-ups can strengthen this minimum design by storing secret material in platform secure storage, exposing only opaque handles through the shell, using Unix-domain-socket or named-pipe peer-credential checks, binding proofs to broker challenges, and invalidating proofs on shell/session teardown, app logout, user switch, or Settings > Scripting changes. These hardening layers improve direct-token theft resistance, but they do not create a perfect security boundary against malicious same-user software with process, filesystem, Accessibility, or screen-observation access. -### 5. Authenticated scripting identity and API-key grants -The full control catalog includes Warp Drive data mutation and execution-underlying actions. Those actions require an authenticated scripting layer in addition to local-control credentials. Local-control credentials prove authority to call the local app; authenticated scripting credentials prove the Warp user or automation identity allowed to request user-backed or high-risk actions. -#### Inside-Warp authenticated scripting +Hardened follow-ups can strengthen this minimum design by storing secret material in platform secure storage, exposing only opaque handles through the shell, adding a Windows named-pipe equivalent to the current Unix-domain-socket peer-credential check, binding proofs to broker challenges, and invalidating proofs on shell/session teardown, app logout, user switch, or Settings > Scripting changes. These hardening layers improve direct-token theft resistance, but they do not create a perfect security boundary against malicious same-user software with process, filesystem, Accessibility, or screen-observation access. +### 5. Authenticated scripting identity +The full control catalog includes Warp Drive data mutation and execution-underlying actions. Those actions require an authenticated scripting layer in addition to local-control credentials. Local-control credentials prove authority to call the local app; authenticated scripting credentials prove the logged-in Warp user allowed to request user-backed or high-risk actions. +#### Verified Warp-terminal authenticated scripting For `warpctrl` launched inside a Warp-managed terminal, use the verified terminal proof broker from the previous section. When the proof is valid and the selected app is logged into Warp, the broker may mint an authenticated-user grant bound to the app's current user subject. The grant is available only if the selected Settings > Scripting mode and action policy allow authenticated-user actions for verified Warp terminals. The CLI must not receive raw Firebase, OAuth, server, or session tokens. The app bridge executes authenticated actions through the selected app's existing auth state and rejects the grant if the app logs out, switches users, or the grant subject no longer matches the app user. -#### External API-key authenticated scripting -For `warpctrl` launched outside Warp, by cron, or by another pure scripting environment, introduce a separate API-key path. The user creates or supplies a Warp-issued scripting API key with explicit scopes such as local-control authenticated reads, Drive mutation, or execution-underlying actions. The CLI may reference the key from a secret manager or environment variable such as `WARPCTRL_API_KEY`, or store it in platform secure storage through `warpctrl auth api-key set --key-stdin`; it must never print or write the raw key to discovery records, logs, JSON output, shell completions, or repo config. -The local broker exchanges or validates the API key with Warp services, obtains a short-lived signed identity assertion, and mints a local authenticated-user grant only when all of the following hold: -- the broadest local-control mode is selected; -- external authenticated-user grants are enabled separately from logged-out outside-Warp control; -- the API key is valid, unexpired, unrevoked, and scoped for the requested permission category and action family; -- the selected app is logged into the same Warp user subject, or the action is explicitly designed to use API-key-backed identity without exporting app cloud tokens; -- the requested local-control permission category is granted by action policy and credential issuance; -- any resource or target restrictions in the key and grant are satisfied. -The grant should record the API-key subject, scopes, credential ID, expiry, invocation context, permission category, and optional target/resource restrictions. The app bridge revalidates the grant before selector resolution and handler dispatch. -#### Auth command surface and storage +External invocations remain limited to logged-out-safe actions. External API-key authenticated scripting and `auth.api_key.*` commands are not part of the selected public contract; adding them requires a separate product/security review and catalog change. +#### Auth command surface Add CLI and broker support for: -- `warpctrl auth status [selectors]` to report selected app login state, configured API-key subject metadata, and available authenticated grant modes without exposing secrets. +- `warpctrl auth status [selectors]` to report selected app login state and verified Warp-terminal authenticated grant availability without exposing secrets. - `warpctrl auth login [selectors]` to focus the selected app's normal sign-in UI for interactive app login. -- `warpctrl auth api-key set --key-env <env_var>|--key-stdin [selectors]` to store or reference an external scripting key. -- `warpctrl auth api-key status [selectors]` to show key subject/scope metadata. -- `warpctrl auth api-key revoke [selectors]` to delete the local reference and revoke the server-side key where supported. -Store raw API keys only in platform secure storage where available. Environment-variable use is allowed for non-interactive automation, but commands and docs should prefer secret-manager injection over plaintext shell profiles. ### 6. App-side request bridge onto the UI/application context The HTTP handler runs on a Tokio runtime thread owned by the local-control server. It cannot directly access or mutate Warp's UI models, views, or app context because all WarpUI state is single-threaded and owned by the main app event loop. The bridge solves this by sending a closure from the Tokio handler thread to the main thread, executing it in the model's context, and returning the result to the waiting HTTP handler. #### Thread model -- **Tokio runtime thread (HTTP handler):** Owns the Axum router, receives HTTP requests, authenticates, deserializes the `RequestEnvelope`. Cannot touch `AppContext`, views, or models. +- **Tokio runtime thread (HTTP handler):** Owns the Axum router, receives HTTP requests, validates context-specific enablement plus the transport credential's existence, expiry, and instance binding before deserializing the `RequestEnvelope`, then hands the decoded request and grant to the bridge. Cannot touch `AppContext`, views, or models. - **Main app thread:** Owns all WarpUI entities (`App`, `AppContext`, views, models). All UI state reads and mutations must happen here. - **Bridge:** Transfers a typed closure from the Tokio thread to the main thread, executes it with `&mut ModelContext`, and sends the return value back. #### Implementation: `ModelSpawner` @@ -214,8 +201,8 @@ The bridge uses WarpUI's `ModelSpawner<T>` mechanism, which is the standard way HTTP handler (Tokio thread) │ ├─ verify inside-Warp or outside-Warp context is enabled - ├─ verify credential, execution context, safety grant, and authenticated-user grant - ├─ deserialize RequestEnvelope + ├─ verify credential existence, expiry, and instance binding + ├─ deserialize RequestEnvelope after transport credential lookup ├─ call bridge_spawner.spawn(move |bridge, ctx| { │ bridge.handle_request(request, ctx) // runs on main thread │ }).await @@ -389,7 +376,7 @@ The active durable review stack is the recovered `zach/warp-cli-v2/*` stack. Thi Spec ownership is part of the branch architecture. The only v2 branch that may intentionally change `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, or `README.md` is `zach/warp-cli-v2/contract-spec-sync`. After a spec change lands there, propagate it upward through every higher v2 branch with raw git rebases so those files remain byte-identical across the stack. Higher implementation branches must not make independent spec edits except when resolving a propagation conflict in a way that preserves the bottom-branch content. The intended v2 stack is: 1. `zach/warp-cli-v2/contract-spec-sync` — create from `origin/master`. It owns the product, technical, security, and README specs plus the shared contract/foundation: protocol crate shape, discovery/auth scaffolding, initial instance selector and error types, action metadata/catalog structure, Scripting settings surface, protected local-control settings, local-control server/bridge skeleton, `warpctrl` wrapper/control-mode entrypoint, packaging hooks, module split, and the minimum first-slice smoke path needed to prove the end-to-end architecture. -2. `zach/warp-cli-v2/auth-security` — create from `zach/warp-cli-v2/contract-spec-sync`. It owns authentication and security enforcement that applies across command families: scoped grants, execution-context policy, authenticated-user/API-key plumbing, denial paths, Settings > Scripting security controls, and tests proving high-risk actions cannot run without the required identity and permission category. +2. `zach/warp-cli-v2/auth-security` — create from `zach/warp-cli-v2/contract-spec-sync`. It owns authentication and security enforcement that applies across command families: scoped grants, execution-context policy, authenticated-user plumbing, denial paths, Settings > Scripting security controls, and tests proving high-risk actions cannot run without the required identity and permission category. 3. `zach/warp-cli-v2/readonly-capability-targets` — create from `zach/warp-cli-v2/auth-security`. It owns structural metadata reads, capability/action metadata, target selector parsing and resolution, opaque IDs, active window/tab/pane/session targeting, metadata-read permission checks, and read-only CLI output for these surfaces. It must not expose terminal output, input buffers, history, Drive object contents, or other underlying user data. 4. `zach/warp-cli-v2/appstate-file-drive-views` — create from `zach/warp-cli-v2/readonly-capability-targets`. It owns read-only app-state, file view, Drive view, block/input/history-style underlying-data read surfaces that are approved for the public catalog, along with the required underlying-data-read permission checks. It must keep local filesystem content reads/writes outside the v0 public catalog unless the specs are updated first. 5. `zach/warp-cli-v2/metadata-config-mutations` — create from `zach/warp-cli-v2/appstate-file-drive-views`. It owns metadata/configuration mutations: allowlisted settings changes, labels/titles/appearance/configuration updates, settings or surface-opening commands that are metadata/configuration rather than underlying-data mutations, and tests proving unallowlisted or private settings are rejected. @@ -425,7 +412,7 @@ When `FeatureFlag::WarpControlCli` is disabled in the Warp app: - `LocalControlSettings` should not register user-visible controls for Warp control; - the app should not create `LocalControlBridge` or `LocalControlServer`; - no local-control discovery record should be written; -- no `/v1/control` or `/v1/control/credentials` local server endpoints should be exposed; +- no `/v1/control` local server endpoint or credential-broker socket should be exposed; - command-palette/keybinding entries related specifically to installing, configuring, or using `warpctrl` should be hidden; - tests should cover both enabled and disabled flag states with the repo's normal feature-flag override helpers. The `warpctrl` wrapper may still be installed in a build where the app feature is disabled, but the hidden control-mode entrypoint should find no compatible enabled app instance and should return a structured no-instance or feature-disabled error rather than relying on hidden server state. @@ -488,7 +475,7 @@ Map tests directly to `PRODUCT.md` behavior. - Execution-context tests proving external clients cannot receive grants reserved for verified Warp-terminal invocations. - Permission-category enforcement tests proving insufficient grants fail with `insufficient_permissions` before selector resolution or handler dispatch, including separate denial cases for app-state mutation, metadata/configuration mutation, and underlying-data mutation. - Authenticated-user tests proving user-authenticated actions fail without a logged-in app user or authenticated-user grant. - - External API-key tests proving missing, invalid, expired, revoked, wrong-subject, and insufficient-scope keys fail before selector resolution or handler dispatch. + - External-context tests proving authenticated-user actions remain unavailable outside verified Warp-terminal invocations and fail before selector resolution or handler dispatch. - Settings > Scripting tests proving mode changes invalidate credentials and prevent new grants for invocation contexts no longer allowed. - Structured-error tests for protocol and runtime local-control failures such as disabled, unauthenticated, expired, revoked, insufficient-scope, execution-context-denied, authenticated-user-required, authenticated-user-unavailable, unsupported, malformed local-control request payloads, ambiguous targets, missing targets, stale targets, and invalid selectors. Clap parser usage errors are allowed to follow the parser's normal CLI error behavior unless a later branch explicitly wraps them. - Behavior 1-6, 29-31: @@ -582,7 +569,7 @@ flowchart LR - External apps silently enabling outside-Warp local control: - Mitigation: outside-Warp control requires the broadest mode, which lives in protected local storage behind Settings > Scripting, is local-only, is not Settings Sync'd, and is not writable through `warpctrl`, config files, registry/plist preference edits, defaults writes, or server-backed settings. - External apps obtaining in-Warp authenticated-user grants: - - Mitigation: require an app-issued execution-context proof for Warp-terminal-only grants, do not trust caller-declared labels or plain environment variables as sole authority, and keep external authenticated-user grants behind the broadest local-control mode plus authenticated-scripting policy. + - Mitigation: require an app-issued execution-context proof for Warp-terminal-only grants, do not trust caller-declared labels or plain environment variables as sole authority, and reject authenticated-user grants for external invocations regardless of the selected local-control mode. - Logged-out requests touching user-authenticated data: - Mitigation: every action declares `requires_authenticated_user`, new actions default to true, and the bridge returns authenticated-user errors before selector resolution or dispatch. - Implementation drift from `SECURITY.md`: From 7542919e1204a836f50eb802b21b3164ebe690e1 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Fri, 5 Jun 2026 13:39:13 -0600 Subject: [PATCH 45/48] Fix warpctrl broker startup context Co-Authored-By: Oz <oz-agent@warp.dev> --- app/src/local_control/mod.rs | 7 ++++++- skills-lock.json | 18 +++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/app/src/local_control/mod.rs b/app/src/local_control/mod.rs index cb2e64dbd5..204db77f8b 100644 --- a/app/src/local_control/mod.rs +++ b/app/src/local_control/mod.rs @@ -180,7 +180,12 @@ impl LocalControlServer { }); let registered_instance = RegisteredInstance::register(record)?; #[cfg(unix)] - let broker_listener = bind_credential_broker(registered_instance.record())?; + let broker_listener = { + let runtime_guard = runtime.enter(); + let listener = bind_credential_broker(registered_instance.record())?; + drop(runtime_guard); + listener + }; let state = ControlServerState { bridge_spawner, instance_id, diff --git a/skills-lock.json b/skills-lock.json index 89826be218..800b703b99 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -47,7 +47,7 @@ "source": "warpdotdev/common-skills", "sourceType": "github", "skillPath": ".agents/skills/pr-walkthrough/SKILL.md", - "computedHash": "990e8549611cff4b82036d4c7cc50d23eb2cbb27272926142d3fd8ad17a5e18f" + "computedHash": "fd5ff2a22f4cc80ef8d0ab3a7bdef427d6463719f9e5fdf47f6fa1371fc4d3f9" }, "reproduce-bug-report": { "source": "warpdotdev/common-skills", @@ -61,6 +61,12 @@ "skillPath": ".agents/skills/resolve-merge-conflicts/SKILL.md", "computedHash": "5376b5692901c624e8f20a5a04aeea5f5a94f5168d29852a8a639aded6408f2e" }, + "respond-to-pr-comments-in-blocklist": { + "source": "warpdotdev/common-skills", + "sourceType": "github", + "skillPath": ".agents/skills/respond-to-pr-comments-in-blocklist/SKILL.md", + "computedHash": "f7408cf90c10397aa9048f14ab985a138641fc1e5f3245e290150437d62875f0" + }, "review-pr": { "source": "warpdotdev/common-skills", "sourceType": "github", @@ -71,7 +77,7 @@ "source": "warpdotdev/common-skills", "sourceType": "github", "skillPath": ".agents/skills/spec-driven-implementation/SKILL.md", - "computedHash": "e334d0f6f0e8fc39055314acad911f36d92d1919372b5e2973cc99d7f8c901b4" + "computedHash": "45793ca1e35b032ddfd2596f2e86fd6f6e938549373bfe4aeb74683486a179e4" }, "update-skill": { "source": "warpdotdev/common-skills", @@ -79,6 +85,12 @@ "skillPath": ".agents/skills/update-skill/SKILL.md", "computedHash": "1e23c5a5c37ed084eced7fa507031e3cdb8e23f09cd5d004e00efd6f66bf200f" }, + "validate-changes-match-specs": { + "source": "warpdotdev/common-skills", + "sourceType": "github", + "skillPath": ".agents/skills/validate-changes-match-specs/SKILL.md", + "computedHash": "9123fd70ced064bdd773fd8d2baa8d5d5291fef910eb2c028084074b3ac72c27" + }, "write-product-spec": { "source": "warpdotdev/common-skills", "sourceType": "github", @@ -89,7 +101,7 @@ "source": "warpdotdev/common-skills", "sourceType": "github", "skillPath": ".agents/skills/write-tech-spec/SKILL.md", - "computedHash": "3b5eb4ef021112d473984eca28412d372e87d9337ad5d9754f3ad3e01f94d39b" + "computedHash": "c7913bfd1ea2be7ce38d5beb7e923b96f5689f6145250af1d81b985e8be4a882" } } } From a02831a4a0fbc60627dc7f7bae1abe86550b3e4f Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Fri, 5 Jun 2026 17:02:58 -0600 Subject: [PATCH 46/48] Refine warpctrl authorization and discovery security Co-Authored-By: Oz <oz-agent@warp.dev> --- app/src/local_control/mod.rs | 145 ++++++++-- app/src/local_control/mod_tests.rs | 34 ++- app/src/local_control/permissions.rs | 4 +- app/src/settings/local_control.rs | 90 +----- app/src/settings/local_control_tests.rs | 35 +-- app/src/settings_view/scripting_page.rs | 27 +- crates/local_control/src/auth.rs | 12 - crates/local_control/src/auth_tests.rs | 30 -- crates/local_control/src/catalog.rs | 258 +++++++----------- crates/local_control/src/client.rs | 46 +++- crates/local_control/src/client_tests.rs | 3 +- crates/local_control/src/discovery.rs | 70 ++++- crates/local_control/src/discovery_tests.rs | 5 +- crates/local_control/src/lib.rs | 2 +- crates/local_control/src/protocol.rs | 2 +- crates/local_control/src/protocol_tests.rs | 53 +--- crates/settings/src/lib.rs | 84 ++++++ crates/warp_cli/src/local_control/mod.rs | 5 +- .../warpui_extras/src/secure_storage/linux.rs | 18 ++ .../src/secure_storage/linux_tests.rs | 12 +- .../warpui_extras/src/secure_storage/mod.rs | 8 + specs/warp-control-cli/PRODUCT.md | 107 +++----- specs/warp-control-cli/README.md | 2 +- specs/warp-control-cli/SECURITY.md | 231 ++++++++-------- specs/warp-control-cli/TECH.md | 72 ++--- 25 files changed, 721 insertions(+), 634 deletions(-) diff --git a/app/src/local_control/mod.rs b/app/src/local_control/mod.rs index 204db77f8b..161b813e14 100644 --- a/app/src/local_control/mod.rs +++ b/app/src/local_control/mod.rs @@ -1,17 +1,58 @@ -//! Local HTTP server entry point for Warp control requests. +//! Running app-side server for local Warp control requests. //! //! This module owns the in-process listener, discovery registration, credential //! broker socket, and request handoff from Axum into the WarpUI model graph. +//! It complements `crates/local_control/src/discovery.rs`: that shared module +//! defines how clients find and validate candidate instances, while this module +//! creates the app-owned endpoints and publishes their routing metadata through +//! `RegisteredInstance`. //! -//! Clients first request a short-lived scoped credential from an owner-authenticated -//! Unix-domain-socket broker. The broker checks the caller's peer UID, feature -//! flag, requested invocation context, action metadata, execution-context proof, -//! and Settings > Scripting permissions before minting a bearer token. Verified -//! inside-Warp terminal credentials remain future work until the app-issued proof -//! broker is implemented. The client then presents that bearer token to -//! `/v1/control`, where the server looks up the in-memory grant, verifies it still -//! matches the requested action, and only then hands the request to the -//! main-thread `LocalControlBridge`. +//! A client uses all three transports in order. It reads the filesystem record +//! to find an instance, connects to that instance's Unix socket to obtain +//! temporary authority, and presents that authority to the instance's loopback +//! HTTP endpoint with one typed action. The filesystem and socket are therefore +//! complementary parts of discovery and credential bootstrap, not competing +//! discovery mechanisms. +//! +//! Credential broker security flow: +//! +//! ```text +//! owner-only discovery record +//! (loopback endpoint + broker path; never a token) +//! | +//! v +//! CLI client -- instance-bound Unix socket --> credential broker +//! [0600 socket + kernel-reported peer UID] +//! | +//! v +//! feature flag + Settings > Scripting gate +//! + protocol + action metadata +//! + invocation context + required proof +//! | +//! v +//! short-lived, instance-bound, action-scoped +//! bearer grant stored only in process memory +//! | +//! v +//! CLI client -- loopback HTTP + bearer --> /v1/control +//! [reject browser Origin + require exact Host +//! + validate grant existence, expiry, instance, and scope] +//! | +//! v +//! typed allowlisted action +//! | +//! v +//! main-thread LocalControlBridge +//! [re-check current settings before dispatch] +//! ``` +//! +//! These boundaries prevent browser-origin clients, other OS users, +//! unauthenticated clients that only obtain or guess the HTTP endpoint, stale +//! or wrong-instance credentials, and accidentally over-scoped credentials from +//! invoking actions. The broker authenticates the OS account, not the calling +//! application: malicious software already running as the same user remains +//! outside this boundary. Verified inside-Warp terminal credentials remain +//! future work until the app-issued proof broker is implemented. //! //! The Settings > Scripting gates used here are local-only settings backed by //! Warp's secure storage provider. @@ -45,18 +86,20 @@ use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::routing::post; use axum::{Json, Router}; +pub use bridge::LocalControlBridge; use chrono::Duration; +use permissions::{ensure_action_allowed, ensure_feature_enabled, ensure_protocol_version}; #[cfg(unix)] use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; use warp_core::channel::ChannelState; use warpui::{Entity, ModelContext, ModelSpawner, SingletonEntity}; - -pub use bridge::LocalControlBridge; -use permissions::{ensure_action_allowed, ensure_feature_enabled, ensure_protocol_version}; const MAX_ACTIVE_CREDENTIALS: usize = 128; -/// Shared state made available to Axum handlers for one localhost server -/// running inside Warp. +/// App-owned authority shared by one instance's broker and HTTP listener. +/// +/// Broker-issued bearer tokens map to grants only in this process-local state. +/// Knowing the endpoint from discovery is therefore insufficient to authenticate +/// an HTTP request. #[derive(Clone)] struct ControlServerState { bridge_spawner: ModelSpawner<LocalControlBridge>, @@ -64,7 +107,11 @@ struct ControlServerState { expected_host: String, credentials: Arc<Mutex<HashMap<String, CredentialGrant>>>, } -/// Process-local localhost server running inside Warp for control actions. +/// Process-local publisher, credential broker, and HTTP server for one Warp instance. +/// +/// Holding the runtime and registration keeps both listeners and the discovery +/// route alive. Dropping them stops request handling and removes the app's +/// published record and broker socket. pub struct LocalControlServer { _runtime: Option<tokio::runtime::Runtime>, control_endpoint: Option<ControlEndpoint>, @@ -98,6 +145,7 @@ impl LocalControlServer { server } + /// Starts, refreshes, or removes all outside-Warp publication as settings change. fn refresh_for_settings(&mut self, ctx: &mut ModelContext<Self>) -> Result<(), ControlError> { if !permissions::warp_control_cli_enabled() { self.stop(); @@ -116,17 +164,28 @@ impl LocalControlServer { if self._runtime.is_some() { return self.refresh_discovery_record(ctx); } - *self = Self::start(ctx)?; - Ok(()) + self.start(ctx) } + /// Stops both listeners and removes the discovery record and broker socket. fn stop(&mut self) { self.registered_instance = None; self.control_endpoint = None; self._runtime = None; } - fn start(ctx: &mut ModelContext<Self>) -> Result<Self, ControlError> { + /// Binds both transports and publishes the routing record that connects them. + /// + /// Startup first binds an ephemeral loopback HTTP port, publishes that port + /// plus the instance-derived broker filename, binds the broker socket, and + /// then serves credential issuance and typed control requests concurrently. + fn start(&mut self, ctx: &mut ModelContext<Self>) -> Result<(), ControlError> { + if self._runtime.is_some() { + return Err(ControlError::new( + ErrorCode::Internal, + "local-control server is already running", + )); + } ensure_feature_enabled()?; if !outside_warp_publication_supported() { return Err(ControlError::new( @@ -135,11 +194,7 @@ impl LocalControlServer { )); } if !crate::settings::LocalControlSettings::as_ref(ctx).outside_warp_control_enabled() { - return Ok(Self { - _runtime: None, - control_endpoint: None, - registered_instance: None, - }); + return Ok(()); } let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(1) @@ -202,11 +257,12 @@ impl LocalControlServer { }); #[cfg(unix)] runtime.spawn(run_credential_broker(broker_listener, state)); - Ok(Self { - _runtime: Some(runtime), - control_endpoint: Some(control_endpoint), - registered_instance: Some(registered_instance), - }) + let endpoint_url = control_endpoint.url(); + self._runtime = Some(runtime); + self.control_endpoint = Some(control_endpoint); + self.registered_instance = Some(registered_instance); + log::info!("local-control server started at {endpoint_url}"); + Ok(()) } fn refresh_discovery_record( @@ -226,6 +282,10 @@ impl LocalControlServer { } } +/// Builds routing metadata without embedding any bearer credential or secret. +/// +/// The endpoint and derived broker reference are published only while the +/// protected outside-Warp setting permits clients to use them. fn discovery_record_for_settings( ctx: &ModelContext<LocalControlServer>, control_endpoint: ControlEndpoint, @@ -242,6 +302,12 @@ fn discovery_record_for_settings( ) } +/// Binds the instance's credential-bootstrap socket and restricts it to the owning user. +/// +/// Any stale socket at the instance-specific path is removed before binding, and +/// the new socket is set to owner-only permissions before it accepts clients. +/// The path came from a validated instance-derived discovery reference, so a +/// record cannot redirect credential requests to an arbitrary socket. #[cfg(unix)] fn bind_credential_broker( record: &InstanceRecord, @@ -274,6 +340,7 @@ fn bind_credential_broker( } #[cfg(unix)] +/// Accepts same-user credential requests independently from the HTTP listener. async fn run_credential_broker(listener: tokio::net::UnixListener, state: ControlServerState) { loop { let Ok((stream, _)) = listener.accept().await else { @@ -289,6 +356,10 @@ async fn run_credential_broker(listener: tokio::net::UnixListener, state: Contro } #[cfg(unix)] +/// Authenticates the socket peer before decoding and evaluating its request. +/// +/// This ordering makes the kernel-reported OS user, rather than any field in +/// caller-controlled JSON, the credential broker's client-identity boundary. async fn handle_credential_broker_connection( mut stream: tokio::net::UnixStream, state: ControlServerState, @@ -330,11 +401,16 @@ async fn handle_credential_broker_connection( } #[cfg(unix)] +/// Requires the kernel-reported peer UID to match Warp's effective UID. +/// +/// This excludes other OS users but does not distinguish trusted Warp code from +/// arbitrary processes already running as the same user. fn ensure_same_user_peer(stream: &tokio::net::UnixStream) -> Result<(), ControlError> { ensure_peer_uid(stream, unsafe { libc::geteuid() }) } #[cfg(unix)] +/// Verifies a socket peer against an expected UID obtained outside request data. fn ensure_peer_uid(stream: &tokio::net::UnixStream, expected_uid: u32) -> Result<(), ControlError> { let peer = stream.peer_cred().map_err(|err| { ControlError::with_details( @@ -364,6 +440,10 @@ fn serialize_credential_broker_response( }) } +/// Evaluates current action policy and mints one short-lived exact-action grant. +/// +/// The bearer secret and its grant are retained only in the running instance's +/// process-local map; neither is written back into the discovery registry. async fn issue_credential( state: &ControlServerState, request: CredentialRequest, @@ -431,6 +511,12 @@ async fn issue_credential( }) } +/// Authenticates and hands one typed HTTP request to the app bridge. +/// +/// Header hardening rejects browser-origin and wrong-endpoint requests. The +/// process-local credential lookup authenticates the transport, after which the +/// bridge revalidates current settings and exact-action authority before +/// resolving targets or dispatching a handler. async fn handle_control_request( State(state): State<ControlServerState>, headers: HeaderMap, @@ -540,6 +626,7 @@ fn insert_credential( credentials.insert(secret, grant); } +/// Resolves an unexpired bearer token issued by this exact running instance. fn lookup_credential( credentials: &mut HashMap<String, CredentialGrant>, auth_token: &AuthToken, diff --git a/app/src/local_control/mod_tests.rs b/app/src/local_control/mod_tests.rs index bd9c4eceff..b1fcbc4420 100644 --- a/app/src/local_control/mod_tests.rs +++ b/app/src/local_control/mod_tests.rs @@ -1,12 +1,9 @@ use std::collections::HashMap; -#[cfg(unix)] -use super::ensure_peer_uid; use ::local_control::auth::{CredentialGrant, CredentialRequest}; -use ::local_control::protocol::ActionKind; use ::local_control::protocol::{ - Action, PaneSelector, PaneTarget, TabSelector, TabTarget, TargetSelector, WindowSelector, - WindowTarget, + Action, ActionKind, PaneSelector, PaneTarget, TabSelector, TabTarget, TargetSelector, + WindowSelector, WindowTarget, }; use ::local_control::{ErrorCode, InstanceId, InvocationContext, RequestEnvelope}; use axum::body::Bytes; @@ -18,13 +15,15 @@ use settings::Setting as _; use warp_core::features::FeatureFlag; use warpui::SingletonEntity as _; +#[cfg(unix)] +use super::ensure_peer_uid; use super::{ capabilities, ensure_feature_enabled, ensure_protocol_version, ensure_settings_allow_action, handle_control_request, insert_credential, issue_credential, lookup_credential, outside_warp_control_enabled_for_settings, require_active_window_id, resolve_index_from_ids, resolve_title_from_matches, validate_action_params, validate_loopback_headers, validate_request_authority, validate_tab_create_target, ControlServerState, LocalControlBridge, - MAX_ACTIVE_CREDENTIALS, + LocalControlServer, MAX_ACTIVE_CREDENTIALS, }; use crate::settings::{LocalControlMode, LocalControlModeSetting, LocalControlSettings}; @@ -228,6 +227,29 @@ fn feature_flag_disabled_denies_local_control() { let err = ensure_feature_enabled().expect_err("feature flag disabled"); assert_eq!(err.code, ErrorCode::LocalControlDisabled); } +#[test] +fn duplicate_server_start_is_rejected() { + warpui::App::test((), |mut app| async move { + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .expect("runtime"); + let server = app.add_model(|_| LocalControlServer { + _runtime: Some(runtime), + control_endpoint: None, + registered_instance: None, + }); + + let err = server + .update(&mut app, |server, ctx| server.start(ctx)) + .expect_err("duplicate start should fail"); + assert_eq!(err.code, ErrorCode::Internal); + + server + .update(&mut app, |server, _| server._runtime.take()) + .expect("existing runtime should remain active") + .shutdown_background(); + }); +} #[test] fn outside_warp_requires_everywhere_mode() { diff --git a/app/src/local_control/permissions.rs b/app/src/local_control/permissions.rs index 838967fa46..bc0bc6cd21 100644 --- a/app/src/local_control/permissions.rs +++ b/app/src/local_control/permissions.rs @@ -1,10 +1,10 @@ //! Permission checks that map invocation context onto local settings. -use crate::features::FeatureFlag; -use crate::settings::LocalControlSettings; use ::local_control::{ActionKind, ControlError, ErrorCode, InvocationContext, PROTOCOL_VERSION}; use warpui::{ModelContext, SingletonEntity}; +use crate::features::FeatureFlag; use crate::local_control::LocalControlBridge; +use crate::settings::LocalControlSettings; pub(super) fn warp_control_cli_enabled() -> bool { FeatureFlag::WarpControlCli.is_enabled() diff --git a/app/src/settings/local_control.rs b/app/src/settings/local_control.rs index 5f679a5450..d1605b3257 100644 --- a/app/src/settings/local_control.rs +++ b/app/src/settings/local_control.rs @@ -3,11 +3,12 @@ //! This setting is local-only, kept out of the user-visible settings file, and //! persisted through Warp's secure storage provider. It is the authoritative //! enablement bit for local control. -use anyhow::{anyhow, Context as _, Result}; +use anyhow::Result; use serde::{Deserialize, Serialize}; -use settings::{macros::define_settings_group, Setting, SupportedPlatforms, SyncToCloud}; +use settings::macros::define_settings_group; +use settings::{SecureSetting, Setting, SupportedPlatforms, SyncToCloud}; use warpui::{AppContext, ModelContext}; -use warpui_extras::secure_storage::{self, AppContextExt as _}; +use warpui_extras::secure_storage; const LOCAL_CONTROL_MODE_STORAGE_KEY: &str = "LocalControlMode"; @@ -70,74 +71,6 @@ pub struct LocalControlModeSetting { } impl LocalControlModeSetting { - fn read_from_secure_storage(ctx: &AppContext) -> Option<LocalControlMode> { - let value = match ctx.secure_storage().read_value(Self::storage_key()) { - Ok(value) => value, - Err(secure_storage::Error::NotFound) => return None, - Err(err) => { - log::error!("Failed to read local-control mode from secure storage: {err:#}"); - return None; - } - }; - match serde_json::from_str(&value) { - Ok(value) => Some(value), - Err(err) => { - log::error!("Failed to deserialize local-control mode: {err:#}"); - None - } - } - } - - /// Preserves the weaker private-preferences value when protected storage is - /// unavailable so platforms without a working secure provider do not lose - /// an existing user choice during migration. - fn migrate_from_private_preferences(ctx: &AppContext) -> Option<LocalControlMode> { - let value = Self::read_from_preferences(Self::preferences_for_setting(ctx))?; - if let Err(err) = Self::write_value_to_secure_storage(&value, ctx) { - log::error!("Failed to migrate local-control mode to secure storage: {err:#}"); - return Some(value); - } - if let Err(err) = Self::clear_from_preferences(Self::preferences_for_setting(ctx)) { - log::warn!( - "Failed to clear migrated local-control mode from private preferences: {err:#}" - ); - } - Some(value) - } - - fn write_value_to_secure_storage( - new_value: &LocalControlMode, - ctx: &AppContext, - ) -> Result<bool> { - let stored_value_matches = match ctx.secure_storage().read_value(Self::storage_key()) { - Ok(stored) => serde_json::from_str::<LocalControlMode>(&stored) - .is_ok_and(|stored| stored == *new_value), - Err(secure_storage::Error::NotFound) => false, - Err(err) => { - return Err(anyhow!(err)) - .context("Failed to read existing local-control mode from secure storage"); - } - }; - if stored_value_matches { - return Ok(false); - } - let serialized = serde_json::to_string(new_value) - .context("Failed to serialize local-control mode for secure storage")?; - ctx.secure_storage() - .write_value(Self::storage_key(), &serialized) - .context("Failed to write local-control mode to secure storage")?; - Ok(true) - } - - fn clear_from_secure_storage(ctx: &AppContext) -> Result<()> { - match ctx.secure_storage().remove_value(Self::storage_key()) { - Ok(()) | Err(secure_storage::Error::NotFound) => Ok(()), - Err(err) => { - Err(anyhow!(err)).context("Failed to clear local-control mode from secure storage") - } - } - } - fn emit_changed( ctx: &mut ModelContext<LocalControlSettings>, change_event_reason: settings::ChangeEventReason, @@ -148,6 +81,15 @@ impl LocalControlModeSetting { } } +impl SecureSetting for LocalControlModeSetting { + fn write_secure_storage_value( + storage: &dyn secure_storage::SecureStorage, + key: &str, + value: &str, + ) -> Result<(), secure_storage::Error> { + storage.write_value_with_owner_only_fallback(key, value) + } +} impl Setting for LocalControlModeSetting { type Group = LocalControlSettings; type Value = LocalControlMode; @@ -225,7 +167,7 @@ impl Setting for LocalControlModeSetting { new_value: Self::Value, ctx: &mut ModelContext<Self::Group>, ) -> Result<()> { - let changed_in_storage = Self::write_value_to_secure_storage(&new_value, ctx)?; + let changed_in_storage = Self::write_to_secure_storage(&new_value, ctx)?; if self.value() != &new_value || changed_in_storage { self.inner = self.validate(new_value); self.is_explicitly_set = true; @@ -239,9 +181,7 @@ impl Setting for LocalControlModeSetting { } fn new_from_storage(ctx: &mut AppContext) -> Self { - let value = Self::read_from_secure_storage(ctx) - .or_else(|| Self::migrate_from_private_preferences(ctx)); - Self::new(value) + Self::new(Self::read_from_secure_storage(ctx)) } fn is_supported_on_current_platform(&self) -> bool { diff --git a/app/src/settings/local_control_tests.rs b/app/src/settings/local_control_tests.rs index 981eec34b6..6dc9f305eb 100644 --- a/app/src/settings/local_control_tests.rs +++ b/app/src/settings/local_control_tests.rs @@ -1,12 +1,13 @@ use std::collections::HashMap; use std::sync::Mutex; -use super::{LocalControlMode, LocalControlModeSetting, LocalControlSettings}; use settings::{PrivatePreferences, PublicPreferences, Setting as _, SettingsManager, SyncToCloud}; use warpui::SingletonEntity as _; use warpui_extras::secure_storage::{self, AppContextExt as _}; use warpui_extras::user_preferences; +use super::{LocalControlMode, LocalControlModeSetting, LocalControlSettings}; + #[derive(Default)] struct InMemorySecureStorage { values: Mutex<HashMap<String, String>>, @@ -50,23 +51,6 @@ impl secure_storage::SecureStorage for InMemorySecureStorage { } } -struct UnavailableSecureStorage; - -impl secure_storage::SecureStorage for UnavailableSecureStorage { - fn write_value(&self, _key: &str, _value: &str) -> Result<(), secure_storage::Error> { - Err(secure_storage::Error::Unknown(anyhow::anyhow!( - "secure storage unavailable" - ))) - } - - fn read_value(&self, _key: &str) -> Result<String, secure_storage::Error> { - Err(secure_storage::Error::NotFound) - } - - fn remove_value(&self, _key: &str) -> Result<(), secure_storage::Error> { - Err(secure_storage::Error::NotFound) - } -} fn default_settings() -> LocalControlSettings { LocalControlSettings { local_control_mode: LocalControlModeSetting::new(None), @@ -131,7 +115,7 @@ fn mode_is_persisted_to_secure_storage() { } #[test] -fn migration_preserves_private_fallback_when_secure_storage_is_unavailable() { +fn mode_does_not_migrate_from_private_preferences() { warpui::App::test((), |mut app| async move { app.update(|ctx| { ctx.add_singleton_model(|_| { @@ -146,7 +130,7 @@ fn migration_preserves_private_fallback_when_secure_storage_is_unavailable() { }); ctx.add_singleton_model(|_| SettingsManager::default()); ctx.add_singleton_model(|_| -> secure_storage::Model { - Box::new(UnavailableSecureStorage) + Box::<InMemorySecureStorage>::default() }); LocalControlModeSetting::preferences_for_setting(ctx) .write_value( @@ -154,23 +138,22 @@ fn migration_preserves_private_fallback_when_secure_storage_is_unavailable() { serde_json::to_string(&LocalControlMode::EnabledEverywhere) .expect("mode serializes"), ) - .expect("private fallback is writable"); + .expect("private preference is writable"); LocalControlSettings::register(ctx); }); app.read(|ctx| { assert_eq!( LocalControlSettings::as_ref(ctx).mode(), - LocalControlMode::EnabledEverywhere + LocalControlMode::Disabled ); - let fallback = LocalControlModeSetting::preferences_for_setting(ctx) + let private_value = LocalControlModeSetting::preferences_for_setting(ctx) .read_value(LocalControlModeSetting::storage_key()) - .expect("private fallback is readable"); - assert!(fallback.is_some()); + .expect("private preference is readable"); + assert!(private_value.is_some()); }); }); } - #[test] fn mode_is_private_and_never_cloud_synced() { assert_eq!(LocalControlModeSetting::sync_to_cloud(), SyncToCloud::Never); diff --git a/app/src/settings_view/scripting_page.rs b/app/src/settings_view/scripting_page.rs index 1c9c3f1a1b..49d4f3a1a9 100644 --- a/app/src/settings_view/scripting_page.rs +++ b/app/src/settings_view/scripting_page.rs @@ -1,21 +1,21 @@ //! Settings UI for local scripting and Warp control permissions. -use super::{ - settings_page::{ - render_body_item, LocalOnlyIconState, MatchData, PageType, SettingsPageMeta, - SettingsPageViewHandle, SettingsWidget, - }, - SettingsSection, ToggleState, +use std::cell::RefCell; +use std::collections::HashMap; + +use settings::Setting as _; +use warpui::elements::{ChildView, Element, MouseStateHandle}; +use warpui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle}; + +use super::settings_page::{ + render_body_item, LocalOnlyIconState, MatchData, PageType, SettingsPageMeta, + SettingsPageViewHandle, SettingsWidget, }; +use super::{SettingsSection, ToggleState}; use crate::appearance::Appearance; use crate::features::FeatureFlag; use crate::report_if_error; use crate::settings::{LocalControlMode, LocalControlModeSetting, LocalControlSettings}; use crate::view_components::{Dropdown, DropdownItem}; -use settings::Setting as _; -use std::cell::RefCell; -use std::collections::HashMap; -use warpui::elements::{ChildView, Element, MouseStateHandle}; -use warpui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle}; #[derive(Clone, Debug, PartialEq)] pub enum ScriptingSettingsPageAction { @@ -167,10 +167,7 @@ impl SettingsWidget for LocalControlModeWidget { ToggleState::Enabled, appearance, ChildView::new(&view.local_control_mode_dropdown).finish(), - Some( - "Disabled blocks all Warp control. Enabled within Warp is reserved for verified Warp terminals and currently rejects requests. Enabled everywhere also allows scripts and automation from other apps to control Warp." - .to_owned(), - ), + Some("warpctrl allows for scripting Warp's UI. Use with care.'".to_owned()), ) } } diff --git a/crates/local_control/src/auth.rs b/crates/local_control/src/auth.rs index 57b86cd5e3..25d13db3b9 100644 --- a/crates/local_control/src/auth.rs +++ b/crates/local_control/src/auth.rs @@ -8,7 +8,6 @@ use uuid::Uuid; use crate::discovery::InstanceId; use crate::protocol::{ ActionKind, ControlError, ErrorCode, ExecutionContextProof, InvocationContext, - PermissionCategory, }; /// Bearer token used to authorize a single scoped local-control credential. @@ -137,7 +136,6 @@ pub struct CredentialGrant { pub credential_id: String, pub instance_id: InstanceId, pub action: ActionKind, - pub permission_category: PermissionCategory, pub invocation_context: InvocationContext, pub authenticated_user: AuthenticatedUserGrant, pub issued_at: DateTime<Utc>, @@ -164,7 +162,6 @@ impl CredentialGrant { credential_id: format!("cred_{}", Uuid::new_v4().simple()), instance_id, action, - permission_category: metadata.permission_category, invocation_context, authenticated_user: AuthenticatedUserGrant { required: metadata.authenticated_user.required, @@ -207,15 +204,6 @@ impl CredentialGrant { )); } let metadata = action.metadata(); - if self.permission_category != metadata.permission_category { - return Err(ControlError::new( - ErrorCode::InsufficientPermissions, - format!( - "{} requires a different local-control permission category", - action.as_str() - ), - )); - } if metadata.requires_authenticated_user && self.authenticated_user.subject.is_none() { return Err(ControlError::new( ErrorCode::AuthenticatedUserRequired, diff --git a/crates/local_control/src/auth_tests.rs b/crates/local_control/src/auth_tests.rs index 484c8f65c0..0a75703d2c 100644 --- a/crates/local_control/src/auth_tests.rs +++ b/crates/local_control/src/auth_tests.rs @@ -94,36 +94,6 @@ fn scoped_credential_carries_authenticated_user_metadata() { assert!(grant.authenticated_user.subject.is_none()); } -#[test] -fn scoped_credential_carries_permission_category() { - let grant = CredentialGrant::new( - InstanceId("inst_test".to_owned()), - ActionKind::TabCreate, - InvocationContext::OutsideWarp, - Duration::minutes(5), - ); - assert_eq!( - grant.permission_category, - ActionKind::TabCreate.metadata().permission_category - ); -} - -#[test] -fn scoped_credential_rejects_permission_category_mismatch() { - let mut grant = CredentialGrant::new( - InstanceId("inst_test".to_owned()), - ActionKind::TabCreate, - InvocationContext::OutsideWarp, - Duration::minutes(5), - ); - grant.permission_category = PermissionCategory::ReadMetadata; - - let err = grant - .verify_for_action(&grant.instance_id, ActionKind::TabCreate) - .expect_err("mismatched permission category is rejected"); - assert_eq!(err.code, ErrorCode::InsufficientPermissions); -} - #[test] fn authenticated_user_actions_require_subject() { let grant = CredentialGrant::new( diff --git a/crates/local_control/src/catalog.rs b/crates/local_control/src/catalog.rs index 824918629a..ff6b8d1e9d 100644 --- a/crates/local_control/src/catalog.rs +++ b/crates/local_control/src/catalog.rs @@ -23,38 +23,6 @@ pub enum ExecutionContextProof { ExternalClient, } -/// User-facing risk tier for an action. -#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RiskTier { - ReadOnlyMetadata, - ReadOnlyTerminalData, - MutatingNonDestructive, - MutatingDestructiveOrExecution, -} - -/// Category of Warp state or data an action reads or mutates. -#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum StateDataCategory { - MetadataRead, - UnderlyingDataRead, - AppStateMutation, - MetadataConfigurationMutation, - UnderlyingDataMutation, -} - -/// Settings permission bucket required before an action may execute. -#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PermissionCategory { - ReadMetadata, - ReadUnderlyingData, - MutateAppState, - MutateMetadataConfiguration, - MutateUnderlyingData, -} - /// Whether an action requires an authenticated Warp user context. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AuthenticatedUserRequirement { @@ -159,12 +127,9 @@ pub struct ActionMetadata { /// payloads, such as `tab.create`. pub name: String, pub implementation_status: ActionImplementationStatus, - pub risk_tier: RiskTier, - pub state_data_category: StateDataCategory, pub requires_authenticated_user: bool, pub authenticated_user: AuthenticatedUserRequirement, pub allowed_invocation_contexts: Vec<InvocationContext>, - pub permission_category: PermissionCategory, pub target_scope: TargetScope, pub parameter_spec: ActionParameterSpec, pub result_spec: ActionResultSpec, @@ -183,7 +148,6 @@ struct ActionSpec { implementation_status: ActionImplementationStatus, requires_authenticated_user: bool, invocation_contexts: InvocationContextSpec, - state_data_category: StateDataCategory, target_scope: TargetScope, parameter_spec: ActionParameterSpec, result_spec: ActionResultSpec, @@ -198,7 +162,6 @@ macro_rules! define_action_catalog { status: $status:ident, authenticated_user: $authenticated_user:literal, contexts: $contexts:ident, - state: $state:ident, target: $target:ident, params: $params:ident, result: $result:ident $(,)? @@ -237,14 +200,11 @@ macro_rules! define_action_catalog { kind: self, name: spec.name.to_owned(), implementation_status: spec.implementation_status, - risk_tier: self.default_risk_tier(), - state_data_category: spec.state_data_category, requires_authenticated_user: spec.requires_authenticated_user, authenticated_user: AuthenticatedUserRequirement { required: spec.requires_authenticated_user, }, allowed_invocation_contexts: self.allowed_invocation_contexts(), - permission_category: self.default_permission_category(), target_scope: spec.target_scope, parameter_spec: spec.parameter_spec, result_spec: spec.result_spec, @@ -274,7 +234,6 @@ macro_rules! define_action_catalog { implementation_status: ActionImplementationStatus::$status, requires_authenticated_user: $authenticated_user, invocation_contexts: InvocationContextSpec::$contexts, - state_data_category: StateDataCategory::$state, target_scope: TargetScope::$target, parameter_spec: ActionParameterSpec::$params, result_spec: ActionResultSpec::$result, @@ -294,184 +253,163 @@ macro_rules! define_action_catalog { } } - fn default_risk_tier(self) -> RiskTier { - match self.spec().state_data_category { - StateDataCategory::MetadataRead => RiskTier::ReadOnlyMetadata, - StateDataCategory::UnderlyingDataRead => RiskTier::ReadOnlyTerminalData, - StateDataCategory::UnderlyingDataMutation => RiskTier::MutatingDestructiveOrExecution, - StateDataCategory::AppStateMutation - | StateDataCategory::MetadataConfigurationMutation => RiskTier::MutatingNonDestructive, - } - } - - fn default_permission_category(self) -> PermissionCategory { - match self.spec().state_data_category { - StateDataCategory::MetadataRead => PermissionCategory::ReadMetadata, - StateDataCategory::UnderlyingDataRead => PermissionCategory::ReadUnderlyingData, - StateDataCategory::AppStateMutation => PermissionCategory::MutateAppState, - StateDataCategory::MetadataConfigurationMutation => { - PermissionCategory::MutateMetadataConfiguration - } - StateDataCategory::UnderlyingDataMutation => PermissionCategory::MutateUnderlyingData, - } - } } }; } define_action_catalog! { instance { - InstanceList => { name: "instance.list", status: Implemented, authenticated_user: false, contexts: OutsideWarpOnly, state: MetadataRead, target: Instance, params: None, result: InstanceList }, - InstanceInspect => { name: "instance.inspect", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Instance, params: None, result: InstanceMetadata }, + InstanceList => { name: "instance.list", status: Implemented, authenticated_user: false, contexts: OutsideWarpOnly, target: Instance, params: None, result: InstanceList }, + InstanceInspect => { name: "instance.inspect", status: Stub, authenticated_user: false, contexts: Any, target: Instance, params: None, result: InstanceMetadata }, } app { - AppPing => { name: "app.ping", status: Implemented, authenticated_user: false, contexts: OutsideWarpOnly, state: MetadataRead, target: Instance, params: None, result: InstanceMetadata }, - AppVersion => { name: "app.version", status: Implemented, authenticated_user: false, contexts: OutsideWarpOnly, state: MetadataRead, target: Instance, params: None, result: InstanceMetadata }, - AppActive => { name: "app.active", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Instance, params: None, result: ActiveTarget }, - AppFocus => { name: "app.focus", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Instance, params: None, result: Acknowledgement }, + AppPing => { name: "app.ping", status: Implemented, authenticated_user: false, contexts: OutsideWarpOnly, target: Instance, params: None, result: InstanceMetadata }, + AppVersion => { name: "app.version", status: Implemented, authenticated_user: false, contexts: OutsideWarpOnly, target: Instance, params: None, result: InstanceMetadata }, + AppActive => { name: "app.active", status: Stub, authenticated_user: false, contexts: Any, target: Instance, params: None, result: ActiveTarget }, + AppFocus => { name: "app.focus", status: Stub, authenticated_user: false, contexts: Any, target: Instance, params: None, result: Acknowledgement }, } auth { - AuthStatus => { name: "auth.status", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Auth, params: None, result: AuthStatus }, - AuthLogin => { name: "auth.login", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Auth, params: None, result: Acknowledgement }, + AuthStatus => { name: "auth.status", status: Stub, authenticated_user: false, contexts: Any, target: Auth, params: None, result: AuthStatus }, + AuthLogin => { name: "auth.login", status: Stub, authenticated_user: false, contexts: Any, target: Auth, params: None, result: Acknowledgement }, } capability { - CapabilityList => { name: "capability.list", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Capability, params: None, result: CapabilityList }, - CapabilityInspect => { name: "capability.inspect", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Capability, params: ActionName, result: CapabilityMetadata }, + CapabilityList => { name: "capability.list", status: Stub, authenticated_user: false, contexts: Any, target: Capability, params: None, result: CapabilityList }, + CapabilityInspect => { name: "capability.inspect", status: Stub, authenticated_user: false, contexts: Any, target: Capability, params: ActionName, result: CapabilityMetadata }, } window { - WindowList => { name: "window.list", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Window, params: None, result: TargetList }, - WindowInspect => { name: "window.inspect", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Window, params: None, result: TargetMetadata }, - WindowCreate => { name: "window.create", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Window, params: TabCreate, result: Acknowledgement }, - WindowFocus => { name: "window.focus", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Window, params: None, result: Acknowledgement }, - WindowClose => { name: "window.close", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Window, params: None, result: Acknowledgement }, + WindowList => { name: "window.list", status: Stub, authenticated_user: false, contexts: Any, target: Window, params: None, result: TargetList }, + WindowInspect => { name: "window.inspect", status: Stub, authenticated_user: false, contexts: Any, target: Window, params: None, result: TargetMetadata }, + WindowCreate => { name: "window.create", status: Stub, authenticated_user: false, contexts: Any, target: Window, params: TabCreate, result: Acknowledgement }, + WindowFocus => { name: "window.focus", status: Stub, authenticated_user: false, contexts: Any, target: Window, params: None, result: Acknowledgement }, + WindowClose => { name: "window.close", status: Stub, authenticated_user: false, contexts: Any, target: Window, params: None, result: Acknowledgement }, } tab { - TabList => { name: "tab.list", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Tab, params: None, result: TargetList }, - TabInspect => { name: "tab.inspect", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Tab, params: None, result: TargetMetadata }, - TabCreate => { name: "tab.create", status: Implemented, authenticated_user: false, contexts: OutsideWarpOnly, state: AppStateMutation, target: Tab, params: None, result: Acknowledgement }, - TabActivate => { name: "tab.activate", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Tab, params: TabActivate, result: Acknowledgement }, - TabMove => { name: "tab.move", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Tab, params: Direction, result: Acknowledgement }, - TabClose => { name: "tab.close", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Tab, params: TabClose, result: Acknowledgement }, - TabRename => { name: "tab.rename", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Tab, params: Rename, result: Acknowledgement }, - TabResetName => { name: "tab.reset_name", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Tab, params: None, result: Acknowledgement }, - TabColorSet => { name: "tab.color.set", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Tab, params: ColorValue, result: Acknowledgement }, - TabColorClear => { name: "tab.color.clear", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Tab, params: None, result: Acknowledgement }, + TabList => { name: "tab.list", status: Stub, authenticated_user: false, contexts: Any, target: Tab, params: None, result: TargetList }, + TabInspect => { name: "tab.inspect", status: Stub, authenticated_user: false, contexts: Any, target: Tab, params: None, result: TargetMetadata }, + TabCreate => { name: "tab.create", status: Implemented, authenticated_user: false, contexts: OutsideWarpOnly, target: Tab, params: None, result: Acknowledgement }, + TabActivate => { name: "tab.activate", status: Stub, authenticated_user: false, contexts: Any, target: Tab, params: TabActivate, result: Acknowledgement }, + TabMove => { name: "tab.move", status: Stub, authenticated_user: false, contexts: Any, target: Tab, params: Direction, result: Acknowledgement }, + TabClose => { name: "tab.close", status: Stub, authenticated_user: false, contexts: Any, target: Tab, params: TabClose, result: Acknowledgement }, + TabRename => { name: "tab.rename", status: Stub, authenticated_user: false, contexts: Any, target: Tab, params: Rename, result: Acknowledgement }, + TabResetName => { name: "tab.reset_name", status: Stub, authenticated_user: false, contexts: Any, target: Tab, params: None, result: Acknowledgement }, + TabColorSet => { name: "tab.color.set", status: Stub, authenticated_user: false, contexts: Any, target: Tab, params: ColorValue, result: Acknowledgement }, + TabColorClear => { name: "tab.color.clear", status: Stub, authenticated_user: false, contexts: Any, target: Tab, params: None, result: Acknowledgement }, } pane { - PaneList => { name: "pane.list", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Pane, params: None, result: TargetList }, - PaneInspect => { name: "pane.inspect", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Pane, params: None, result: TargetMetadata }, - PaneSplit => { name: "pane.split", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Pane, params: Direction, result: Acknowledgement }, - PaneFocus => { name: "pane.focus", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Pane, params: None, result: Acknowledgement }, - PaneNavigate => { name: "pane.navigate", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Pane, params: Direction, result: Acknowledgement }, - PaneResize => { name: "pane.resize", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Pane, params: Resize, result: Acknowledgement }, - PaneMaximize => { name: "pane.maximize", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Pane, params: None, result: Acknowledgement }, - PaneUnmaximize => { name: "pane.unmaximize", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Pane, params: None, result: Acknowledgement }, - PaneClose => { name: "pane.close", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Pane, params: None, result: Acknowledgement }, - PaneRename => { name: "pane.rename", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Pane, params: Rename, result: Acknowledgement }, - PaneResetName => { name: "pane.reset_name", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Pane, params: None, result: Acknowledgement }, + PaneList => { name: "pane.list", status: Stub, authenticated_user: false, contexts: Any, target: Pane, params: None, result: TargetList }, + PaneInspect => { name: "pane.inspect", status: Stub, authenticated_user: false, contexts: Any, target: Pane, params: None, result: TargetMetadata }, + PaneSplit => { name: "pane.split", status: Stub, authenticated_user: false, contexts: Any, target: Pane, params: Direction, result: Acknowledgement }, + PaneFocus => { name: "pane.focus", status: Stub, authenticated_user: false, contexts: Any, target: Pane, params: None, result: Acknowledgement }, + PaneNavigate => { name: "pane.navigate", status: Stub, authenticated_user: false, contexts: Any, target: Pane, params: Direction, result: Acknowledgement }, + PaneResize => { name: "pane.resize", status: Stub, authenticated_user: false, contexts: Any, target: Pane, params: Resize, result: Acknowledgement }, + PaneMaximize => { name: "pane.maximize", status: Stub, authenticated_user: false, contexts: Any, target: Pane, params: None, result: Acknowledgement }, + PaneUnmaximize => { name: "pane.unmaximize", status: Stub, authenticated_user: false, contexts: Any, target: Pane, params: None, result: Acknowledgement }, + PaneClose => { name: "pane.close", status: Stub, authenticated_user: false, contexts: Any, target: Pane, params: None, result: Acknowledgement }, + PaneRename => { name: "pane.rename", status: Stub, authenticated_user: false, contexts: Any, target: Pane, params: Rename, result: Acknowledgement }, + PaneResetName => { name: "pane.reset_name", status: Stub, authenticated_user: false, contexts: Any, target: Pane, params: None, result: Acknowledgement }, } session { - SessionList => { name: "session.list", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Session, params: None, result: TargetList }, - SessionInspect => { name: "session.inspect", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Session, params: None, result: TargetMetadata }, - SessionActivate => { name: "session.activate", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Session, params: None, result: Acknowledgement }, - SessionPrevious => { name: "session.previous", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Session, params: None, result: Acknowledgement }, - SessionNext => { name: "session.next", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Session, params: None, result: Acknowledgement }, - SessionReopenClosed => { name: "session.reopen_closed", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Session, params: None, result: Acknowledgement }, + SessionList => { name: "session.list", status: Stub, authenticated_user: false, contexts: Any, target: Session, params: None, result: TargetList }, + SessionInspect => { name: "session.inspect", status: Stub, authenticated_user: false, contexts: Any, target: Session, params: None, result: TargetMetadata }, + SessionActivate => { name: "session.activate", status: Stub, authenticated_user: false, contexts: Any, target: Session, params: None, result: Acknowledgement }, + SessionPrevious => { name: "session.previous", status: Stub, authenticated_user: false, contexts: Any, target: Session, params: None, result: Acknowledgement }, + SessionNext => { name: "session.next", status: Stub, authenticated_user: false, contexts: Any, target: Session, params: None, result: Acknowledgement }, + SessionReopenClosed => { name: "session.reopen_closed", status: Stub, authenticated_user: false, contexts: Any, target: Session, params: None, result: Acknowledgement }, } block { - BlockList => { name: "block.list", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Block, params: Limit, result: TargetList }, - BlockInspect => { name: "block.inspect", status: Stub, authenticated_user: false, contexts: Any, state: UnderlyingDataRead, target: Block, params: None, result: Content }, - BlockOutput => { name: "block.output", status: Stub, authenticated_user: false, contexts: Any, state: UnderlyingDataRead, target: Block, params: None, result: Content }, + BlockList => { name: "block.list", status: Stub, authenticated_user: false, contexts: Any, target: Block, params: Limit, result: TargetList }, + BlockInspect => { name: "block.inspect", status: Stub, authenticated_user: false, contexts: Any, target: Block, params: None, result: Content }, + BlockOutput => { name: "block.output", status: Stub, authenticated_user: false, contexts: Any, target: Block, params: None, result: Content }, } input { - InputGet => { name: "input.get", status: Stub, authenticated_user: false, contexts: Any, state: UnderlyingDataRead, target: Input, params: None, result: Content }, - InputInsert => { name: "input.insert", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Input, params: Text, result: Acknowledgement }, - InputReplace => { name: "input.replace", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Input, params: Text, result: Acknowledgement }, - InputClear => { name: "input.clear", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Input, params: None, result: Acknowledgement }, - InputModeSet => { name: "input.mode.set", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Input, params: InputMode, result: Acknowledgement }, - InputRun => { name: "input.run", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: UnderlyingDataMutation, target: Input, params: Text, result: Acknowledgement }, + InputGet => { name: "input.get", status: Stub, authenticated_user: false, contexts: Any, target: Input, params: None, result: Content }, + InputInsert => { name: "input.insert", status: Stub, authenticated_user: false, contexts: Any, target: Input, params: Text, result: Acknowledgement }, + InputReplace => { name: "input.replace", status: Stub, authenticated_user: false, contexts: Any, target: Input, params: Text, result: Acknowledgement }, + InputClear => { name: "input.clear", status: Stub, authenticated_user: false, contexts: Any, target: Input, params: None, result: Acknowledgement }, + InputModeSet => { name: "input.mode.set", status: Stub, authenticated_user: false, contexts: Any, target: Input, params: InputMode, result: Acknowledgement }, + InputRun => { name: "input.run", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: Input, params: Text, result: Acknowledgement }, } history { - HistoryList => { name: "history.list", status: Stub, authenticated_user: false, contexts: Any, state: UnderlyingDataRead, target: History, params: Limit, result: Content }, + HistoryList => { name: "history.list", status: Stub, authenticated_user: false, contexts: Any, target: History, params: Limit, result: Content }, } theme { - ThemeList => { name: "theme.list", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Appearance, params: None, result: ThemeList }, - ThemeGet => { name: "theme.get", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Appearance, params: None, result: ThemeState }, - ThemeSet => { name: "theme.set", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Appearance, params: ThemeName, result: Acknowledgement }, - ThemeSystemSet => { name: "theme.system.set", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Appearance, params: BooleanValue, result: Acknowledgement }, - ThemeLightSet => { name: "theme.light.set", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Appearance, params: ThemeName, result: Acknowledgement }, - ThemeDarkSet => { name: "theme.dark.set", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Appearance, params: ThemeName, result: Acknowledgement }, + ThemeList => { name: "theme.list", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: None, result: ThemeList }, + ThemeGet => { name: "theme.get", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: None, result: ThemeState }, + ThemeSet => { name: "theme.set", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: ThemeName, result: Acknowledgement }, + ThemeSystemSet => { name: "theme.system.set", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: BooleanValue, result: Acknowledgement }, + ThemeLightSet => { name: "theme.light.set", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: ThemeName, result: Acknowledgement }, + ThemeDarkSet => { name: "theme.dark.set", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: ThemeName, result: Acknowledgement }, } appearance { - AppearanceGet => { name: "appearance.get", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Appearance, params: None, result: AppearanceState }, - AppearanceFontSizeIncrease => { name: "appearance.font_size.increase", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Appearance, params: None, result: Acknowledgement }, - AppearanceFontSizeDecrease => { name: "appearance.font_size.decrease", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Appearance, params: None, result: Acknowledgement }, - AppearanceFontSizeReset => { name: "appearance.font_size.reset", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Appearance, params: None, result: Acknowledgement }, - AppearanceZoomIncrease => { name: "appearance.zoom.increase", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Appearance, params: None, result: Acknowledgement }, - AppearanceZoomDecrease => { name: "appearance.zoom.decrease", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Appearance, params: None, result: Acknowledgement }, - AppearanceZoomReset => { name: "appearance.zoom.reset", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Appearance, params: None, result: Acknowledgement }, + AppearanceGet => { name: "appearance.get", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: None, result: AppearanceState }, + AppearanceFontSizeIncrease => { name: "appearance.font_size.increase", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: None, result: Acknowledgement }, + AppearanceFontSizeDecrease => { name: "appearance.font_size.decrease", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: None, result: Acknowledgement }, + AppearanceFontSizeReset => { name: "appearance.font_size.reset", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: None, result: Acknowledgement }, + AppearanceZoomIncrease => { name: "appearance.zoom.increase", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: None, result: Acknowledgement }, + AppearanceZoomDecrease => { name: "appearance.zoom.decrease", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: None, result: Acknowledgement }, + AppearanceZoomReset => { name: "appearance.zoom.reset", status: Stub, authenticated_user: false, contexts: Any, target: Appearance, params: None, result: Acknowledgement }, } setting { - SettingList => { name: "setting.list", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Settings, params: Namespace, result: SettingList }, - SettingGet => { name: "setting.get", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Settings, params: Key, result: SettingValue }, - SettingSet => { name: "setting.set", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Settings, params: KeyValue, result: Acknowledgement }, - SettingToggle => { name: "setting.toggle", status: Stub, authenticated_user: false, contexts: Any, state: MetadataConfigurationMutation, target: Settings, params: Key, result: Acknowledgement }, + SettingList => { name: "setting.list", status: Stub, authenticated_user: false, contexts: Any, target: Settings, params: Namespace, result: SettingList }, + SettingGet => { name: "setting.get", status: Stub, authenticated_user: false, contexts: Any, target: Settings, params: Key, result: SettingValue }, + SettingSet => { name: "setting.set", status: Stub, authenticated_user: false, contexts: Any, target: Settings, params: KeyValue, result: Acknowledgement }, + SettingToggle => { name: "setting.toggle", status: Stub, authenticated_user: false, contexts: Any, target: Settings, params: Key, result: Acknowledgement }, } keybinding { - KeybindingList => { name: "keybinding.list", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Keybinding, params: None, result: KeybindingList }, - KeybindingGet => { name: "keybinding.get", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Keybinding, params: BindingName, result: KeybindingMetadata }, + KeybindingList => { name: "keybinding.list", status: Stub, authenticated_user: false, contexts: Any, target: Keybinding, params: None, result: KeybindingList }, + KeybindingGet => { name: "keybinding.get", status: Stub, authenticated_user: false, contexts: Any, target: Keybinding, params: BindingName, result: KeybindingMetadata }, } action { - ActionList => { name: "action.list", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Action, params: None, result: CapabilityList }, - ActionInspect => { name: "action.inspect", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: Action, params: ActionName, result: CapabilityMetadata }, + ActionList => { name: "action.list", status: Stub, authenticated_user: false, contexts: Any, target: Action, params: None, result: CapabilityList }, + ActionInspect => { name: "action.inspect", status: Stub, authenticated_user: false, contexts: Any, target: Action, params: ActionName, result: CapabilityMetadata }, } surface { - SurfaceSettingsOpen => { name: "surface.settings.open", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Surface, params: PageQuery, result: Acknowledgement }, - SurfaceCommandPaletteOpen => { name: "surface.command_palette.open", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Surface, params: Query, result: Acknowledgement }, - SurfaceCommandSearchOpen => { name: "surface.command_search.open", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Surface, params: Query, result: Acknowledgement }, - SurfaceWarpDriveOpen => { name: "surface.warp_drive.open", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Surface, params: None, result: Acknowledgement }, - SurfaceWarpDriveToggle => { name: "surface.warp_drive.toggle", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Surface, params: None, result: Acknowledgement }, - SurfaceResourceCenterToggle => { name: "surface.resource_center.toggle", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Surface, params: None, result: Acknowledgement }, - SurfaceAiAssistantToggle => { name: "surface.ai_assistant.toggle", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Surface, params: None, result: Acknowledgement }, - SurfaceCodeReviewToggle => { name: "surface.code_review.toggle", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Surface, params: None, result: Acknowledgement }, - SurfaceLeftPanelToggle => { name: "surface.left_panel.toggle", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Surface, params: None, result: Acknowledgement }, - SurfaceRightPanelToggle => { name: "surface.right_panel.toggle", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Surface, params: None, result: Acknowledgement }, - SurfaceVerticalTabsToggle => { name: "surface.vertical_tabs.toggle", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: Surface, params: None, result: Acknowledgement }, + SurfaceSettingsOpen => { name: "surface.settings.open", status: Stub, authenticated_user: false, contexts: Any, target: Surface, params: PageQuery, result: Acknowledgement }, + SurfaceCommandPaletteOpen => { name: "surface.command_palette.open", status: Stub, authenticated_user: false, contexts: Any, target: Surface, params: Query, result: Acknowledgement }, + SurfaceCommandSearchOpen => { name: "surface.command_search.open", status: Stub, authenticated_user: false, contexts: Any, target: Surface, params: Query, result: Acknowledgement }, + SurfaceWarpDriveOpen => { name: "surface.warp_drive.open", status: Stub, authenticated_user: false, contexts: Any, target: Surface, params: None, result: Acknowledgement }, + SurfaceWarpDriveToggle => { name: "surface.warp_drive.toggle", status: Stub, authenticated_user: false, contexts: Any, target: Surface, params: None, result: Acknowledgement }, + SurfaceResourceCenterToggle => { name: "surface.resource_center.toggle", status: Stub, authenticated_user: false, contexts: Any, target: Surface, params: None, result: Acknowledgement }, + SurfaceAiAssistantToggle => { name: "surface.ai_assistant.toggle", status: Stub, authenticated_user: false, contexts: Any, target: Surface, params: None, result: Acknowledgement }, + SurfaceCodeReviewToggle => { name: "surface.code_review.toggle", status: Stub, authenticated_user: false, contexts: Any, target: Surface, params: None, result: Acknowledgement }, + SurfaceLeftPanelToggle => { name: "surface.left_panel.toggle", status: Stub, authenticated_user: false, contexts: Any, target: Surface, params: None, result: Acknowledgement }, + SurfaceRightPanelToggle => { name: "surface.right_panel.toggle", status: Stub, authenticated_user: false, contexts: Any, target: Surface, params: None, result: Acknowledgement }, + SurfaceVerticalTabsToggle => { name: "surface.vertical_tabs.toggle", status: Stub, authenticated_user: false, contexts: Any, target: Surface, params: None, result: Acknowledgement }, } file { - FileList => { name: "file.list", status: Stub, authenticated_user: false, contexts: Any, state: MetadataRead, target: File, params: None, result: FileList }, - FileOpen => { name: "file.open", status: Stub, authenticated_user: false, contexts: Any, state: AppStateMutation, target: File, params: FileOpen, result: Acknowledgement }, + FileList => { name: "file.list", status: Stub, authenticated_user: false, contexts: Any, target: File, params: None, result: FileList }, + FileOpen => { name: "file.open", status: Stub, authenticated_user: false, contexts: Any, target: File, params: FileOpen, result: Acknowledgement }, } drive { - DriveList => { name: "drive.list", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: MetadataRead, target: DriveObject, params: DriveObjectList, result: DriveObjectList }, - DriveInspect => { name: "drive.inspect", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: UnderlyingDataRead, target: DriveObject, params: DriveObjectId, result: DriveObjectMetadata }, - DriveOpen => { name: "drive.open", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: AppStateMutation, target: DriveObject, params: DriveObjectId, result: Acknowledgement }, - DriveNotebookOpen => { name: "drive.notebook.open", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: AppStateMutation, target: DriveObject, params: DriveObjectId, result: Acknowledgement }, - DriveEnvVarCollectionOpen => { name: "drive.env_var_collection.open", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: AppStateMutation, target: DriveObject, params: DriveObjectId, result: Acknowledgement }, - DriveObjectShareOpen => { name: "drive.object.share.open", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: AppStateMutation, target: DriveObject, params: DriveObjectId, result: Acknowledgement }, - DriveObjectCreate => { name: "drive.object.create", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: UnderlyingDataMutation, target: DriveObject, params: DriveObjectCreate, result: Acknowledgement }, - DriveObjectUpdate => { name: "drive.object.update", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: UnderlyingDataMutation, target: DriveObject, params: DriveObjectUpdate, result: Acknowledgement }, - DriveObjectDelete => { name: "drive.object.delete", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: UnderlyingDataMutation, target: DriveObject, params: DriveObjectId, result: Acknowledgement }, - DriveObjectInsert => { name: "drive.object.insert", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: UnderlyingDataMutation, target: DriveObject, params: DriveObjectInsert, result: Acknowledgement }, - DriveObjectShareToTeam => { name: "drive.object.share_to_team", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: UnderlyingDataMutation, target: DriveObject, params: DriveObjectId, result: Acknowledgement }, - DriveWorkflowRun => { name: "drive.workflow.run", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, state: UnderlyingDataMutation, target: DriveObject, params: WorkflowRun, result: Acknowledgement }, + DriveList => { name: "drive.list", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: DriveObject, params: DriveObjectList, result: DriveObjectList }, + DriveInspect => { name: "drive.inspect", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: DriveObject, params: DriveObjectId, result: DriveObjectMetadata }, + DriveOpen => { name: "drive.open", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: DriveObject, params: DriveObjectId, result: Acknowledgement }, + DriveNotebookOpen => { name: "drive.notebook.open", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: DriveObject, params: DriveObjectId, result: Acknowledgement }, + DriveEnvVarCollectionOpen => { name: "drive.env_var_collection.open", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: DriveObject, params: DriveObjectId, result: Acknowledgement }, + DriveObjectShareOpen => { name: "drive.object.share.open", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: DriveObject, params: DriveObjectId, result: Acknowledgement }, + DriveObjectCreate => { name: "drive.object.create", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: DriveObject, params: DriveObjectCreate, result: Acknowledgement }, + DriveObjectUpdate => { name: "drive.object.update", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: DriveObject, params: DriveObjectUpdate, result: Acknowledgement }, + DriveObjectDelete => { name: "drive.object.delete", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: DriveObject, params: DriveObjectId, result: Acknowledgement }, + DriveObjectInsert => { name: "drive.object.insert", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: DriveObject, params: DriveObjectInsert, result: Acknowledgement }, + DriveObjectShareToTeam => { name: "drive.object.share_to_team", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: DriveObject, params: DriveObjectId, result: Acknowledgement }, + DriveWorkflowRun => { name: "drive.workflow.run", status: Stub, authenticated_user: true, contexts: InsideWarpOnly, target: DriveObject, params: WorkflowRun, result: Acknowledgement }, } } diff --git a/crates/local_control/src/client.rs b/crates/local_control/src/client.rs index ec14e1cee7..c9f589c09d 100644 --- a/crates/local_control/src/client.rs +++ b/crates/local_control/src/client.rs @@ -1,10 +1,26 @@ //! Blocking client helpers used by the standalone `warpctrl` CLI. -use crate::auth::{CredentialRequest, ScopedCredential}; -use crate::discovery::InstanceRecord; -use crate::protocol::{ - Action, ActionKind, ControlError, ControlResponse, ErrorCode, ErrorResponseEnvelope, - InvocationContext, RequestEnvelope, ResponseEnvelope, -}; +//! +//! Authentication is a two-transport flow: +//! +//! 1. Discovery supplies instance metadata, an exact `127.0.0.1` control +//! endpoint, and an instance-bound credential-broker socket reference. It +//! never supplies a bearer credential. +//! 2. Before using either reference, the client validates that the endpoint is +//! loopback and that the broker filename is derived from the selected +//! instance ID. +//! 3. The client requests a credential for one action and invocation context +//! over the owner-only broker socket. On Unix, the server authenticates the +//! connecting process through kernel-reported peer credentials before +//! issuing a short-lived, action-scoped credential. +//! 4. The client keeps that credential in memory and presents it as a bearer +//! token only to the selected instance's loopback HTTP endpoint. The running +//! Warp app revalidates the credential, current settings, action scope, and +//! request before dispatch. +//! +//! Client-side validation prevents accidental use of inconsistent discovery +//! authority, but it is not the authorization boundary. The broker and running +//! app enforce authorization, and credentials must never be written to +//! discovery records, logs, or command output. #[cfg(unix)] use std::io::{Read as _, Write as _}; #[cfg(unix)] @@ -14,6 +30,14 @@ use std::os::unix::net::UnixStream; #[cfg(unix)] use std::path::Path; +use crate::auth::{CredentialRequest, ScopedCredential}; +use crate::discovery::InstanceRecord; +use crate::protocol::{ + Action, ActionKind, ControlError, ControlResponse, ErrorCode, ErrorResponseEnvelope, + InvocationContext, RequestEnvelope, ResponseEnvelope, +}; + +/// Requests an action-scoped credential and sends one authenticated control request. pub fn send_request( instance: &InstanceRecord, request: &RequestEnvelope, @@ -68,6 +92,7 @@ pub fn send_request( } #[cfg(unix)] +/// Resolves the selected instance's validated broker path and requests a credential. fn request_credential_over_owner_ipc( instance: &InstanceRecord, request: &CredentialRequest, @@ -77,6 +102,11 @@ fn request_credential_over_owner_ipc( } #[cfg(unix)] +/// Exchanges one credential request and response over an owner-authenticated socket. +/// +/// Shutting down the write half delimits the JSON request so the broker can +/// read it to EOF before returning either a scoped credential or a structured +/// error response. fn request_credential_over_socket( path: &Path, request: &CredentialRequest, @@ -121,6 +151,7 @@ fn request_credential_over_socket( } #[cfg(not(unix))] +/// Fails closed on platforms without an owner-authenticated broker transport. fn request_credential_over_owner_ipc( _instance: &InstanceRecord, _request: &CredentialRequest, @@ -131,6 +162,7 @@ fn request_credential_over_owner_ipc( )) } +/// Requests and decodes a short-lived credential for one action and invocation context. pub fn request_credential( instance: &InstanceRecord, action: crate::protocol::ActionKind, @@ -152,6 +184,7 @@ pub fn request_credential( )) } +/// Authenticates an app-ping request and verifies the selected instance is live. pub fn probe_instance(instance: &InstanceRecord) -> Result<(), ControlError> { let response = send_request( instance, @@ -160,6 +193,7 @@ pub fn probe_instance(instance: &InstanceRecord) -> Result<(), ControlError> { validate_probe_response(instance, response) } +/// Rejects a health response that does not prove the selected instance identity. fn validate_probe_response( instance: &InstanceRecord, response: ResponseEnvelope, diff --git a/crates/local_control/src/client_tests.rs b/crates/local_control/src/client_tests.rs index a7ef15c17d..538056cd01 100644 --- a/crates/local_control/src/client_tests.rs +++ b/crates/local_control/src/client_tests.rs @@ -1,6 +1,7 @@ -use chrono::Utc; #[cfg(unix)] use std::io::{Read as _, Write as _}; + +use chrono::Utc; use uuid::Uuid; use super::*; diff --git a/crates/local_control/src/discovery.rs b/crates/local_control/src/discovery.rs index 07428c74a3..3c5fa726ab 100644 --- a/crates/local_control/src/discovery.rs +++ b/crates/local_control/src/discovery.rs @@ -1,4 +1,29 @@ -//! Filesystem discovery records for running local Warp instances. +//! Private filesystem registry for discovering running local Warp instances. +//! +//! This module answers “which compatible instances are available, and where +//! can a client begin authentication?” It does not listen for control requests +//! and does not grant control authority. `app/src/local_control/mod.rs` owns the +//! running app-side listeners and uses these types to publish their routing +//! metadata. +//! +//! An enabled instance publishes an owner-only JSON record containing +//! instance/build metadata, implemented actions, its exact loopback HTTP +//! endpoint, and the filename of its instance-bound credential-broker socket. +//! The client reads that record, connects to the Unix socket to request a +//! short-lived credential for one exact action, and then presents the credential +//! to the HTTP endpoint. Discovery records never contain bearer tokens or +//! reusable credentials. +//! +//! Before following a record, clients require the endpoint host to be exactly +//! `127.0.0.1` and the broker filename to be derived from the instance ID. A +//! discovery scan also rejects incompatible records, prunes dead PIDs, and +//! performs an authenticated `app.ping` probe. When outside-Warp control is +//! disabled, records contain neither an endpoint nor a broker reference. +//! +//! The owner-only directory, records, and broker sockets protect against other +//! OS users. The broker's kernel-reported peer-UID check is the authoritative +//! same-user check before credential issuance. Neither mechanism distinguishes +//! trusted Warp code from arbitrary software already running as that user. use std::fs; #[cfg(unix)] use std::os::unix::fs::PermissionsExt as _; @@ -29,7 +54,10 @@ impl Default for InstanceId { } } -/// Loopback HTTP endpoint for a running local-control server. +/// Exact loopback HTTP route used after a client obtains a broker-issued credential. +/// +/// Publishing this endpoint lets clients route requests; it does not authorize +/// them to invoke actions. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ControlEndpoint { pub host: String, @@ -49,13 +77,23 @@ impl ControlEndpoint { } } -/// Discovery reference to the owner-authenticated socket that issues scoped credentials. +/// Discovery reference to the owner-authenticated socket that issues credentials. +/// +/// Enabled records publish the instance-derived filename, not an arbitrary +/// socket path or a credential. Clients validate the filename and resolve it +/// inside the owner-only discovery directory before connecting. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CredentialBrokerReference { pub socket_path: PathBuf, } -/// Filesystem-published metadata for a running Warp app process. +/// Filesystem-published routing metadata for a running Warp app process. +/// +/// An enabled record connects the three stages of the protocol: filesystem +/// discovery, Unix-socket credential issuance, and authenticated loopback HTTP +/// dispatch. The optional endpoint and broker reference are present together or +/// absent together, so a disabled record cannot accidentally publish a usable +/// partial control route. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct InstanceRecord { pub protocol_version: u32, @@ -100,6 +138,12 @@ impl InstanceRecord { } } + /// Rejects records that could redirect a client away from the selected instance. + /// + /// This validates routing metadata rather than granting authority: an + /// enabled record must name exactly loopback and the broker filename derived + /// from its instance ID. The broker and app bridge still authenticate and + /// authorize the eventual request. pub fn validate_local_control_authority(&self) -> Result<(), ControlError> { match ( self.outside_warp_control_enabled, @@ -121,6 +165,7 @@ impl InstanceRecord { } } + /// Resolves the validated broker filename inside the private discovery directory. pub fn broker_socket_path(&self) -> Result<PathBuf, ControlError> { self.validate_local_control_authority()?; let credential_broker = self.credential_broker.as_ref().ok_or_else(|| { @@ -133,7 +178,11 @@ impl InstanceRecord { } } -/// RAII registration that publishes and removes one discovery record. +/// RAII registration for one app-owned discovery record and broker socket. +/// +/// The registration publishes routing metadata for the lifetime of the running +/// server. Dropping it removes the record and socket on graceful shutdown; +/// discovery scans prune dead-PID records left behind by crashes. pub struct RegisteredInstance { record: InstanceRecord, path: PathBuf, @@ -141,6 +190,7 @@ pub struct RegisteredInstance { } impl RegisteredInstance { + /// Publishes a record in the protected per-user registry. pub fn register(record: InstanceRecord) -> Result<Self, ControlError> { let dir = discovery_dir(); fs::create_dir_all(&dir).map_err(|err| { @@ -216,6 +266,7 @@ impl Drop for RegisteredInstance { } } +/// Returns the private registry shared by app publishers and local clients. pub fn discovery_dir() -> PathBuf { if let Some(path) = std::env::var_os(DISCOVERY_DIR_ENV) { return PathBuf::from(path); @@ -227,6 +278,10 @@ pub fn discovery_dir() -> PathBuf { PathBuf::from(home).join(".warp").join("local-control") } +/// Returns compatible live instances that pass an authenticated app ping. +/// +/// The ping follows the normal broker-to-HTTP flow and verifies the responding +/// app's instance ID, so a live PID and parseable record alone are insufficient. pub fn list_instances() -> Vec<InstanceRecord> { list_instances_from_dir(&discovery_dir()) .into_iter() @@ -234,6 +289,11 @@ pub fn list_instances() -> Vec<InstanceRecord> { .collect() } +/// Parses structurally valid candidate records and prunes records with dead PIDs. +/// +/// This lower-level scan does not contact the advertised endpoint; callers that +/// need invokable instances should use [`list_instances`] so candidates also +/// pass the authenticated probe. pub fn list_instances_from_dir(dir: &Path) -> Vec<InstanceRecord> { let Ok(entries) = fs::read_dir(dir) else { return Vec::new(); diff --git a/crates/local_control/src/discovery_tests.rs b/crates/local_control/src/discovery_tests.rs index def14b99d8..b573ab792b 100644 --- a/crates/local_control/src/discovery_tests.rs +++ b/crates/local_control/src/discovery_tests.rs @@ -1,8 +1,9 @@ -#[cfg(unix)] -use command::blocking::Command; use std::fs; use std::path::Path; +#[cfg(unix)] +use command::blocking::Command; + use super::*; #[test] diff --git a/crates/local_control/src/lib.rs b/crates/local_control/src/lib.rs index f44b377256..6ba4205e6b 100644 --- a/crates/local_control/src/lib.rs +++ b/crates/local_control/src/lib.rs @@ -16,7 +16,7 @@ pub use auth::{ }; pub use catalog::{ ActionImplementationStatus, ActionKind, ActionMetadata, AuthenticatedUserRequirement, - InvocationContext, PermissionCategory, RiskTier, StateDataCategory, TargetScope, + InvocationContext, TargetScope, }; pub use discovery::{ ControlEndpoint, CredentialBrokerReference, InstanceId, InstanceRecord, RegisteredInstance, diff --git a/crates/local_control/src/protocol.rs b/crates/local_control/src/protocol.rs index caab147e98..0ad3592ec9 100644 --- a/crates/local_control/src/protocol.rs +++ b/crates/local_control/src/protocol.rs @@ -5,7 +5,7 @@ use uuid::Uuid; pub use crate::catalog::{ ActionImplementationStatus, ActionKind, ActionMetadata, ActionParameterSpec, ActionResultSpec, AuthenticatedUserRequirement, ExecutionContextProof, InvocationContext, PROTOCOL_VERSION, - PermissionCategory, RiskTier, StateDataCategory, TargetScope, + TargetScope, }; pub use crate::selectors::{ PaneSelector, PaneTarget, TabSelector, TabTarget, TargetSelector, WindowSelector, WindowTarget, diff --git a/crates/local_control/src/protocol_tests.rs b/crates/local_control/src/protocol_tests.rs index f821a05285..096da70513 100644 --- a/crates/local_control/src/protocol_tests.rs +++ b/crates/local_control/src/protocol_tests.rs @@ -48,31 +48,23 @@ fn non_allowlisted_action_names_are_not_deserialized() { } #[test] -fn tab_create_metadata_is_first_slice_logged_out_safe_mutation() { +fn tab_create_metadata_is_first_slice_logged_out_safe_action() { let metadata = ActionKind::TabCreate.metadata(); assert_eq!( metadata.implementation_status, ActionImplementationStatus::Implemented ); - assert_eq!(metadata.risk_tier, RiskTier::MutatingNonDestructive); - assert_eq!( - metadata.state_data_category, - StateDataCategory::AppStateMutation - ); assert!(!metadata.requires_authenticated_user); assert!(!metadata.authenticated_user.required); - assert_eq!( - metadata.permission_category, - PermissionCategory::MutateAppState - ); assert_eq!( metadata.allowed_invocation_contexts, vec![InvocationContext::OutsideWarp] ); + assert_eq!(metadata.target_scope, TargetScope::Tab); } #[test] -fn core_smoke_metadata_has_explicit_read_metadata_category() { +fn core_smoke_metadata_has_explicit_instance_policy() { for action in [ ActionKind::InstanceList, ActionKind::AppPing, @@ -83,19 +75,15 @@ fn core_smoke_metadata_has_explicit_read_metadata_category() { metadata.implementation_status, ActionImplementationStatus::Implemented ); - assert_eq!(metadata.risk_tier, RiskTier::ReadOnlyMetadata); - assert_eq!( - metadata.state_data_category, - StateDataCategory::MetadataRead - ); + assert!(!metadata.authenticated_user.required); assert_eq!( - metadata.permission_category, - PermissionCategory::ReadMetadata + metadata.allowed_invocation_contexts, + vec![InvocationContext::OutsideWarp] ); - assert!(!metadata.authenticated_user.required); assert_eq!(metadata.target_scope, TargetScope::Instance); } } + #[test] fn implemented_catalog_is_exactly_the_first_slice() { let actions = ActionKind::implemented_metadata() @@ -114,37 +102,22 @@ fn implemented_catalog_is_exactly_the_first_slice() { } #[test] -fn action_metadata_serializes_security_categories() { +fn action_metadata_serializes_action_policy() { let metadata = ActionKind::TabCreate.metadata(); let value = serde_json::to_value(metadata).expect("metadata serializes"); assert_eq!(value["name"], "tab.create"); - assert_eq!(value["state_data_category"], "app_state_mutation"); - assert_eq!(value["permission_category"], "mutate_app_state"); + assert_eq!(value["implementation_status"], "implemented"); assert_eq!( value["authenticated_user"]["required"], serde_json::json!(false) ); -} - -#[test] -fn default_permissions_preserve_security_categories() { - assert_eq!( - ActionKind::TabCreate.metadata().permission_category, - PermissionCategory::MutateAppState - ); assert_eq!( - ActionKind::InputInsert.metadata().permission_category, - PermissionCategory::MutateAppState - ); - assert_eq!( - ActionKind::SettingSet.metadata().permission_category, - PermissionCategory::MutateMetadataConfiguration - ); - assert_eq!( - ActionKind::TabList.metadata().permission_category, - PermissionCategory::ReadMetadata + value["allowed_invocation_contexts"], + serde_json::json!(["outside_warp"]) ); + assert_eq!(value["target_scope"], "tab"); } + #[test] fn logged_out_safe_stub_actions_can_advertise_external_context() { let metadata = ActionKind::WindowCreate.metadata(); diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 65de75dce0..f1c9bf363b 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -63,6 +63,7 @@ use serde::Serialize; use serde::de::DeserializeOwned; use warp_features::FeatureFlag; use warpui_core::{AppContext, Entity, ModelContext}; +use warpui_extras::secure_storage::{self, AppContextExt as _}; use warpui_extras::user_preferences::UserPreferences; /// A newtype wrapper for the public preferences backend. @@ -556,6 +557,89 @@ pub trait Setting { fn is_value_explicitly_set(&self) -> bool; } +/// Shared persistence operations for typed settings backed by secure storage. +/// +/// Implementors remain responsible for routing their [`Setting`] lifecycle +/// methods through this trait and for keeping the setting private and +/// non-synced when the value must not be exposed through ordinary settings +/// storage. +pub trait SecureSetting: Setting { + /// Writes this setting's serialized value through its selected secure-storage path. + fn write_secure_storage_value( + storage: &dyn secure_storage::SecureStorage, + key: &str, + value: &str, + ) -> Result<(), secure_storage::Error> { + storage.write_value(key, value) + } + /// Reads and deserializes this setting from secure storage. + /// + /// Missing, unreadable, or malformed values return `None`, allowing the + /// setting to fail closed to its default value. + fn read_from_secure_storage(ctx: &AppContext) -> Option<Self::Value> { + let value = match ctx.secure_storage().read_value(Self::storage_key()) { + Ok(value) => value, + Err(secure_storage::Error::NotFound) => return None, + Err(err) => { + log::error!( + "Failed to read {} from secure storage: {err:#}", + Self::setting_name() + ); + return None; + } + }; + match serde_json::from_str(&value) { + Ok(value) => Some(value), + Err(err) => { + log::error!( + "Failed to deserialize {} from secure storage: {err:#}", + Self::setting_name() + ); + None + } + } + } + + /// Persists this setting to secure storage if its typed value changed. + fn write_to_secure_storage(new_value: &Self::Value, ctx: &AppContext) -> Result<bool> { + let stored_value_matches = match ctx.secure_storage().read_value(Self::storage_key()) { + Ok(stored) => serde_json::from_str::<Self::Value>(&stored) + .is_ok_and(|stored| stored == *new_value), + Err(secure_storage::Error::NotFound) => false, + Err(err) => { + return Err(anyhow::anyhow!(err)).context(format!( + "Failed to read existing {} from secure storage", + Self::setting_name() + )); + } + }; + if stored_value_matches { + return Ok(false); + } + let serialized = serde_json::to_string(new_value).context(format!( + "Failed to serialize {} for secure storage", + Self::setting_name() + ))?; + Self::write_secure_storage_value(ctx.secure_storage(), Self::storage_key(), &serialized) + .context(format!( + "Failed to write {} to secure storage", + Self::setting_name() + ))?; + Ok(true) + } + + /// Removes this setting from secure storage. + fn clear_from_secure_storage(ctx: &AppContext) -> Result<()> { + match ctx.secure_storage().remove_value(Self::storage_key()) { + Ok(()) | Err(secure_storage::Error::NotFound) => Ok(()), + Err(err) => Err(anyhow::anyhow!(err)).context(format!( + "Failed to clear {} from secure storage", + Self::setting_name() + )), + } + } +} + /// A trait for settings that can be toggled between two values. pub trait ToggleableSetting: Setting { /// Toggles the value of the setting and persists it to storage, returning diff --git a/crates/warp_cli/src/local_control/mod.rs b/crates/warp_cli/src/local_control/mod.rs index 5f1de3eeca..425638a847 100644 --- a/crates/warp_cli/src/local_control/mod.rs +++ b/crates/warp_cli/src/local_control/mod.rs @@ -4,16 +4,15 @@ mod completions; mod output; mod selectors; use std::ffi::OsString; - use std::process::ExitCode; -use crate::agent::OutputFormat; use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand}; use clap_complete::aot::Shell; - use commands::{run_app_command, run_instance_command, run_tab_command}; use completions::generate_completions_to_stdout; use output::write_control_error; + +use crate::agent::OutputFormat; /// Hidden flag used by the channel-specific Warp app binary to enter `warpctrl` mode. pub const CONTROL_MODE_FLAG: &str = "--warpctrl"; diff --git a/crates/warpui_extras/src/secure_storage/linux.rs b/crates/warpui_extras/src/secure_storage/linux.rs index 7a074c0739..adb130f0cc 100644 --- a/crates/warpui_extras/src/secure_storage/linux.rs +++ b/crates/warpui_extras/src/secure_storage/linux.rs @@ -229,6 +229,13 @@ impl SecureStorage { fn write_fallback_value(&self, key: &str, value: &str) -> Result<(), Error> { let fallback_file = self.fallback_file(key)?; + let encrypted = self.fallback_encrypt(value)?; + std::fs::write(fallback_file, encrypted).map_err(|err| Error::Unknown(err.into())) + } + + fn write_owner_only_fallback_value(&self, key: &str, value: &str) -> Result<(), Error> { + let fallback_file = self.fallback_file(key)?; + let encrypted = self.fallback_encrypt(value)?; let Some(fallback_dir) = fallback_file.parent() else { return Err(Error::Unknown(anyhow!( @@ -289,6 +296,17 @@ impl super::SecureStorage for SecureStorage { Err(_) => self.write_fallback_value(key, value), } } + fn write_value_with_owner_only_fallback(&self, key: &str, value: &str) -> Result<(), Error> { + let secret_result = self.write_secret_value(key, value); + + match secret_result { + Ok(_) => { + let _ = self.delete_fallback_value(key); + Ok(()) + } + Err(_) => self.write_owner_only_fallback_value(key, value), + } + } fn read_value(&self, key: &str) -> Result<String, Error> { let secret_result = self.with_item(key, |item| { diff --git a/crates/warpui_extras/src/secure_storage/linux_tests.rs b/crates/warpui_extras/src/secure_storage/linux_tests.rs index b90c60b611..3de2e4b45e 100644 --- a/crates/warpui_extras/src/secure_storage/linux_tests.rs +++ b/crates/warpui_extras/src/secure_storage/linux_tests.rs @@ -52,7 +52,7 @@ fn fallback_value_is_owner_only() { let fallback_dir = temp_dir.path().join("secure-storage"); let storage = SecureStorage::new_with_fallback("darmok", fallback_dir.clone()); storage - .write_fallback_value("key", "value") + .write_owner_only_fallback_value("key", "value") .expect("fallback write"); let dir_mode = std::fs::metadata(&fallback_dir) .expect("directory metadata") @@ -67,3 +67,13 @@ fn fallback_value_is_owner_only() { assert_eq!(dir_mode, 0o700); assert_eq!(file_mode, 0o600); } + +#[test] +fn default_fallback_does_not_create_missing_directory() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let fallback_dir = temp_dir.path().join("secure-storage"); + let storage = SecureStorage::new_with_fallback("darmok", fallback_dir.clone()); + + assert!(storage.write_fallback_value("key", "value").is_err()); + assert!(!fallback_dir.exists()); +} diff --git a/crates/warpui_extras/src/secure_storage/mod.rs b/crates/warpui_extras/src/secure_storage/mod.rs index de71c81b6f..3e1a052ffb 100644 --- a/crates/warpui_extras/src/secure_storage/mod.rs +++ b/crates/warpui_extras/src/secure_storage/mod.rs @@ -93,6 +93,14 @@ pub fn register_with_dir( pub trait SecureStorage { /// Writes a value at the given key. fn write_value(&self, key: &str, value: &str) -> Result<(), Error>; + /// Writes a value while requiring any file fallback to be owner-only. + /// + /// Platforms without a file fallback use their normal secure-storage write + /// path. Callers should opt into this only when they require the stronger + /// fallback behavior because it may create or change fallback permissions. + fn write_value_with_owner_only_fallback(&self, key: &str, value: &str) -> Result<(), Error> { + self.write_value(key, value) + } /// Reads the value stored at the given key. fn read_value(&self, key: &str) -> Result<String, Error>; diff --git a/specs/warp-control-cli/PRODUCT.md b/specs/warp-control-cli/PRODUCT.md index 7f29ae6f79..487246e7d7 100644 --- a/specs/warp-control-cli/PRODUCT.md +++ b/specs/warp-control-cli/PRODUCT.md @@ -20,13 +20,13 @@ Non-goals: - Requiring the first implementation slice to ship every action in the catalog. ## Primary user stories These stories define the most compelling product uses for `warpctrl`. The command catalog below is intentionally broader, but the product should prioritize surfaces that agents cannot already operate well through native tools. -1. **Agent workspace orchestration.** When a user asks an agent to work on a task, the agent can inspect the current Warp state, create or reuse an appropriate window/tab layout, split panes, name and focus targets, open relevant Warp surfaces, and leave the workspace in a readable task-shaped state for the user. The agent should continue to use native tools for code edits, file reads/writes, shell execution, MCP calls, and other work that does not require operating Warp's UI or local-control permission model. +1. **Agent workspace orchestration.** When a user asks an agent to work on a task, the agent can inspect the current Warp state, create or reuse an appropriate window/tab layout, split panes, name and focus targets, open relevant Warp surfaces, and leave the workspace in a readable task-shaped state for the user. The agent should continue to use native tools for code edits, file reads/writes, shell execution, MCP calls, and other work that does not require operating Warp's UI or local-control authorization model. 2. **Existing-session debugging and repair.** When a user asks for help with an existing Warp session, the agent can understand Warp-specific UI and session structure before acting: which instance/window/tab/pane/session is active, whether the relevant pane still exists, whether the correct surface is focused, which panels or settings pages are open, and which selector should be used for follow-up actions. The story should focus on UI/session structure, focus, panels, settings, and deterministic targeting; native agent context tools should remain the preferred way to read attached blocks, conversations, and other content when they are available. 3. **Warp Drive creation, navigation, and sharing.** When an agent notices reusable knowledge during normal work, it can help the user turn that knowledge into a Warp Drive object, open it for review, and guide sharing with the right scope. This includes workflows from repeated command sequences, notebooks from task writeups, prompts/rules/facts from user or project preferences, environment variable collections, MCP setup objects, folders, and spaces. Existing object navigation remains important, but creation and sharing are first-class because reusable team knowledge cannot be used until users are guided into creating it. 4. **Deterministic demos and walkthroughs.** When a user, teammate, or go-to-market workflow needs a reliable Warp demo, an agent or script can put Warp into a known presentation state: theme, zoom, windows, tabs, panes, focused targets, panels, command palette/search, and Warp Drive surfaces. The walkthrough can then advance using structured target IDs and recover from stale or missing targets instead of relying on screen coordinates, manual setup, or brittle UI automation. 5. **Personalization, onboarding, and preference migration.** When a user wants Warp to feel familiar, an agent can inspect user-approved settings from tools such as VS Code, iTerm, Ghostty, or shell configuration, propose Warp equivalents, apply allowlisted changes through `warpctrl`, and report unsupported mappings explicitly instead of guessing. The same flow can support team onboarding presets, presentation preferences, accessibility-related settings, themes, font and zoom, keybindings, notifications, and panels. -Human power-user scripting is a secondary beneficiary of the same design. Scripts get reliable JSON, target selectors, structured errors, and permission categories because the API is strong enough for agents, but the primary product narrative remains agent-led operation of Warp itself. -Persistent settings changes, Warp Drive creation or sharing, cross-app preference migration, terminal command execution, and other underlying-data mutations must be visibly reviewable or require stronger explicit permission than low-risk workspace organization. `warpctrl` should support full typed control over time, but each command must be progressively unlocked through action categories, target resolution, Agent Profile permissions, Scripting settings, and authenticated-user requirements rather than broad unchecked authority. +Human power-user scripting is a secondary beneficiary of the same design. Scripts get reliable JSON, target selectors, structured errors, and exact-action credentials because the API is strong enough for agents, but the primary product narrative remains agent-led operation of Warp itself. +Persistent settings changes, Warp Drive creation or sharing, cross-app preference migration, terminal command execution, and other actions with durable or external effects must be visibly reviewable or require explicit action-specific authorization. `warpctrl` should support full typed control over time, but each command must be progressively unlocked through exact-action grants, deterministic target resolution, Agent Profile policy, Scripting settings, authenticated-user requirements, and action-specific approval rather than broad unchecked authority. ## Behavior 1. The Warp control CLI operates only on running local Warp app processes. If no compatible Warp process is available, the CLI exits non-zero with a clear “no running Warp instance found” error. 2. The CLI exposes only explicitly allowlisted actions. Protocol-level unknown action names, unsupported local-control parameter combinations, or requests for non-allowlisted capabilities fail with structured local-control errors; they are never forwarded to arbitrary internal dispatch. Clap parser usage errors, such as an unknown CLI subcommand or invalid flag syntax, may use the parser's normal CLI error behavior unless a later branch explicitly wraps them. @@ -116,7 +116,7 @@ Persistent settings changes, Warp Drive creation or sharing, cross-app preferenc - Replace the active input buffer. - Clear the active input buffer where that matches existing user behavior. - Switch input mode between terminal and agent modes only where that mode switch is already user-visible and valid for the selected target. - Input staging commands must not submit terminal input or press Enter. The separate `input run` execution action may submit a command only in the later execution-underlying branch, after authenticated scripting identity, underlying-data-mutation permission, audit coverage, and explicit target resolution are implemented. Accepted-command submission and agent-prompt submission remain future protocol concepts that require separate product/security review. + Input staging commands must not submit terminal input or press Enter. The separate `input run` execution action may submit a command only in the later execution-underlying branch, after authenticated scripting identity, an exact `input.run` grant, approval or configured policy, audit coverage, and explicit target resolution are implemented. Accepted-command submission and agent-prompt submission remain future protocol concepts that require separate product/security review. 21. Appearance actions: - List available themes. - Set the current fixed theme. @@ -149,14 +149,14 @@ Persistent settings changes, Warp Drive creation or sharing, cross-app preferenc - Open a path in a new tab or window. - Open a repository picker or repo path flow where the current app already supports it. These should remain allowlisted intent actions rather than arbitrary filesystem RPCs. -26. The following categories are explicitly excluded from the public allowlist even when internal actions exist for them: +26. The following actions are explicitly excluded from the public allowlist even when internal implementations exist: - Crash, panic, heap-dump, token-copying, debug-reset, and similar developer/debug helpers. - Arbitrary auth manipulation outside the explicit authenticated-scripting flows. - Arbitrary cloud object mutation or broad Warp Drive CRUD outside the typed Drive actions in this spec. - Arbitrary internal view dispatch by string. - Arbitrary setting names outside the allowlist. - Accepted-command submission and agent-prompt submission until they receive a separate product/security review. - Terminal command execution and typed Warp Drive object mutations are no longer excluded from the full product scope, but they belong to later authenticated underlying-data mutation branches and require stronger authenticated-user and permission gates than app-state or settings mutations. Local file content reads, writes, appends, deletes, and other filesystem-content mutations are excluded from the public `warpctrl` catalog; file/path support is limited to app-state intents such as opening a path in Warp and metadata reads of files already open in Warp. + Terminal command execution and typed Warp Drive object mutations are no longer excluded from the full product scope, but they belong to later authenticated underlying-data mutation branches and require stronger authenticated-user, approval, targeting, and audit requirements than ordinary UI actions. Local file content reads, writes, appends, deletes, and other filesystem-content mutations are excluded from the public `warpctrl` catalog; file/path support is limited to app-state intents such as opening a path in Warp and metadata reads of files already open in Warp. 27. CLI command names should be noun-oriented and discoverable. During the provisional wrapper-script phase, the control CLI should expose a `warpctrl ...` command surface: - `warpctrl instance list` - `warpctrl app ping` @@ -192,6 +192,7 @@ Persistent settings changes, Warp Drive creation or sharing, cross-app preferenc - Setting a theme that is not available in that instance. 33. The first `warpctrl` implementation slice should ship the smallest end-to-end vertical slice that proves: - The current implementation supports outside-Warp local-control requests only; verified inside-Warp requests are specified for future work and rejected until the app-issued terminal proof broker exists. + - The authoritative local-control mode is read only from protected storage, never imported from ordinary or private preferences, and defaults to disabled when no valid protected value is available. - Process discovery and target resolution work. - The wrapper-script command can reach a running local Warp process through the existing Warp binary's early control-mode dispatch without launching or initializing the GUI app. - `warpctrl tab create` creates a new terminal tab in the selected running instance. @@ -200,16 +201,11 @@ Persistent settings changes, Warp Drive creation or sharing, cross-app preferenc 34. Follow-up PRs should fill out the remaining catalog in parallelizable groups once the protocol, discovery model, target resolution, error model, `tab.create` action path, and wrapper-script `warpctrl` packaging shape have been validated by the first slice. 35. The protocol transport should be designed so that the default target is localhost but the CLI can be extended in the future to target remote URLs (e.g., a Warp instance on another machine or a hosted control endpoint). This is not in scope for the first implementation but should not be precluded by the architecture. ## API command surface -The public `warpctrl` API is organized around nouns that map to stable user-facing entities. Command names are intentionally not a dump of every internal `WorkspaceAction`, `TerminalAction`, keybinding, or command-palette binding. Internal actions inform the catalog, but a command is added only when it has a stable user-facing behavior, typed parameters, deterministic target resolution, and an explicit risk classification. +The public `warpctrl` API is organized around nouns that map to stable user-facing entities. Command names are intentionally not a dump of every internal `WorkspaceAction`, `TerminalAction`, keybinding, or command-palette binding. Internal actions inform the catalog, but a command is added only when it has a stable user-facing behavior, typed parameters, deterministic target resolution, and explicit authorization requirements. Catalog support status is part of the public API contract. An action reported as `implemented` by `warpctrl action list --implemented-only`, `warpctrl capability list --implemented-only`, or app discovery metadata must be reachable through the wrapper-backed `warpctrl ...` parser route, represented in generated help/completions/docs, and backed by an app-side bridge handler in the selected app build. Planned actions without that complete path must be reported as stubs or planned entries, even if an internal app handler already exists. -### State and data taxonomy -The product surface must distinguish what kind of state a command touches. This distinction is part of the public API and the permission model, not just an implementation detail. -- **Metadata reads** inspect app structure or configuration metadata without exposing user content: instances, windows, tabs, panes, sessions, capability metadata, action metadata, keybinding metadata, theme names, setting keys, and other structural state. -- **Underlying data reads** expose user content or data-bearing state without changing it: terminal output, block contents, command history, input buffer contents, Warp Drive object contents, AI conversation content, and any other content that could contain user data or secrets. -- **App-state mutations** change visible local Warp UI state without directly changing user data: opening or focusing windows, creating or closing tabs, splitting panes, focusing panes, opening panels, opening command surfaces, opening files in Warp, and editing the input buffer without submitting it. -- **Metadata/configuration mutations** change persistent configuration or metadata, but not primary user content: changing themes, font size, zoom, allowlisted settings, keybindings, tab names, pane names, and tab colors. -- **Underlying data mutations** can change user data or cause external side effects: typed CRUD operations on Warp Drive objects, sharing Warp Drive objects to the user's team through an explicit approved command, inserting content into Warp Drive views, running allowlisted Warp Drive workflows, and running terminal commands through an explicit `input run` action. Accepted-command submission, agent-prompt submission, local file content mutation, arbitrary workflow execution, and arbitrary internal dispatch remain excluded until separately reviewed. -A command that touches multiple categories must require the strongest applicable permission. For example, `file open` is an app-state mutation because it opens a visible Warp editor/view, while `input run` is an underlying data mutation because it executes a command in the target session. +### Direct action requirements +Every public action must declare the policy inputs that the broker and app bridge actually enforce: stable action identity, implementation status, authenticated-user requirement, allowed invocation contexts, target scope, typed parameters, and typed result. Credentials authorize one exact action; authority for one action never implies authority for another action, even when the actions have similar effects. +Sensitive actions carry stronger requirements directly. Actions that expose terminal output or other user content may require authenticated-user access or explicit approval. Actions that execute commands, mutate or share Warp Drive objects, change persistent settings, or cause external side effects require the identity, invocation context, target restrictions, approval, and audit coverage specified for that exact action. Opening or focusing Warp UI must never imply authority to execute commands or mutate user data. ### Targeting flags The full product should converge on shared selector flags for every command that addresses a running app target. The current foundation branch is not required to expose that complete CLI grammar yet: it supports instance selection with `--instance` and `--pid` for the implemented commands, while the shared window/tab/pane/session/block selector flags are deferred to the later target-selector branch that implements those target families. When the shared grammar ships, generic `--window`, `--tab`, `--pane`, `--session`, and `--block` flags accept the selector grammar below; explicit typed aliases are provided so scripts can avoid string parsing ambiguity: - `--instance <instance_id>` selects a running Warp process from `warpctrl instance list`. @@ -229,7 +225,7 @@ The full product should converge on shared selector flags for every command that - `--output-format <pretty|json|ndjson|text>` controls output shape and remains globally available. Within a selector family, specifying more than one form is invalid. For example, `--tab-id` conflicts with `--tab-index`, `--tab-title`, and `--tab`. Omitted lower-level selectors use active defaults only when the resolved target is unambiguous; window-scoped mutations may use the sole existing window when no active window is reported. Explicit IDs must resolve exactly or fail with `stale_target`; index/title/name/path selectors that match zero targets fail with `missing_target`, and selectors that match multiple targets fail with `ambiguous_target`. ### Read-only command set -The read-only v2 follow-up branches should implement the following commands before mutating catalog expansion begins. `zach/warp-cli-v2/readonly-capability-targets` owns structural metadata and targeting, while `zach/warp-cli-v2/appstate-file-drive-views` owns approved underlying-data reads and app/file/Drive view surfaces. Read-only does not mean one permission: metadata reads and underlying data reads are separate grant categories. +The read-only v2 follow-up branches should implement the following commands before mutating catalog expansion begins. `zach/warp-cli-v2/readonly-capability-targets` owns structural metadata and targeting, while `zach/warp-cli-v2/appstate-file-drive-views` owns approved underlying-data reads and app/file/Drive view surfaces. Read-only actions remain independently authorized: a credential for structural metadata does not authorize terminal output, input buffers, history, Drive content, or any other action. Metadata and capability reads: - `warpctrl instance list` - `warpctrl instance inspect [--instance <id>|--pid <pid>]` @@ -247,7 +243,7 @@ Window, tab, pane, and session reads: - `warpctrl pane inspect [--pane <selector>] [selectors]` - `warpctrl session list [--pane <selector>] [selectors]` - `warpctrl session inspect [--session <selector>] [selectors]` -Underlying data reads, gated separately from structural metadata reads: +Content-bearing reads, each gated by its own exact-action grant: - `warpctrl block list [--session <selector>|--pane <selector>] [--limit <n>] [selectors]` - `warpctrl block inspect --block <selector> [selectors]` - `warpctrl block output --block <selector> [--plain|--ansi|--json] [selectors]` @@ -274,7 +270,7 @@ Recommended CLI surface for app-backed authenticated status: - `warpctrl auth status [selectors]` reports local-control auth state, selected app login state, and verified Warp-terminal authenticated grant availability. - `warpctrl auth login [selectors]` focuses the selected Warp app's sign-in UI for interactive app-login flows. ### Mutating command set -The mutating v2 follow-up branches should build on the shared contract, auth/security, read-only, and targeting layers. `zach/warp-cli-v2/metadata-config-mutations` owns metadata/configuration mutations, `zach/warp-cli-v2/drive-data-mutations` owns Warp Drive underlying-data mutations, and `zach/warp-cli-v2/execution-underlying` owns terminal command execution and other approved execution-underlying actions. Approved app-state mutations and views land in the earliest v2 branch that owns their required targeting and permission prerequisites. Mutating commands are split by what they mutate: app-state, metadata/configuration, or underlying data. Underlying data mutations require a separate and stronger permission plus an authenticated scripting identity. +The mutating v2 follow-up branches should build on the shared contract, auth/security, read-only, and targeting layers. `zach/warp-cli-v2/metadata-config-mutations` owns metadata/configuration mutations, `zach/warp-cli-v2/drive-data-mutations` owns Warp Drive underlying-data mutations, and `zach/warp-cli-v2/execution-underlying` owns terminal command execution and other approved execution-underlying actions. Approved app-state mutations and views land in the earliest v2 branch that owns their required targeting and direct policy prerequisites. Every mutating command requires its own exact-action grant. Commands that mutate user data, execute code, or cause external side effects additionally require authenticated scripting identity, explicit approval or configured action policy, deterministic targets, and audit coverage. App-state mutations for app, window, and surfaces: - `warpctrl app focus [selectors]` - `warpctrl window create [--shell <name>] [selectors]` @@ -327,7 +323,7 @@ App-state mutations for sessions and input buffers: - `warpctrl input replace <text> [--session <selector>] [selectors]` - `warpctrl input clear [--session <selector>] [selectors]` - `warpctrl input mode set <terminal|agent> [--session <selector>] [selectors]` -These input-buffer commands only stage or edit text and must not submit the buffer. The separate `input run` command belongs only to the execution-underlying branch and requires authenticated scripting identity, underlying-data-mutation permission, explicit target resolution, and audit coverage. Accepted-command submission and agent-prompt submission remain excluded until separately reviewed. +These input-buffer commands only stage or edit text and must not submit the buffer. The separate `input run` command belongs only to the execution-underlying branch and requires authenticated scripting identity, an exact `input.run` grant, explicit target resolution, approval or configured policy, and audit coverage. Accepted-command submission and agent-prompt submission remain excluded until separately reviewed. Metadata/configuration mutations for appearance and settings: - `warpctrl theme set <theme_name> [selectors]` - `warpctrl theme system set <true|false> [selectors]` @@ -356,7 +352,7 @@ Underlying data mutations for authenticated Warp Drive objects: - `warpctrl drive workflow run <id> [--arg <name=value>...] [selectors]` Execution-underlying actions: - `warpctrl input run <command> [--session <selector>] [selectors]` -These are underlying-data mutations because they can modify user data, execute code, trigger external side effects, share cloud-backed content, or run user-authored content. They require authenticated scripting identity, underlying-data-mutation permission, deterministic target resolution, audit records, and explicit tests proving lower-permission credentials cannot run them. `drive object share-to-team` is the only direct sharing mutation in the v0 product scope: it may make a personal Warp Drive object available to the user's current team using the app's standard team-sharing semantics. Arbitrary ACL editing, sharing with specific users, sharing with external guests, public-link creation, accepted-command submission, and agent-prompt submission remain excluded until separately reviewed. +These are underlying-data mutations because they can modify user data, execute code, trigger external side effects, share cloud-backed content, or run user-authored content. They require authenticated scripting identity, exact-action grants, deterministic target resolution, approval or configured policy, audit records, and explicit tests proving credentials for other actions cannot run them. `drive object share-to-team` is the only direct sharing mutation in the v0 product scope: it may make a personal Warp Drive object available to the user's current team using the app's standard team-sharing semantics. Arbitrary ACL editing, sharing with specific users, sharing with external guests, public-link creation, accepted-command submission, and agent-prompt submission remain excluded until separately reviewed. ### Excluded from the public command surface The command surface must continue to exclude debug-only, crash-only, auth-token, heap-dump, and arbitrary internal dispatch actions even when those actions are available in command palette or keybinding registries. Examples that remain excluded are app crash/panic helpers, access-token copy helpers, heap profile dumps, debug reset actions, raw view-tree debugging, and broad internal action-by-string execution. ## Branch stacking and delivery model @@ -379,50 +375,26 @@ Warp should include a built-in Agent skill for `warpctrl`, analogous to the bund - JSON serialization and machine-readable output should use the same `serde`/`serde_json` conventions and the same output-format vocabulary used by the Oz CLI. - Human-readable help, examples, errors, and generated completions should follow Oz CLI conventions unless Warp Control has a documented product reason to differ. CLI documentation should be generated from the command catalog instead of maintained by hand in multiple places: -- The typed action catalog is the source of truth for command names, selector flags, parameters, output formats, state/data category, required permission, authenticated-user requirement, support status, and examples. +- The typed action catalog is the source of truth for command names, selector flags, parameters, output formats, authenticated-user requirement, allowed invocation contexts, target scope, support status, and examples. - `warpctrl help`, shell completions, markdown reference docs, the built-in Warp Agent skill, and the operator README should be generated or checked from that catalog so they cannot drift silently. - A later branch should add native Warp completions for `warpctrl` in addition to shell completions so Warp can suggest commands, flags, selectors, and action names directly in the input editor. - Generated documentation must distinguish implemented commands from planned catalog entries. A command may appear in specs as planned, but public operator docs must not imply it is usable until the selected app build advertises support for it. - CI or presubmit checks should fail when CLI parser/help output, generated reference docs, completions, or the built-in skill are stale relative to the command catalog. -## Action classification and permission model -Agents, scripts, and human developers are expected to be major consumers of `warpctrl`. The action catalog must therefore classify every action by risk posture, state/data category, permission category, and authenticated-user requirement so Warp can enforce local-control policy in the app bridge. +## Exact-action authorization model +Agents, scripts, and human developers are expected to be major consumers of `warpctrl`. Every credential therefore authorizes one exact typed action, and the app bridge verifies that exact action before selector resolution or handler dispatch. Similar actions do not inherit authority from one another. Every action definition must include: - a stable action name and namespace; -- a risk posture; -- a state/data category: metadata read, underlying data read, app-state mutation, metadata/configuration mutation, or underlying data mutation; - whether a true logged-in Warp user is required; - whether the action may run from external clients, verified Warp-terminal clients, or both; - whether inside-Warp and outside-Warp scripting settings can enable the action; -- the required local-control permission category; -- any target-scope restrictions. -By default, new actions require an authenticated Warp user. An action may be marked logged-out-safe only after deliberate review confirms it does not touch Warp Drive, AI conversation traces, synced settings, team/account data, cloud-backed user state, terminal content, or other user-sensitive data. +- any target-scope restrictions; +- typed parameter and result contracts; +- any action-specific approval, audit, or policy requirements. +By default, new actions require an authenticated Warp user. An action may be marked logged-out-safe only after deliberate review confirms it does not touch Warp Drive, AI conversation traces, synced settings, team/account data, cloud-backed user state, terminal content, or other user-sensitive data. Actions that expose sensitive content, mutate durable data, execute code, or cause external effects require stronger conditions attached directly to the action. ### Authenticated scripting model Authenticated scripting is required for any command that acts on a true Warp user identity or performs underlying-data mutation. Local-control credentials prove that a process may talk to the selected app; authenticated scripting credentials prove which logged-in Warp user is allowed to request user-backed or high-risk actions. Inside Warp, authenticated scripting uses the verified terminal proof flow: the selected app is already logged in, the terminal proof binds the CLI to a live Warp-managed session, and the broker may mint an authenticated-user grant for that app user when Settings > Scripting allows it. Outside-Warp invocations are limited to actions explicitly classified as logged-out-safe. External authenticated scripting is not part of the selected public contract. -### Permission categories -Every action in the catalog belongs to exactly one of the following permission categories, from least to most sensitive: -1. **Read-only / metadata.** Actions that return local app structure, app state, or configuration metadata without exposing terminal content, file content, Warp Drive object content, AI conversation content, or other user data. - - Instance discovery and health: `instance list`, `app active`, `app version`, `app ping`. - - Layout enumeration: `window list`, `tab list`, `pane list`, `session list`. - - Metadata reads: `theme list`, `setting list`, `keybinding list`, `action list`, and Drive object listing that returns object IDs/names/types but not content. -2. **Read-only / underlying data.** Actions that return user content or data-bearing state without changing it. - - Terminal reads: block output, scrollback, command history, input editor contents, session replay, or terminal-derived traces. - - Warp Drive object content reads, AI conversation reads, and any authenticated-user data read. - This category is separate from metadata because read-only content can contain secrets, source code, customer data, command output, or other sensitive data. -3. **Mutating / app state.** Actions that change visible local Warp UI state without directly changing underlying user data. - - Layout and focus: `window create`, `window focus`, `tab create`, `tab activate`, `tab move`, `window close`, `tab close`, `pane split`, `pane focus`, `pane navigate`, `pane maximize`, `pane resize`, and panel/surface toggles. - - Input-buffer staging: `input insert`, `input replace`, and `input clear` as long as they do not submit or execute the buffer. - - Opening views: opening settings, command palette, command search, Warp Drive, code review, files, notebooks, and env-var collections. -4. **Mutating / metadata or configuration.** Actions that change persistent metadata or configuration but do not directly mutate primary user data. - - Tab and pane names, tab colors, themes, system-theme settings, font size, zoom, allowlisted app settings, and keybindings. - Metadata/configuration writes need a stronger permission than app-state-only changes because they persist beyond the current UI interaction, but they are still distinct from data writes. -5. **Mutating / underlying data.** Actions that can change user data, execute code, submit prompts, or cause external side effects. - - Terminal execution through the explicit `input run` action and typed workflow execution where supported. - - Warp Drive CRUD and sharing: create, update, delete, insert, share to the user's current team, run, or otherwise mutate workflows, notebooks, prompts, env-var collections, folders, or other Drive objects. - - AI conversation history mutation and any action that modifies cloud-backed user content. - - Future agent execution: submitting an agent prompt, accepting an agent-proposed command, or otherwise causing an agent to act; these remain excluded until separately reviewed. - This category must be explicitly separate from app-state mutation and requires authenticated scripting identity. A client allowed to open or focus Warp UI must not automatically be allowed to execute commands, mutate Warp Drive content, or perform local file content operations. ### Authenticated-user requirement An authenticated user means a true logged-in Warp user in the selected Warp app, not merely the local OS user or a `warpctrl` process authenticated to localhost. The allowlist must clearly indicate `requires_authenticated_user` for every action: @@ -449,30 +421,23 @@ Warp should add a new top-level Settings pane page named **Scripting**. This pag - **Enabled within Warp:** allows only verified Warp-managed terminal invocations once the proof broker exists. In the current foundation branch, inside-Warp proof verification is not implemented yet, so requests in this mode are rejected rather than silently treated as external. - **Enabled everywhere, including outside Warp:** allows verified Warp-managed terminal invocations and external local clients such as other terminals, scripts, IDEs, launch agents, and same-user automation to request local-control credentials. The Scripting page should explain that the default mode blocks local-control credentials, the within-Warp mode is reserved for verified Warp-managed terminals once proof support lands, and the broadest mode allows other local apps and scripts to talk to Warp's control plane. Changing the mode should invalidate or prevent credentials for invocation contexts no longer allowed by the selected mode. -### Local-control permission policy -The Scripting settings page should not expose separate per-risk local-control toggles in the foundation stack. The single mode setting defines which invocation contexts may receive credentials. The app bridge still enforces each action's risk posture, state/data category, authenticated-user requirement, execution-context requirement, and target scope for every request. Enabling the broadest mode must not bypass catalog enforcement or imply permission to run actions that require authenticated scripting identity, logged-in user state, or future review. +### Local-control action policy +The Scripting settings page should not expose separate risk-group toggles in the foundation stack. The single mode setting defines which invocation contexts may request credentials. For every request, the broker and app bridge still enforce the exact granted action, authenticated-user requirement, execution-context requirement, target restrictions, and any action-specific approval or audit policy. Enabling the broadest mode must not imply permission to run a different action or bypass authenticated scripting identity, logged-in user state, or future review. ### Agent Profile permissions -Agent Profiles should expose a dedicated **Warp control** permission group for agents that can invoke `warpctrl`. This permission group should mirror the local-control action categories so users can choose different `warpctrl` authority for different agent workflows: -- Metadata reads. -- Underlying data reads. -- App-state mutations. -- Metadata/configuration mutations. -- Underlying data mutations. -Each category should support the same autonomy vocabulary used by other Agent Profile permissions: the agent may be allowed to proceed, required to ask, allowed to decide based on confidence and risk, or denied. A cautious profile can therefore allow metadata reads and ask for app-state mutations, while a demo or onboarding profile can be explicitly configured to allow workspace organization or presentation setup. -Agent Profile permissions and global Scripting settings both apply. Settings > Scripting defines the maximum local-control authority available for an execution context, such as verified inside-Warp invocation or outside-Warp invocation. The selected Agent Profile defines what that specific agent is allowed to request within that maximum. If either layer denies an action category, authenticated-user requirement, or execution context, the request fails with a structured permission error instead of falling back to a weaker action or a raw `warpctrl` shell command. +Agent Profiles should expose a dedicated **Warp control** permission group for agents that can invoke `warpctrl`. Profile policy should evaluate the requested exact action using the same autonomy vocabulary used by other Agent Profile permissions: allow, ask, let the agent decide based on confidence and risk, or deny. Profiles may offer curated UI groupings for usability, but those groupings must not become credential scopes or allow one approved action to authorize another. +Agent Profile permissions and global Scripting settings both apply. Settings > Scripting defines which invocation contexts may request local-control credentials. The selected Agent Profile determines whether that agent may request the specific action within that maximum. If either layer denies the action, authenticated-user requirement, or execution context, the request fails with a structured permission error instead of falling back to a weaker action or a raw `warpctrl` shell command. The profile-level permission group should preserve the native-tools-first boundary. Agents should prefer native tools for code editing, file reads/writes, shell command execution, web/MCP calls, and attached conversation or block context when those tools are available. Agents should prefer `warpctrl` when the task requires operating Warp product surfaces, preserving visible UI context for the user, using Warp Drive as a first-class app surface, or applying the app's own permissioned control plane. -### Scoped credentials -The local discovery record must not expose a reusable full-access credential. `warpctrl` should request scoped credentials from an app-owned broker or equivalent trusted path. -Scoped credentials should include: +### Exact-action credentials +The local discovery record must not expose a reusable full-access credential. `warpctrl` requests a short-lived credential for the one typed action it is about to invoke from an app-owned broker or equivalent trusted path. +Exact-action credentials include: - the selected Warp instance; -- granted permission categories; -- allowed action families; +- the granted `ActionKind`; - verified execution context; - whether authenticated-user access is granted and for which logged-in user subject; - optional target scopes; - issuance and expiry metadata; - revocation/audit identity. -The bridge, not the CLI frontend, enforces these grants. If a request exceeds its credential, the bridge returns `insufficient_permissions`, `authenticated_user_required`, `authenticated_user_unavailable`, or `execution_context_not_allowed` as appropriate. +The bridge, not the CLI frontend, enforces these grants. If a request presents a credential for a different action or otherwise exceeds its authority, the bridge returns `insufficient_permissions`, `authenticated_user_required`, `authenticated_user_unavailable`, or `execution_context_not_allowed` as appropriate. ### Future entity extensibility: files, blocks, and Warp Drive objects The selector and action model should accommodate entity types beyond the current window/tab/pane/session hierarchy. Important entity families are **terminal blocks**, **file/path intents**, and **Warp Drive objects**. Broad Drive mutation and command execution are not in scope for the foundation branch, but they are in scope for later authenticated branches in the expanded stack. Local file content reads, writes, appends, deletes, and other filesystem-content mutations are intentionally out of scope for the public `warpctrl` catalog because native agent file tools are the preferred surface for file content operations. Agent-prompt submission remains excluded until separately reviewed. **Terminal blocks.** Blocks are first-class targetable terminal entities, not just data hanging off a session. Block selectors should support the same addressing primitives as terminal sessions where meaningful: active/current block, opaque block ID, and block index scoped to the resolved session. Block reads can expose command text, output, status, timing, exit code, and metadata, so block content reads are underlying-data reads while block listing that returns only IDs/status/timestamps may be metadata reads. Stale, missing, or ambiguous block selectors must fail rather than selecting a neighboring block. @@ -489,16 +454,16 @@ File selectors use filesystem paths (absolute or relative to the working directo - `warpctrl drive object share open <id>` — app-state mutation that opens the sharing dialog for user review without changing sharing state. - `warpctrl drive object share-to-team <id>` — authenticated underlying data mutation that makes a personal object available to the user's current team using the app's standard team-sharing behavior. This is the only direct sharing mutation in the v0 product scope. - `warpctrl drive notebook open <notebook-id>` — app-state mutation that opens a view of an existing notebook without modifying it. -Drive object selectors should support both opaque IDs (for automation stability) and human-friendly name/path lookups (for interactive use). The type field (`workflow`, `notebook`, `env_var_collection`, `prompt`, `folder`, `ai_fact`, `mcp_server`, etc.) acts as a namespace filter. Drive actions that execute content in a terminal session (e.g., running a workflow) inherit the underlying-data-mutation permission from the action classification model and are implemented only in the execution-underlying branch after authenticated scripting identity and audit coverage are in place. +Drive object selectors should support both opaque IDs (for automation stability) and human-friendly name/path lookups (for interactive use). The type field (`workflow`, `notebook`, `env_var_collection`, `prompt`, `folder`, `ai_fact`, `mcp_server`, etc.) acts as a namespace filter. Drive actions that execute content in a terminal session, such as running a workflow, require an exact grant for that execution action and are implemented only in the execution-underlying branch after authenticated scripting identity, approval policy, and audit coverage are in place. **Design constraints for these future entity families:** - File and Drive selectors are orthogonal to the window/tab/pane hierarchy — a file open action targets an instance (which window to open in), not a specific pane. A Drive workflow execution targets a session (which pane to run in). - The `TargetSelector` type in the protocol should be extensible with optional fields for these new selector families without breaking existing requests that omit them. -- The action classification categories apply, and Drive actions require authenticated-user grants by default: listing Drive objects is metadata plus authenticated user, reading Drive object content is underlying-data-read plus authenticated user, opening an existing Drive object or its sharing dialog in the app is app-state mutation plus authenticated user, and executing, sharing, or changing a Drive object is underlying-data-mutation plus authenticated user. +- Drive actions require authenticated-user grants by default. Listing, reading content, opening a view, executing, sharing, and changing a Drive object are separate exact actions; a grant for any one of them does not authorize the others. Executing, sharing, or changing a Drive object additionally requires its action-specific approval and audit policy. ### Settings: protocol-first Settings reads and writes should go through the local-control protocol like other actions, not bypass it via direct file manipulation. - `warpctrl setting get <key>`, `warpctrl setting set <key> <value>`, and `warpctrl setting toggle <key>` send requests to the running Warp instance through the standard authenticated control endpoint. - The app bridge validates the key against the allowlist and the value against the expected type before applying the change. -- This keeps authorization enforcement consistent: the same permission category, execution-context, and authenticated-user policies apply to settings mutations as to any other action, rather than creating a second unguarded path through the filesystem. +- This keeps authorization enforcement consistent: the exact requested setting action, execution context, authenticated-user requirement, and action-specific policy are checked like any other action, rather than creating a second unguarded path through the filesystem. - The app owns the write to the settings file and any side effects (e.g., theme reload, layout reflow) as a single atomic operation, avoiding races between a direct settings-file edit and the app's file watcher. - If a future need arises for offline settings manipulation (no running Warp process), a separate file-based path can be added later with its own validation, but it should not be the default. -- The action classification still applies: settings reads are metadata reads, and settings writes are metadata/configuration mutations. Settings writes must not be authorized by app-state mutation permission alone. +- Settings reads and writes are separate exact actions. A credential for opening or focusing Warp UI must not authorize any settings write. diff --git a/specs/warp-control-cli/README.md b/specs/warp-control-cli/README.md index cc71241f09..d8269a29d5 100644 --- a/specs/warp-control-cli/README.md +++ b/specs/warp-control-cli/README.md @@ -76,7 +76,7 @@ The local-control protocol is designed for same-user scripting, not cross-user o - **Loopback-only listener.** Each Warp process binds its control server to `127.0.0.1` on an ephemeral port. The listener is not reachable from the network. - **Brokered scoped credentials.** Discovery records contain instance metadata, loopback control-endpoint information, and an instance-bound Unix-domain-socket broker reference only when the selected Scripting mode allows outside-Warp control. The broker authenticates the connecting OS user with kernel peer credentials before decoding the credential request or issuing an action-scoped credential. Records do not contain bearer tokens or reusable full-access credentials. - **Short-lived grants.** `warpctrl` requests an action-scoped credential over the owner-authenticated broker socket for the selected instance and invocation context, then presents that credential to `/v1/control`. Grants are instance-bound, expired entries are pruned, and the in-memory grant set is capped. Missing, invalid, expired, revoked, or wrong-instance credentials are rejected before request decoding. After decoding identifies the requested action, insufficient-scope credentials are rejected before selector resolution or handler dispatch. -- **Protected local state.** The authoritative Scripting mode uses platform secure storage where available. During migration, an existing private-preferences value may remain as an explicitly weaker owner-only fallback when secure storage is unavailable; it remains private, local-only, and never cloud-synced. On POSIX platforms, discovery records, broker sockets, and fallback local state use owner-only permissions. On Windows, outside-Warp publication remains disabled until equivalent ACL and broker protections are implemented. +- **Protected local state.** The authoritative Scripting mode uses platform secure storage, never imports a value from ordinary or private preferences, and defaults to disabled when no valid protected value is available. On POSIX platforms, discovery records and broker sockets use owner-only permissions. On Windows, outside-Warp publication remains disabled until equivalent ACL and broker protections are implemented. - **Stale-record pruning.** On each `instance list` or implicit discovery call, records whose PID is no longer alive are deleted automatically. Candidates are also health-probed and accepted only when the live app reports the expected instance identity. - **No CORS.** The control endpoints do not set permissive CORS headers, so browser-origin JavaScript cannot read responses even if it guesses the port. The credential requirement provides a second layer since browsers cannot read the brokered credential material. ```mermaid diff --git a/specs/warp-control-cli/SECURITY.md b/specs/warp-control-cli/SECURITY.md index b2df4d25bf..40f8aa6f39 100644 --- a/specs/warp-control-cli/SECURITY.md +++ b/specs/warp-control-cli/SECURITY.md @@ -1,7 +1,7 @@ # warpctrl security architecture `warpctrl` is a local-control CLI for an already-running Warp app instance. Its security architecture is designed to support the control catalog: discovery, structural metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, underlying data mutations, input-buffer staging, file/path app-state intents, Warp Drive operations, and explicitly authenticated execution-underlying actions. Accepted-command submission and agent-prompt submission remain future high-risk capabilities that require separate product/security review. Local file content operations are intentionally excluded from the public `warpctrl` catalog because native agent file tools are the preferred surface for file content reads and writes. -The correct architecture is not a single shared localhost bearer token with client-side conventions. The CLI, app bridge, and protocol must treat security as a local app-enforced capability system: discovery finds compatible instances, protected storage safeguards the authoritative mode and any future long-lived proof secrets, broker-issued credentials identify the granted scopes, the running Warp app's local-control bridge enforces action categories before dispatch, and target resolution never silently retargets a request. -The action-category model is primarily a safety and intent mechanism, not a hard security boundary against malicious same-user software. It lets a user, script, or agent intentionally request metadata-only, data-read, app-state mutation, metadata/configuration mutation, or underlying-data mutation access so it does not accidentally mutate state, expose sensitive content, or execute commands. It should not be described as strong access control against a process that can already run arbitrary commands as the user. +The correct architecture is not a single shared localhost bearer token with client-side conventions. The CLI, app bridge, and protocol must treat security as a local app-enforced capability system: discovery finds compatible instances, protected storage safeguards the authoritative mode and any future long-lived proof secrets, broker-issued credentials grant one exact action, the running Warp app's local-control bridge verifies that action before dispatch, and target resolution never silently retargets a request. +Exact-action credentials and action-specific approval policy are primarily safety and intent mechanisms, not a hard security boundary against malicious same-user software. They let a user, script, or agent request only the specific operation it intends to perform so authority for a harmless UI action cannot accidentally be reused to expose sensitive content, mutate durable data, or execute commands. They should not be described as strong access control against a process that can already run arbitrary commands as the user. `warpctrl` has two distinct authorization dimensions: local-control authority and authenticated scripting authority. Local-control authority proves the request is allowed to control the local app. Authenticated scripting authority proves the logged-in Warp user that is allowed to act on user-authenticated data such as Warp Drive objects, AI conversation traces, synced settings, cloud-backed user state, and execution-underlying actions. Logged-out users should retain a smaller local-only control surface, but authenticated-user and high-risk underlying-data mutation actions require a verified Warp-terminal grant tied to the selected app's logged-in user. ## Current foundation status The current foundation implementation stores a single local-control mode with three choices: disabled by default, enabled within Warp, and enabled everywhere including outside Warp. Verified inside-Warp invocation is specified as future work because the app-issued terminal-session proof broker, proof injection path, and session registry do not exist yet. Until those pieces land, `InvocationContext::InsideWarp` requests must be rejected with `execution_context_not_allowed`, implemented action metadata must not advertise inside-Warp support, and external requests must receive credentials only when the user selects the broadest mode. On Unix, the broker authenticates the connecting OS user through kernel peer credentials before decoding credential requests, then mints short-lived scoped credentials in memory without a stored or bootstrap local-control secret. The current broker therefore trusts the owning OS user rather than authenticating Warp-signed client code. Windows outside-Warp publication remains disabled until discovery-record ACL enforcement and an equivalent authenticated broker transport land. @@ -18,11 +18,11 @@ The current foundation implementation stores a single local-control mode with th - Distinguish verified `warpctrl` invocations that originate from a Warp-managed terminal session from external same-user invocations. - When the broadest mode enables outside-Warp control, allow external invocations only for the action set explicitly allowed by the action catalog and granted credential. - Allow in-Warp invocations to receive authenticated-user grants when the selected Warp app has a true logged-in user and the user's local-control mode and action policy permit that grant. -- Support least-privilege safety modes for automation and interactive use without relying on an unenforceable identity label. -- Classify every action by state/data category and enforce the required permission category in the local app bridge, not in the CLI frontend. +- Support least-privilege exact-action grants for automation and interactive use without relying on an unenforceable identity label. +- Authorize every action by its exact typed identity in the local app bridge, not in the CLI frontend. - Classify every action by whether it requires an authenticated Warp user. New actions should default to requiring an authenticated user unless they are deliberately reviewed as logged-out-safe and therefore eligible for external use. - Prevent `warpctrl` from becoming an ambient full-power confused deputy that any same-user process can invoke for high-risk actions. -- Require authenticated scripting identity, underlying-data-mutation permission, explicit target resolution, and audit records before terminal command execution or typed workflow execution can ship; accepted-command submission and agent-prompt submission remain prohibited until separately reviewed. +- Require authenticated scripting identity, an exact execution-action grant, explicit approval or configured policy, deterministic target resolution, and audit records before terminal command execution or typed workflow execution can ship; accepted-command submission and agent-prompt submission remain prohibited until separately reviewed. - Preserve deterministic targeting so a request never silently mutates or reads the wrong window, tab, pane, session, file/path intent, or Warp Drive object. - Keep the action surface allowlisted and typed rather than exposing arbitrary internal app dispatch. - Make high-risk operations auditable and configurable without logging sensitive terminal contents or credentials. @@ -39,7 +39,7 @@ For same-user local apps, the realistic goal is narrower: - do not leave a raw bearer token in plaintext discovery records; - prevent ambient direct HTTP calls to the localhost control listener by requiring a just-in-time broker-issued scoped credential; - use platform secure storage, such as macOS Keychain, for future long-lived proof or bootstrap secrets so they are accessible only to Warp-owned signed code where practical; -- make high-risk operations go through `warpctrl` or a Warp-owned helper where user approval, configured policy, and safety grants can be applied; +- make high-risk operations go through `warpctrl` or a Warp-owned helper where user approval, configured policy, and exact-action grants can be applied; - avoid giving `warpctrl` ambient non-interactive full-control authority. In other words, the security model can make ambient direct localhost protocol calls fail, and future protected secrets can make direct credential theft harder. The current Unix broker still allows any process running as the owning OS user to request eligible scoped credentials. It cannot make a same-user malicious app safe if that app can invoke `warpctrl`, connect to the broker, automate the user's desktop, read other local state, or wait for the user to approve prompts. ## Comparison with other local scripting models @@ -49,7 +49,7 @@ VS Code's `code` command is primarily a launch and routing CLI: it opens files, VS Code's richer local automation runs through extension APIs and extension hosts. Extensions are installed into a trusted editor environment and run with broad access to the workspace or UI side depending on extension kind. Workspace Trust and remote extension placement help users reason about whether code should run locally, remotely, or in a browser sandbox, but they do not create a fine-grained same-user security boundary against arbitrary local software. Lessons for `warpctrl`: - a narrow, typed CLI command surface is safer to reason about than exposing arbitrary internal app commands; -- agent and script workflows should request explicit capabilities instead of inheriting ambient full-control authority; +- agent and script workflows should request exact actions instead of inheriting ambient full-control authority; - local UI control should remain distinct from remote/tunnel control because remote transports need stronger identity, approval, and network-security semantics. ### Chrome DevTools Protocol Chrome DevTools Protocol is a powerful debugging and automation API. When Chrome is launched with remote debugging enabled, clients can discover targets over local HTTP endpoints and then control the browser over WebSocket. That protocol is intentionally high-power: it can inspect pages, navigate, execute JavaScript, observe network state, and interact with browser storage. @@ -65,7 +65,7 @@ Ghostty also supports terminal-oriented features such as shell integration and c Lessons for `warpctrl`: - use platform security mechanisms where they exist, such as macOS Keychain and Automation prompts; - keep a user-visible kill switch or policy path for scripting/control surfaces; -- do not rely only on platform automation permission if Warp needs cross-platform, action-scoped safety grants. +- do not rely only on platform automation permission if Warp needs cross-platform, exact-action grants. ### iTerm2 Python API iTerm2's Python API is a close comparison for terminal automation. The API is disabled by default. When enabled, iTerm2 listens on a Unix domain socket and requires authentication by default. Scripts launched by iTerm2 receive a random cookie in the environment, while external programs can request a cookie through AppleScript so macOS Automation permission mediates access. iTerm2 also documents an administrator-gated escape hatch to allow unauthenticated local apps. This model directly acknowledges that terminal contents are sensitive and that any local automation API can affect local and remote hosts connected through terminal sessions. @@ -88,7 +88,7 @@ Compared with these systems, `warpctrl` should combine: - iTerm2's use of explicit local credentials and macOS Automation-style approval for external control; - Ghostty's use of platform-native scripting controls where available; - VS Code's preference for typed public commands and separate treatment of remote control. -The resulting architecture should not claim to solve arbitrary same-user malicious-app isolation. Its real value is preventing web-origin and other-user access, preventing unauthenticated direct localhost calls, keeping raw credentials out of plaintext discovery, giving honest scripts and agents narrow safety grants, and routing high-risk operations through local Warp app validation and user/policy approval. +The resulting architecture should not claim to solve arbitrary same-user malicious-app isolation. Its real value is preventing web-origin and other-user access, preventing unauthenticated direct localhost calls, keeping raw credentials out of plaintext discovery, giving honest scripts and agents narrow exact-action grants, and routing high-risk operations through local Warp app validation and user/policy approval. ## Authoritative enablement model Warp control has one top-level mode setting based on invocation context: - **Disabled:** default. No local-control invocation context can receive credentials. @@ -96,7 +96,7 @@ Warp control has one top-level mode setting based on invocation context: - **Enabled everywhere, including outside Warp:** controls verified Warp-managed terminal invocations and external terminals, scripts, launch agents, IDEs, or other same-user processes. The mode should live in a new top-level Settings pane page named **Scripting**. The Scripting page owns the user-facing controls for local scripting surfaces, including Warp control, and should explain the difference between commands run inside Warp and commands run from other apps. The visible UI setting is not enough by itself. The authoritative mode must be stored in the most secure local storage provider available for the platform, with read/write access limited to the Warp application or Warp-owned trusted helper code where the platform supports that restriction. On macOS this means Keychain or an equivalent protected store constrained to Warp-signed code, not ordinary UserDefaults; on Windows this means Credential Manager, DPAPI-backed protected storage, or an equivalent app-controlled protected store; on Linux this means the platform secret service where available, with any owner-only file fallback explicitly documented as weaker. This avoids turning outside-Warp control into a feature that any process can silently enable before invoking `warpctrl`. -Current foundation implementation note: the mode is represented in the typed `LocalControlSettings` group and prefers Warp's secure storage provider over ordinary private preferences, `settings.toml`, SQLite, or a synced cloud preference. The implemented setting uses `SyncToCloud::Never` and remains absent from user-visible settings files, generated schemas, Settings Sync, Warp Drive, local-control settings read/write commands, and user-editable or server-backed settings surfaces. During migration, an earlier private-preferences value is cleared only after the protected write succeeds; when secure storage is unavailable, the value is intentionally preserved as an explicitly weaker owner-only private-preferences fallback. This is a tamper-resistant platform storage preference, not a claim that arbitrary same-user compromise is impossible. +Current foundation implementation note: the mode is represented in the typed `LocalControlSettings` group and reads only from Warp's secure storage provider, never ordinary private preferences, `settings.toml`, SQLite, or a synced cloud preference. The implemented setting uses `SyncToCloud::Never` and remains absent from user-visible settings files, generated schemas, Settings Sync, Warp Drive, local-control settings read/write commands, and user-editable or server-backed settings surfaces. It does not migrate or fall back to an earlier private-preferences value; when no valid protected value is available, it fails closed to disabled. This is a tamper-resistant platform storage preference, not a claim that arbitrary same-user compromise is impossible. Enablement requirements: - The mode is local-only and must not sync through Settings Sync, Warp Drive, or server-backed user preferences. - The implemented foundation setting must remain private and absent from user-visible settings files, generated schemas, local-control settings read/write commands, and any allowlisted settings mutation catalog. @@ -106,7 +106,7 @@ Enablement requirements: - Outside-Warp control requires an intentional user gesture to select the broadest mode; the UI should explain that it allows scripts and automation from other apps to control Warp. - The mode should be easy to change from the same UI, and narrowing the mode should revoke or invalidate active local-control credentials for invocation contexts no longer allowed. - If enterprise or managed-device policy is added later, policy may force-disable the mode or force a narrower default, but policy should be separate from user-editable local settings. -Local-control actions that open, focus, or view cloud-backed objects must not create unexpected cloud-synced durable side effects merely because the object was displayed through automation. If an action intentionally mutates synced state, that mutation must be classified under the appropriate state/data category and require the matching grant, authenticated-user authority, and user or policy approval where applicable. +Local-control actions that open, focus, or view cloud-backed objects must not create unexpected cloud-synced durable side effects merely because the object was displayed through automation. If an action intentionally mutates synced state, that exact action must require authenticated-user authority plus user or policy approval where applicable. Disabled-state behavior: - Warp should not mint scoped local-control credentials for a request whose invocation context is disabled. - The control listener should reject requests from disabled contexts with a structured disabled-state error before authentication, selector resolution, or handler dispatch. @@ -114,25 +114,20 @@ Disabled-state behavior: - `warpctrl` may detect a disabled context and print instructions to enable it in Settings > Scripting, but it must not offer a command that flips the setting. - Previously issued credentials must become unusable when their invocation context is no longer allowed, even if their original expiry has not elapsed. These enablement gates do not create perfect same-user malicious-app isolation. A hostile process with Accessibility or Screen Recording permission might still try to automate the Warp UI. The outside-Warp gate is still important because it closes the much easier paths where external apps silently edit local preferences, call a config CLI, or write synced settings to enable a powerful control surface. -### Permission categories and grants -The foundation stack should not expose separate per-risk toggles under Settings > Scripting. Once the selected mode allows a request context, the broker and app bridge still enforce each action's catalog classification and the credential's grants: -- **Metadata reads:** inspect non-sensitive local app structure and configuration metadata such as instances, windows, tabs, panes, protocol and build identity metadata, theme names, setting keys, action metadata, and Drive object IDs/names/types without content. -- **Underlying data reads:** read terminal output, scrollback, input buffers, command history, session traces, Warp Drive object contents, AI conversation content, and other content-bearing state. -- **App-state mutations:** change local UI/layout/focus such as opening windows, creating tabs, closing tabs, focusing panes, splitting panes, opening panels, opening files/views, and staging text in the input buffer without executing it. -- **Metadata/configuration mutations:** change persistent metadata or configuration such as tab/pane names, tab colors, themes, font size, zoom, allowlisted settings, and keybindings. -- **Underlying data mutations:** mutate Warp Drive objects, share personal objects to a team, mutate AI conversation data, run terminal commands, run typed workflows, or perform any other allowlisted action that can change user data or cause external side effects. -The single mode setting is an invocation-context gate, not a replacement for action classification. App-state mutation permission must not imply metadata/configuration mutation or underlying data mutation permission. Authenticated-user actions remain separately gated by verified Warp-terminal identity and by the selected app's logged-in user state where required. +### Exact-action grants and direct policy +The foundation stack should not expose separate per-risk toggles under Settings > Scripting. Once the selected mode allows a request context, the broker issues a short-lived credential for the one typed action requested, and the app bridge verifies that exact action. A credential for one action never authorizes another action, even if both actions read similar data or produce similar mutations. +Sensitive requirements attach directly to actions. Actions that expose terminal output or user content may require authenticated-user access or approval. Actions that execute code, mutate or share Warp Drive objects, change persistent configuration, or cause external effects require authenticated scripting identity, deterministic targets, action-specific approval or configured policy, and audit coverage as specified for that action. Opening or focusing Warp UI must never imply authority to execute commands or mutate user data. ## Trust boundaries `warpctrl` has several distinct trust boundaries. ### Operating-system user boundary The baseline local trust boundary is the OS user account. Discovery records and local credential material must be readable only by the owning user. This protects against other local users and network peers, but it does not protect against an already-compromised same-user process. ### Invocation boundary -Same-user does not mean same authority. Interactive use and unattended automation may both run commands under the same user account, but they should be able to intentionally request narrower capabilities. The protocol needs scoped credentials that encode concrete grants, target scopes, and lifetimes rather than an abstract caller type that the bridge cannot reliably verify. +Same-user does not mean same authority. Interactive use and unattended automation may both run commands under the same user account, but they should be able to intentionally request only the action they need. The protocol needs exact-action credentials that encode the granted action, target scopes, and lifetimes rather than an abstract caller type that the bridge cannot reliably verify. These scoped credentials are guardrails for well-behaved clients. They prevent accidental overreach and make user intent explicit, but they are not a defense against malicious same-user code that can automate the CLI, inspect the user's environment, or wait for user approvals. ### Warp-terminal execution context boundary `warpctrl` should be able to receive special grants when it is invoked from within a Warp-managed terminal session, but the bridge must not trust a caller-supplied string such as `caller_class=warp_terminal`. The app should issue a session-bound execution-context proof to Warp-managed terminal sessions and have the broker verify that proof before minting in-Warp-only grants. Acceptable designs include a short-lived per-session capability, an app-owned broker handshake tied to the terminal session, or an equivalent proof that arbitrary external processes cannot mint by setting an environment variable. Plain environment variables may be used as handles or hints, but they must not be the sole authority for in-Warp privileges because external processes can spoof them. -Verified in-Warp context can raise the maximum eligible grant set, especially for authenticated-user actions. It does not by itself bypass the selected local-control mode, action categories, target scopes, or logged-in-user requirements. +Verified in-Warp context can raise the maximum eligible grant set, especially for authenticated-user actions. It does not by itself bypass the selected local-control mode, exact-action check, target scopes, or logged-in-user requirements. ### Authenticated scripting boundary Actions that touch user-authenticated Warp data or perform high-risk underlying-data mutations require authenticated scripting authority. This includes Warp Drive object contents, object mutation, the v0 personal-to-team sharing path, AI conversation traces, cloud-backed user settings, team/account data, typed workflow execution, terminal command execution, and any other surface whose normal app access depends on the user's Warp account or can cause external side effects. Authenticated scripting uses verified Warp-terminal mode: `warpctrl` presents an app-issued terminal-session proof. If the selected app is logged into Warp and Settings > Scripting mode plus action policy permit authenticated actions from verified Warp terminals, the broker may mint an authenticated-user grant tied to the selected app's current user subject. External API-key authenticated scripting is not part of the selected public contract and requires a separate product/security review before it can be allowlisted. @@ -149,24 +144,24 @@ Requirements: Logged-out-safe actions continue to use local-control credentials without requiring authenticated scripting identity. ### Application identity boundary On platforms with secure credential storage, especially macOS, future long-lived proof or bootstrap secrets should be readable only by Warp-owned, correctly signed code. On macOS this means storing that material in Keychain with access constrained by Warp's signing identity, designated requirement, Keychain access group, or equivalent platform mechanism. This narrows extraction from “any same-user process can read a file” to “only trusted Warp-signed code can unwrap the secret.” -This future boundary protects stored secrets from direct theft and can prevent arbitrary apps from using those secrets to make authenticated raw HTTP requests to the local-control listener. It also lets the authoritative mode be stored somewhere harder to modify than ordinary user preferences. The current Unix foundation does not implement this application-identity boundary for local-control credential issuance: it verifies the broker peer's OS user and mints short-lived credentials in memory. Neither model proves that the user personally intended the specific action. Any same-user process may still be able to invoke the trusted `warpctrl` binary or automate the Warp UI. That confused-deputy risk is reduced by explicit in-app enablement, scoped credential issuance, action-category policy, and local app-side bridge enforcement, but it is not eliminated as a hard same-user security boundary. +This future boundary protects stored secrets from direct theft and can prevent arbitrary apps from using those secrets to make authenticated raw HTTP requests to the local-control listener. It also lets the authoritative mode be stored somewhere harder to modify than ordinary user preferences. The current Unix foundation does not implement this application-identity boundary for local-control credential issuance: it verifies the broker peer's OS user and mints short-lived credentials in memory. Neither model proves that the user personally intended the specific action. Any same-user process may still be able to invoke the trusted `warpctrl` binary or automate the Warp UI. That confused-deputy risk is reduced by explicit in-app enablement, exact-action credential issuance, action-specific policy, and local app-side bridge enforcement, but it is not eliminated as a hard same-user security boundary. ### Action boundary -Every action belongs to a state/data category. The bridge must map the requested action to a required permission category and compare that category to the presented credential before selector resolution or handler dispatch. +Every credential grants one exact typed action. The bridge must compare the requested action to that granted action before selector resolution or handler dispatch. Actions with stronger identity, context, target, approval, or audit requirements declare and enforce those requirements directly. ### Target boundary A valid credential for one instance or target must not imply authority over another. Credentials should be bound to the issuing Warp instance and may be further scoped to target families such as terminal sessions, files, or Warp Drive objects when those surfaces are exposed. ## Threat model ### In scope - Other local OS users attempting to control a Warp instance owned by the current user. - Browser-origin JavaScript attempting to call localhost control endpoints. -- Same-user automation attempting actions without the required scoped grants. +- Same-user automation attempting an action without a credential for that exact action. - Same-user processes attempting to extract plaintext credentials from local state. -- Same-user processes invoking `warpctrl` as a confused deputy for actions the process could not authorize directly. +- Same-user processes invoking `warpctrl` as a confused deputy for actions the process does not hold exact-action authority for directly. - External same-user processes attempting authenticated-user actions that should be limited to verified Warp-terminal invocations. - Logged-out requests attempting actions that require a true logged-in Warp user. - Stale discovery records from exited Warp processes. - Multiple running Warp instances where ambiguous selection could target the wrong process. - Malformed clients attempting unknown, unsupported, unallowlisted, or invalid action payloads. -- Valid clients attempting actions above their granted permission category. +- Valid clients attempting actions other than the exact action granted by their credential. - Explicit target IDs that become stale between discovery and execution. - Future handlers that expose terminal data, settings writes, input mutation, command execution, file intents, or Warp Drive object operations. ### Out of scope @@ -180,9 +175,9 @@ The security model has eight layers: 2. **Discovery:** Find compatible live Warp instances without granting broad authority. 3. **Secret handling:** Mint the current short-lived local-control credentials in memory, keep all secrets outside plaintext discovery records, and restrict future stored proof or bootstrap secrets to trusted Warp-owned code where the platform supports it. 4. **Execution context verification:** Distinguish verified Warp-terminal invocations from external same-user invocations without trusting caller-declared labels. -5. **Credential issuance:** Issue scope-specific credentials with explicit grants and lifetimes only when the selected mode allows the request's invocation context and the requested action/category is allowed. +5. **Credential issuance:** Issue exact-action credentials with explicit lifetimes only when the selected mode allows the request's invocation context and policy allows the requested action. 6. **Transport authentication:** Reject disabled or unauthenticated requests before reading or mutating app state. -7. **Safety and user-auth policy:** Enforce permission categories, target scopes, execution-context requirements, and authenticated-user requirements locally in the app bridge. +7. **Safety and user-auth policy:** Enforce the exact granted action, target scopes, execution-context requirements, authenticated-user requirements, and direct action policy locally in the app bridge. 8. **Deterministic dispatch:** Resolve targets exactly and invoke only allowlisted typed handlers. ```mermaid sequenceDiagram @@ -216,7 +211,7 @@ sequenceDiagram Broker-->>CLI: Scoped credential with grants, context, user scope, expiry CLI->>HTTP: Authenticated typed request HTTP->>Bridge: Verify credential and protocol envelope - Bridge->>Bridge: Check permission category + context + authenticated-user + target scope + Bridge->>Bridge: Check exact action + context + authenticated-user + target scope alt Denied Bridge-->>CLI: structured safety-policy error else Allowed @@ -226,6 +221,65 @@ sequenceDiagram end end ``` +## Current foundation discovery and request flow +The current Unix foundation deliberately uses three different mechanisms for three different jobs: +1. **Private filesystem discovery finds candidate instances.** `crates/local_control/src/discovery.rs` defines the shared registry format and validation rules. Each enabled Warp process publishes an owner-only JSON record that tells clients which instance exists, which actions it implements, its exact loopback HTTP endpoint, and the filename of its instance-specific credential-broker socket. The record contains routing metadata, not a bearer token or other control authority. +2. **The Unix-domain socket authenticates the OS user and issues authority.** The broker socket is the protected bootstrap path from discovery metadata to a short-lived exact-action credential. The Warp app obtains the connecting process's UID from kernel peer credentials before decoding its request, then evaluates current policy and, if allowed, returns an in-memory credential for the one requested action. +3. **The loopback HTTP endpoint carries the typed action.** The client presents the broker-issued credential to the selected instance's `/v1/control` endpoint. The app validates the endpoint headers and credential, then hands the typed request to the app bridge for current-policy, exact-action, target, and handler validation. + +The filesystem record and Unix socket are therefore complementary, not alternative discovery mechanisms. The JSON record is how a client learns that an instance and broker exist. The socket path in that record is how the client asks that selected instance for temporary authority. HTTP is how the client uses that authority. A client does not discover instances by enumerating or querying the Unix socket, and it cannot control an instance merely by reading its JSON record. + +### Server publication lifecycle +`app/src/local_control/mod.rs` owns the running app side of all three mechanisms. When the feature, platform support, and protected Settings > Scripting mode allow outside-Warp control, the app: +1. binds an ephemeral TCP port on exactly `127.0.0.1`; +2. creates an `InstanceRecord` containing that endpoint and an instance-derived broker socket filename; +3. publishes the record through `RegisteredInstance::register`; +4. binds the referenced Unix-domain socket inside the same owner-only discovery directory; +5. starts the credential broker on the Unix socket and the typed control handler on `/v1/control`. + +When outside-Warp control is disabled or the server stops, the app drops the registration and runtime. Graceful drop removes the JSON record and broker socket. Discovery scans provide the crash-recovery path by rejecting and pruning records whose PID is no longer alive. + +The default discovery directory is `~/.warp/local-control/`. `WARP_LOCAL_CONTROL_DISCOVERY_DIR` overrides it, and `$XDG_RUNTIME_DIR/warp/local-control` is preferred when `XDG_RUNTIME_DIR` is present. On Unix, the directory is restricted to `0700`, while discovery records and broker sockets are restricted to `0600`. An enabled instance publishes files shaped like `inst_<id>.json` and `inst_<id>.broker.sock`. These permissions meaningfully protect the registry and broker from other OS users, but they do not distinguish among processes running as the owning user. + +### Client discovery and invocation +A client invocation follows this sequence: +1. Read JSON records from the per-user discovery directory. +2. Parse compatible records and reject records with a mismatched protocol version, malformed authority, or dead PID. +3. Require the HTTP host to be exactly `127.0.0.1` and the broker reference to be the filename derived from the selected `instance_id`. This prevents a record from redirecting the client to an arbitrary network host or arbitrary socket path. +4. Probe a candidate by connecting to its broker, requesting an exact-action `app.ping` credential, making an authenticated HTTP ping, and verifying the returned `instance_id`. This removes records that name an unresponsive or inconsistent instance. +5. Select one compatible instance. If selection is ambiguous, require the user to identify the intended instance rather than silently targeting one. +6. Connect to the selected instance's Unix broker socket and request a credential for the exact action about to be invoked. +7. Present that credential only to the exact loopback endpoint from the validated record and send the typed action request. + +The probe is intentionally authenticated. Merely binding the stale record's old TCP port is insufficient to impersonate a live Warp instance because a port squatter cannot issue a credential through the instance-derived broker socket or satisfy the selected instance's in-memory credential lookup. + +### What the Unix broker contributes +The key security property of the Unix-domain socket is kernel-authenticated peer identity. Before the broker reads or decodes a credential request, it calls the platform peer-credential API and verifies that the connecting process's UID equals Warp's effective UID. The caller cannot forge this kernel-reported UID through request data, environment variables, a claimed PID, or a username string. + +The broker also provides a protected just-in-time credential bootstrap path: +- bearer credentials are never written into discovery records; +- there is no reusable bootstrap token for a client to read from disk and send to a stale or squatted TCP endpoint; +- the running Warp app can evaluate the protected Scripting mode and direct action policy at issuance time; +- every issued credential is bound to the selected instance, one exact action, an invocation context, and a short expiry; +- issued credentials exist only in the running app's process-local credential map and the requesting client's memory. + +The instance-derived socket filename and owner-only discovery directory bind the broker reference to the selected record and make arbitrary socket-path injection fail validation. Socket permissions provide an additional owner-only filesystem check, while peer credentials provide the authoritative same-UID check after connection. + +### What the HTTP and app bridge contribute +The HTTP listener is a transport for the final typed action, not the discovery or credential-issuance mechanism. Knowing or guessing its loopback port is insufficient. Before dispatch, the app: +- rejects requests carrying a browser-style `Origin` header; +- requires the `Host` header to exactly match the selected `127.0.0.1:<port>` endpoint; +- requires a bearer credential present in the selected instance's process-local credential map; +- rejects missing, malformed, expired, revoked, or wrong-instance credentials; +- decodes the typed request only after transport authentication; +- has the app bridge re-check current Scripting mode, invocation context, exact granted action, authenticated-user requirements, target restrictions, and allowlisted handler dispatch. + +These checks defend against browser-origin clients, network clients, unauthenticated clients that discover or guess the TCP port, stale or malformed records, wrong-instance credentials, and accidental action overreach. Loopback binding and header checks harden the endpoint, but the broker-issued credential and app-side checks remain the authority. + +### Current boundary and limitation +The current broker authenticates the **OS account**, not the identity of the application running under that account. It does not prove that the caller is the official `warpctrl` binary, Warp-signed code, a process launched from a Warp terminal, or a human-approved invocation. Once the user enables outside-Warp control, any process running as that OS user can connect to the broker and request credentials for actions that current policy allows from the external invocation context. + +The current architecture therefore provides a meaningful hard boundary against other OS users, browsers, network peers, and unauthenticated direct HTTP clients. For same-user software, protected enablement, short expiry, exact-action grants, app-side revalidation, deterministic targeting, and future approval policy are least-privilege and intent guardrails rather than strong isolation. A stronger same-user boundary would require an additional application-identity or user-intent mechanism, such as platform code-signature validation, verified Warp-terminal session proof, or per-action user approval. ## Discovery registry Each participating Warp process writes a discovery record in a secure per-user local-control directory. Discovery records are metadata, not a full control-authority model. A discovery record should contain: @@ -251,40 +305,27 @@ Current foundation implementation note: `warpctrl` discovers a loopback control A control credential should encode or reference: - issuing Warp instance; - protocol version or accepted version range; -- granted permission categories; +- the one granted `ActionKind`; - verified execution context, such as external client or Warp-managed terminal session; - whether the credential may act on behalf of an authenticated Warp user; - authenticated Warp user subject or stable user reference when an authenticated-user grant is present; -- optional allowed action families; - optional target restrictions, such as one session, one workspace, one file path, or one Warp Drive object type; - issued-at time; - expiry time or process-lifetime binding; - unique credential ID for revocation and auditing; - integrity protection so callers cannot forge or widen grants. ### Credential issuance -Warp should issue credentials through an app-owned local broker or equivalent trusted path. The broker decides which grants to issue based on the requested permission category, target scope, user configuration, execution context, and any explicit user approval. +Warp should issue credentials through an app-owned local broker or equivalent trusted path. The broker decides whether to issue a credential based on the requested exact action, target scope, user configuration, execution context, authenticated-user requirements, and any explicit user approval. Recommended defaults: -- Credential issuance is unavailable unless the protected enablement state allows the request's invocation context: inside Warp or outside Warp. -- Commands should start from least privilege and request only the grant needed for the requested action. -- External same-user invocations are limited to the smaller logged-out-safe local action set. Policy or explicit approval may grant narrower or broader permission categories only within that set; neither can grant authenticated-user authority to an external invocation. -- Verified Warp-terminal invocations may receive broader local-control grants when the selected mode and action policy allow them. +- Credential issuance is unavailable unless the protected enablement state allows the request's invocation context. +- Commands request only the exact action they are about to invoke. +- External same-user invocations are limited to the smaller logged-out-safe local action set and cannot receive authenticated-user authority. +- Verified Warp-terminal invocations may request broader sets of actions over time, but each credential remains scoped to one action. - App-user authenticated grants are available only when the selected Warp app has a true logged-in Warp user and a verified Warp-terminal execution context allowed by local-control settings. -- Metadata reads require an explicit `read_metadata` grant. -- Underlying data reads require an explicit `read_underlying_data` grant. -- App-state mutations require an explicit `mutate_app_state` grant. -- Metadata/configuration mutations require an explicit `mutate_metadata` or `mutate_configuration` grant. -- Underlying data mutations require an explicit `mutate_underlying_data` grant and should require approval or policy for unattended automation. -- User-authenticated data reads or mutations require an explicit `authenticated_user` grant and an allowed authenticated action family in addition to the data-category grant. -- Integrations should be granted only the narrowest authority needed for the configured workflow. -Callers should not manage low-level permission scopes directly. They request a typed action or higher-level capability, and the app-owned broker maps that request to the required permission category, target scope, configured policy, execution context, and any user approval or consent prompt. If a request exceeds the caller's current grant and is not explicitly denied by policy, the app can prompt for the narrower additional grant; if it is denied, the bridge returns a structured error. The broker must not issue broad authority merely because the request came from the signed `warpctrl` binary. The CLI must not mint its own authority. It can request and present broker-issued credentials, but the app bridge remains the enforcement point for these safety grants. -### Safety grants, not strong access control -The category system should be understood as a user-intent and accident-prevention mechanism: -- A user can ask an agent or script to operate with metadata-read grants so it can inspect structure but cannot read terminal content or mutate state. -- A workflow can request underlying-data reads separately from structural metadata reads because terminal output, files, Drive object content, and AI conversations can contain sensitive data. -- A script can request app-state mutation without also receiving permission to change persistent settings, execute commands, mutate Warp Drive objects, or perform local file content operations. -- Metadata/configuration mutations can be allowed without granting underlying data mutation. -- Underlying data mutations can require explicit approval or configured policy so surprising operations pause before they execute commands or change user data. -This model does not make untrusted same-user software safe. A malicious local process may invoke `warpctrl`, simulate user workflows, or use other OS-level capabilities outside `warpctrl`. The category model is still valuable because it lets honest clients, agents, and scripts constrain themselves and gives Warp a structured point to prompt, deny, or audit risky actions. +- Actions that expose sensitive content, mutate durable data, execute code, or cause external effects require the direct identity, approval, target, and audit conditions specified for that action. +Callers do not manage low-level scope strings. They request a typed action, and the app-owned broker evaluates that action's configured policy, execution context, target restrictions, authenticated-user requirements, and any approval or consent prompt. If the action is denied, the broker or bridge returns a structured error. The broker must not issue broader authority merely because the request came from the signed `warpctrl` binary. The CLI must not mint its own authority, and the app bridge remains the enforcement point because direct protocol clients can bypass the CLI. +### Exact-action grants, not strong access control +Exact-action credentials prevent an honest client from accidentally reusing authority for a different operation and give Warp a structured point to prompt, deny, or audit sensitive actions. They do not make untrusted same-user software safe. A malicious local process may invoke `warpctrl`, simulate user workflows, or use other OS-level capabilities outside `warpctrl`. ### Credential storage The current Unix foundation stores no bootstrap or long-lived local-control secret; it mints short-lived scoped credentials in memory. Future credential and proof storage should be platform-appropriate: - Local discovery may store a credential reference rather than the credential itself. @@ -302,7 +343,7 @@ Mitigations: - Prefer action-scoped or session-scoped credentials minted just in time by the broker. - Require explicit user approval or preconfigured policy for underlying data mutations and other sensitive grants. - Distinguish user-approved credential requests from ambient unattended invocations through explicit approval prompts, configured policy, terminal/session context, or narrow credential request flows. -- Bind issued credentials to the requested instance, permission category, optional action family, optional target scope, and short expiry. +- Bind issued credentials to the requested instance, exact action, optional target scope, and short expiry. - Prune expired grants and cap the process-local active-grant set. The low-risk foundation slice may reuse an unexpired scoped grant, but a replay policy is required before broader or higher-risk action families ship. - Let `warpctrl` preflight and request credentials, but require the local app bridge to enforce scopes because direct protocol clients can bypass the CLI. - Make denials structured and non-fatal for automation so callers can request narrower or user-approved grants rather than falling back to unsafe behavior. @@ -323,72 +364,40 @@ Transport requirements: - Preserve structured error envelopes so the CLI does not collapse security failures into generic transport errors. Remote URL support is a separate future transport mode. It should not reuse the local same-user credential model without additional identity, encryption, replay protection, and remote approval/policy design. ## Logged-in user requirements -Local-control validation always begins with local protocol state: discovery records, secure local credential references, scoped safety grants, execution-context proof, protocol version, request shape, allowlisted actions, typed parameters, and deterministic target selectors. +Local-control validation always begins with local protocol state: discovery records, secure local credential references, exact-action grants, execution-context proof, protocol version, request shape, allowlisted actions, typed parameters, and deterministic target selectors. Some actions additionally require authenticated scripting authority from a true logged-in Warp user in the selected app and a verified Warp-terminal invocation. The action allowlist must declare this explicitly with a `requires_authenticated_user` or equivalent authenticated-scripting requirement field. Default rule for new actions: - New actions require an authenticated Warp user unless the implementer deliberately classifies them as logged-out-safe. - The logged-out-safe set should remain meaningfully smaller and limited to local app structure, local appearance metadata, and other surfaces that do not depend on the user's cloud-backed Warp identity. - Actions that read or mutate Warp Drive, AI conversation traces, synced settings, team/account data, or other user-authenticated state must require an authenticated user. -- Actions that execute user-authored cloud-backed content, such as running typed Warp Drive workflows, require both authenticated scripting authority and the appropriate high-risk action category. Agent-prompt submission remains excluded until separately reviewed. +- Actions that execute user-authored cloud-backed content, such as running typed Warp Drive workflows, require authenticated scripting authority plus the direct approval, targeting, and audit requirements for that exact action. Agent-prompt submission remains excluded until separately reviewed. When an authenticated-user or authenticated-scripting action is requested: - app-user mode requires the selected app to have an active logged-in Warp user; - the presented local-control credential must include an authenticated grant for that user; - the selected mode, action policy, and authenticated-scripting policy must allow authenticated actions for the verified Warp-terminal execution context; - the app bridge should execute app-user actions through the app's existing authenticated state rather than exporting raw cloud auth credentials to `warpctrl`. If these conditions are not met, the app returns a structured error. It must not fall back to logged-out behavior or silently omit user-authenticated data from a result that claims success. -## Safety policy model -Safety grants are enforced in the app bridge after transport authentication and before target resolution or handler dispatch. This provides consistent “do not accidentally do more than requested” behavior for honest clients, not a sandbox for hostile same-user code. +## Exact-action policy model +Exact-action grants are enforced in the app bridge after transport authentication and before target resolution or handler dispatch. This provides consistent “do not accidentally do more than requested” behavior for honest clients, not a sandbox for hostile same-user code. The bridge path must: 1. Authenticate the transport credential before decoding the typed request envelope. 2. Parse the typed request envelope. 3. Verify protocol version compatibility. -4. Determine granted permission categories, execution context, target scopes, and authenticated-user grants. -5. Map the requested action to a required permission category, action family, execution-context requirement, and authenticated-user requirement. -6. Check optional target-family restrictions. -7. Reject requests that exceed the credential's grants with `insufficient_permissions`. +4. Determine the exact granted action, execution context, target scopes, and authenticated-user grant. +5. Compare the requested action to the granted action and load that action's direct policy requirements. +6. Check optional target-family restrictions, authenticated-user requirements, and action-specific approval or audit prerequisites. +7. Reject a request for any different action with `insufficient_permissions`. 8. Reject authenticated-user actions without the required app-user login or authenticated grant with a structured authenticated-user error. 9. Only then resolve selectors and invoke the allowlisted handler. The CLI frontend may provide helpful preflight errors, but those checks are advisory. Local app-side bridge enforcement is mandatory because other tools can bypass the official CLI and speak the protocol directly. -## Action permission categories -Every action belongs to exactly one state/data category for permission enforcement. These categories describe risk and intended safety prompts; they are not a sandbox or a complete OS-level access-control model. -### Metadata reads -Return app structure, app state, or configuration metadata without exposing terminal content, file content, Warp Drive object content, AI conversation content, or other user data. -Examples: -- `instance list`, `app active`, `app version`, `app ping`; -- `window list`, `tab list`, `pane list`, `session list`; -- `theme list`, `setting list`, `keybinding list`, and action/capability metadata; -- Drive object listing that returns object IDs, names, and types but not content. -Default unattended credentials may include this category. -### Underlying data reads -Return user content or data-bearing state without mutating state. -Examples: -- pane output, scrollback, current input buffer, command history, session replay, or transcript reads; -- Warp Drive object content reads; -- AI conversation content reads. -This category is separate from metadata because content often contains secrets, source code, file paths, command output, customer data, and other sensitive information. -### App-state mutations -Change visible local Warp UI state without directly changing underlying user data. -Examples: -- creating, focusing, activating, moving, or closing windows, tabs, panes, or sessions; -- splitting, navigating, maximizing, or resizing panes; -- opening panels, palettes, files, notebooks, and other user-facing surfaces; -- inserting, replacing, or clearing staged input buffer text without submitting or executing it. -### Metadata/configuration mutations -Change persistent metadata or configuration without directly mutating primary user content. -Examples: -- renaming tabs or panes; -- changing tab colors; -- theme, font, zoom, keybinding, and allowlisted settings writes. -This category should not authorize terminal command execution, Warp Drive CRUD, Warp Drive sharing, or local file content operations. -### Underlying data mutations -Can change user data, execute code, submit prompts, or cause external side effects. -Examples: -- terminal command execution through the explicit `input.run` action; -- typed Warp Drive workflow execution or other approved user-authored runnable content; -- Warp Drive object create/update/delete/insert operations; -- Warp Drive object sharing, limited in v0 to making a personal object available to the user's current team through an explicit `share-to-team` command; -- AI conversation history mutation or other cloud-backed content mutation. -This category requires authenticated scripting identity plus explicit user or policy approval for unattended automation and integrations. It must remain separate from app-state mutation so a client that can open or focus Warp UI cannot automatically execute commands, submit prompts, mutate Warp Drive content, share Drive objects, or perform local file content operations. Accepted-command submission and agent-prompt submission remain unavailable until separately reviewed even if future protocol names are reserved for them. +## Direct action requirements +Action authorization is defined per typed action rather than by assigning actions to permission buckets. Similar actions remain independently authorized. +- Structural metadata actions such as `instance.list`, `window.list`, or `theme.list` each require their own grant and must not expose terminal content or other user data. +- Content-bearing reads such as block output, input buffers, command history, Warp Drive content, or AI conversations each require their own grant and any direct authenticated-user or approval policy specified for that action. +- UI actions such as creating tabs, focusing panes, opening files, or staging input each require their own grant and must not imply authority to execute commands, change persistent settings, or mutate user data. +- Persistent settings and metadata changes each require their own grant and allowlist validation. +- Actions that execute code, mutate or share Warp Drive objects, mutate AI content, or cause external effects each require their own grant plus authenticated scripting identity, explicit approval or configured policy, deterministic targeting, and audit coverage where specified. +Accepted-command submission and agent-prompt submission remain unavailable until separately reviewed, regardless of what other actions a credential grants. ## Target scoping and deterministic resolution Targeting is part of security. The protocol must not convert ambiguous or stale selectors into best-effort mutations. Rules: @@ -407,14 +416,14 @@ Each supported command requires: - a typed protocol action; - typed parameters; - validation rules; -- a documented state/data category and permission category; +- documented authenticated-user, invocation-context, target, approval, and audit requirements; - a documented `requires_authenticated_user` value; - a documented allowed execution context, including whether external clients can run it or whether it is limited to verified Warp-terminal invocations; -- local app-side safety-grant checks; +- local app-side exact-action grant checks; - deterministic target resolution; - a handler that reuses existing user-visible app behavior where possible; - typed success and error responses. -Adding a new action should be additive and reviewable: extend the protocol enum, implement validation, map the action to a state/data category, declare whether it requires an authenticated user, declare its allowed execution contexts, add a handler, and add tests for authentication, safety-policy denial, authenticated-user denial, selector failure, and success behavior. +Adding a new action should be additive and reviewable: extend the protocol enum, implement validation, declare whether it requires an authenticated user, declare its allowed execution contexts and direct policy requirements, add a handler, and add tests for authentication, exact-action denial, authenticated-user denial, selector failure, and success behavior. ## Browser and localhost protections Loopback is not sufficient by itself because browsers can send requests to localhost. This section is not a browser-only defense and must not rely on CORS as the primary control. Non-browser local clients can also send HTTP requests, so the local app must enforce credentials, invocation-context gating, app-side authorization, and endpoint hardening for every request. @@ -434,7 +443,7 @@ Recommended audit fields: - timestamp; - instance ID; - credential ID or grant profile; -- action name, state/data category, and permission category; +- action name and applicable direct policy requirements; - target type and opaque target ID when safe; - success or structured error code. Avoid logging: @@ -451,7 +460,7 @@ Structured errors are part of the security contract. Important errors include: - `local_control_disabled` when the relevant inside-Warp or outside-Warp scripting context is disabled in Settings > Scripting or has been disabled after credentials were issued; - `unauthorized_local_client` for missing, malformed, expired, revoked, or invalid credentials; -- `insufficient_permissions` for valid credentials that lack the requested permission category or target scope; +- `insufficient_permissions` for valid credentials that grant a different action or do not include the requested target scope; - `authenticated_user_required` when an action requires authenticated scripting authority but the credential lacks an authenticated-user grant; - `authenticated_user_unavailable` when the selected Warp app has no logged-in Warp user or cannot access the required authenticated user state; - `authenticated_user_mismatch` when an authenticated-user credential is bound to a different user subject than the user currently logged in to the selected Warp app; @@ -469,17 +478,17 @@ The app must not downgrade these failures into broader default actions, and the Before shipping each action family, verify that these controls are implemented for that family: - Local control scripting must be enabled for the request's invocation context before the action family can run; disabled mode blocks all contexts, the within-Warp mode allows inside-Warp only once proof verification exists, and outside-Warp control requires the broadest mode. - The authoritative mode lives under Settings > Scripting, is protected from external writes, and is local-only rather than synced. -- The action has a documented state/data category and required permission category. +- The action has documented authenticated-user, invocation-context, target, approval, and audit requirements. - The action has a documented `requires_authenticated_user` value. New actions default to `true` unless explicitly reviewed as logged-out-safe. - The action documents allowed execution contexts and whether external clients may run it. -- The bridge maps the action to that permission category locally in the selected Warp app process. -- The credential model can express the required grant. +- The bridge verifies the credential grants that exact action locally in the selected Warp app process. +- The credential model grants the exact requested action. - The credential model can express authenticated-user grants and verified execution context requirements when needed. - The handler checks optional target restrictions where relevant. -- Requests with invalid credentials or insufficient safety grants fail before selector resolution or mutation. +- Requests with invalid credentials or credentials for a different action fail before selector resolution or mutation. - Requests that require authenticated-user access fail unless the selected app has a true logged-in Warp user and the credential includes an authenticated-user grant. - Ambiguous, missing, and stale targets return structured errors. -- Tests cover allowed, insufficient-permission, and denied credential paths. +- Tests cover the allowed path, use of a different action credential, and denied credential paths. - Logs and errors do not expose credentials, terminal contents, command text, or sensitive settings. - Operator docs distinguish available commands from planned catalog entries. - Initial public action-family docs and tests prove terminal command execution, workflow execution, accepted-command submission, and agent-prompt submission are not allowlisted; input-buffer staging never submits the buffer. diff --git a/specs/warp-control-cli/TECH.md b/specs/warp-control-cli/TECH.md index f0b855c320..38f8680cf2 100644 --- a/specs/warp-control-cli/TECH.md +++ b/specs/warp-control-cli/TECH.md @@ -1,6 +1,6 @@ # Context `PRODUCT.md` defines a local Warp control CLI command, provisionally named `warpctrl`, with an allowlisted action catalog, deterministic addressing across multiple running Warp app processes, and an incremental implementation plan. The public command should be exposed through an Oz-style wrapper script that invokes the existing channel-specific Warp binary in control mode, not through a separate standalone control binary. -`SECURITY.md` is the normative security architecture for this feature. Implementation work must follow it for the top-level Settings > Scripting surface, protected mode storage, discovery metadata, credential storage, scoped safety grants, verified execution context, authenticated-user requirements, localhost/browser protections, permission-category enforcement, deterministic target resolution, and local app-side validation. The long-term architecture includes separate verified inside-Warp and outside-Warp invocation contexts, but the current foundation implementation supports outside-Warp requests only in the broadest mode and must reject `InvocationContext::InsideWarp` until the verified Warp-terminal proof broker lands. If this technical plan and `SECURITY.md` disagree, update the plan before implementing rather than treating the security architecture as optional follow-up work. +`SECURITY.md` is the normative security architecture for this feature. Implementation work must follow it for the top-level Settings > Scripting surface, protected mode storage, discovery metadata, credential storage, exact-action grants, verified execution context, authenticated-user requirements, localhost/browser protections, deterministic target resolution, and local app-side validation. The long-term architecture includes separate verified inside-Warp and outside-Warp invocation contexts, but the current foundation implementation supports outside-Warp requests only in the broadest mode and must reject `InvocationContext::InsideWarp` until the verified Warp-terminal proof broker lands. If this technical plan and `SECURITY.md` disagree, update the plan before implementing rather than treating the security architecture as optional follow-up work. The existing app already has three relevant building blocks: - `crates/http_server/src/lib.rs (7-61)` runs a native-only loopback Axum server on fixed port `9277`. - `app/src/lib.rs (1993-2001)` registers that HTTP server in the native app and currently merges only installation-detection and profiling routers. @@ -39,18 +39,18 @@ Required security gates: - External invocations are limited to a smaller logged-out-safe action set that does not touch user-authenticated data and cannot receive authenticated-user authority. - Verified Warp-terminal invocations may receive authenticated-user grants only when the selected app has a true logged-in Warp user and local-control mode plus action policy allow authenticated-user actions from Warp terminals. - The app rejects disabled, unauthenticated, expired, revoked, insufficient-scope, unsupported, malformed, ambiguous, missing-target, and stale-target requests with structured errors. -- Every action has a documented state/data category and the app bridge enforces the required permission category locally before selector resolution or handler dispatch. +- Every credential grants one exact action, and the app bridge verifies that action locally before selector resolution or handler dispatch. - Every action has a documented `requires_authenticated_user` value and allowed execution contexts. New actions default to requiring an authenticated user unless explicitly reviewed as logged-out-safe. -- The Settings > Scripting mode gates invocation contexts; action metadata, credential grants, Agent/Profile policy, and authenticated-scripting identity gate metadata reads, underlying data reads, app-state mutations, metadata/configuration mutations, underlying data mutations, and authenticated-user actions. -- Permission categories are treated as user-intent and accident-prevention guardrails, not as strong same-user malicious-app isolation. +- The Settings > Scripting mode gates invocation contexts; exact-action credentials, Agent/Profile policy, authenticated-scripting identity, target restrictions, and action-specific approval or audit requirements gate each request. +- Exact-action grants and approval policy are treated as user-intent and accident-prevention guardrails, not as strong same-user malicious-app isolation. - Remote control remains out of scope for the local same-machine credential model. -The first implementation slice should include the protected enablement gate, credential issuance checks, and app-side permission-category enforcement even if the only mutating action initially implemented is `tab.create`. Shipping `tab.create` without the enablement and validation architecture would create the wrong foundation for the full catalog. +The first implementation slice should include the protected enablement gate, exact-action credential issuance, and app-side exact-action enforcement even if the only mutating action initially implemented is `tab.create`. Shipping `tab.create` without the enablement and validation architecture would create the wrong foundation for the full catalog. ### 1. Protocol crate and stable envelope Create a small shared protocol crate or equivalent shared module used by both the app server and the `warpctrl` command-mode client. It should define: - A request protocol version used as a defensive schema guard for stale copied JSON, stale wrappers, and future external clients, not as a normal compatibility-negotiation mechanism between separately versioned CLI and GUI binaries. - Discovery/health response types. - Execution-context proof/request types for verified Warp-terminal invocations versus external invocations. -- Action metadata describing state/data category, required permission grant, `requires_authenticated_user`, allowed execution contexts, and target families. +- Action metadata describing implementation status, `requires_authenticated_user`, allowed execution contexts, target families, and typed parameter/result contracts. - Selector types: - `InstanceSelector` - `WindowSelector` @@ -154,11 +154,11 @@ Recommended local trust model: - The broker verifies whether the invocation originated from a Warp-managed terminal session before issuing in-Warp-only grants. - The broker issues authenticated-user grants only when the selected app has a true logged-in Warp user and the selected mode plus action policy allow the grant. - The app rejects disabled-state, missing, malformed, invalid, expired, or revoked credentials before selector resolution or mutation. -- The app maps every action to a state/data category and rejects insufficient grants before selector resolution or mutation. +- The app rejects credentials issued for any action other than the requested action before selector resolution or mutation. - The app maps every action to a `requires_authenticated_user` value and allowed execution contexts, rejecting mismatches before selector resolution or mutation. - Health metadata exposed without credentials, if needed for stale-record pruning, must not reveal mutating capabilities, credentials, or sensitive target state. This keeps the protocol local and scriptable without creating an ambient browser-to-localhost control surface. -Do not ship the first slice as a plaintext discovery bearer token, even for same-user human CLI use. The first slice is the foundation for underlying data reads, app-state mutations, metadata/configuration mutations, and underlying data mutations, so it must establish the protected enablement, credential storage, scoped grant, and app-side enforcement model from `SECURITY.md`. +Do not ship the first slice as a plaintext discovery bearer token, even for same-user human CLI use. The first slice is the foundation for sensitive reads, UI mutations, persistent configuration changes, and actions that mutate user data or execute code, so it must establish the protected enablement, credential storage, exact-action grant, and app-side enforcement model from `SECURITY.md`. ### 4. Future verified Warp-terminal invocation context The current foundation branch does not implement verified inside-Warp invocation. `InvocationContext::InsideWarp` and `ExecutionContextProof::VerifiedWarpTerminal` may remain in the shared protocol as reserved future concepts, but the credential broker must reject them until the proof broker described here exists. Minimum implementable design: @@ -167,8 +167,8 @@ Minimum implementable design: - The shell receives only proof material needed by `warpctrl`, such as an opaque handle plus a short-lived token or challenge-response input. Plain environment variables may carry handles or hints, but a caller-set variable must not be sufficient authority. - `warpctrl` invoked from that terminal sends `InvocationContext::InsideWarp` and `ExecutionContextProof::VerifiedWarpTerminal` to the owner-authenticated credential broker when it has proof material. Without proof material it must use `OutsideWarp`. - The broker verifies the proof against the app-owned registry, including app instance, session liveness, expiry, revocation, and nonce or challenge binding before minting any inside-Warp scoped credential. -- The broker then checks Settings > Scripting mode and permission-category policy for the requested action. A valid proof raises the maximum eligible grant set; it does not bypass user settings, action metadata, authenticated-user requirements, target scopes, or bridge enforcement. -- The minted credential records `invocation_context: InsideWarp`, the granted permission category, expiry, instance, and any authenticated-user subject. The app bridge revalidates the grant and current app policy on every control request. +- The broker then checks Settings > Scripting mode and action-specific policy for the requested action. A valid proof raises the maximum eligible authority; it does not bypass user settings, exact-action enforcement, authenticated-user requirements, target scopes, or bridge enforcement. +- The minted credential records `invocation_context: InsideWarp`, the granted action, expiry, instance, and any authenticated-user subject. The app bridge revalidates the grant and current app policy on every control request. Hardened follow-ups can strengthen this minimum design by storing secret material in platform secure storage, exposing only opaque handles through the shell, adding a Windows named-pipe equivalent to the current Unix-domain-socket peer-credential check, binding proofs to broker challenges, and invalidating proofs on shell/session teardown, app logout, user switch, or Settings > Scripting changes. These hardening layers improve direct-token theft resistance, but they do not create a perfect security boundary against malicious same-user software with process, filesystem, Accessibility, or screen-observation access. ### 5. Authenticated scripting identity The full control catalog includes Warp Drive data mutation and execution-underlying actions. Those actions require an authenticated scripting layer in addition to local-control credentials. Local-control credentials prove authority to call the local app; authenticated scripting credentials prove the logged-in Warp user allowed to request user-backed or high-risk actions. @@ -212,9 +212,9 @@ HTTP handler (Tokio thread) LocalControlBridge::handle_request (main thread) │ ├─ verify protected local-control mode still allows the context - ├─ map action to required permission category + ├─ verify the presented credential grants the exact requested action ├─ map action to authenticated-user and execution-context requirements - ├─ verify presented credential grants that category, target family, execution context, and authenticated-user access + ├─ verify target restrictions, execution context, and authenticated-user access ├─ match request.action.kind │ └─ ActionKind::TabCreate │ ├─ validate_tab_create_target(&request.target) @@ -239,9 +239,9 @@ LocalControlBridge::handle_request (main thread) #### Adding new action handlers To add a new action to the bridge: 1. Add an entry to the macro-backed `ActionKind` catalog in `crates/local_control/src/catalog.rs`. -2. Document its `SECURITY.md` state/data category, required permission grant, `requires_authenticated_user` value, and allowed execution contexts. +2. Document its `SECURITY.md` authenticated-user requirement, allowed execution contexts, target restrictions, and any action-specific approval or audit policy. 3. Add a match arm in `LocalControlBridge::handle_request` in `app/src/local_control/mod.rs`. -4. Before selector resolution or dispatch, verify local control is enabled and the presented credential grants the action category, target family, execution context, and authenticated-user access if required. +4. Before selector resolution or dispatch, verify local control is enabled and the presented credential grants the exact action, target family, execution context, and authenticated-user access if required. 5. Inside the match arm, use `ctx` (which is a `&mut ModelContext<LocalControlBridge>` that derefs to `&mut AppContext`) to resolve selectors and dispatch the action onto existing app types. 6. Return a `ResponseEnvelope::ok(...)` or `ResponseEnvelope::error(...)` with the result. The bridge closure has access to the full `AppContext` API surface, including `ctx.windows()`, `ctx.window_ids()`, `ctx.views_of_type::<T>(window_id)`, `handle.update(ctx, ...)`, and `handle.read(ctx, ...)`. This makes it straightforward to wire new actions to existing UI behavior without introducing new concurrency concerns. @@ -260,7 +260,7 @@ Selector behavior: - Index selectors are allowed only for user-visible indexed concepts and should resolve to a concrete opaque ID before execution. - Title, name, and path selectors are convenience selectors. They must be exact by default, document any future fuzzy behavior explicitly, and return `ambiguous_target` when more than one target matches. - A session-scoped request against a non-terminal pane returns `target_state_conflict`. -Target resolution must happen after protected enablement, authentication, and safety-grant checks. This prevents denied requests from learning more target state than necessary and keeps enforcement centralized. +Target resolution must happen after protected enablement, authentication, and exact-action grant checks. This prevents denied requests from learning more target state than necessary and keeps enforcement centralized. Implementation references: - Window-level active selection already exists inside the app through `WindowManager`. - Pane scoping can build on the conceptual model of `PaneViewLocator` in `app/src/workspace/util.rs (12-18)`. @@ -296,7 +296,7 @@ Recommended modules/families: - object listing/inspection/opening, object creation/update/delete/insert, opening the share dialog, the v0 personal-to-team share mutation, and typed workflow execution where supported. Do not use a generic “dispatch action by string” endpoint. Every handler should be reachable only through an explicit `ControlAction` variant. #### Future WarpCtrlBehavior review gate -The public `ControlAction` catalog remains the source of truth for the wire protocol, CLI parser, permission metadata, generated documentation, and app bridge handlers. Internal app actions such as `WorkspaceAction`, `TerminalAction`, `PaneGroupAction`, settings actions, and future user-visible action enums must not become the public protocol directly because they can contain transient view locators, indices, debug-only variants, implementation-specific payloads, and unstable internal semantics. +The public `ControlAction` catalog remains the source of truth for the wire protocol, CLI parser, authorization metadata, generated documentation, and app bridge handlers. Internal app actions such as `WorkspaceAction`, `TerminalAction`, `PaneGroupAction`, settings actions, and future user-visible action enums must not become the public protocol directly because they can contain transient view locators, indices, debug-only variants, implementation-specific payloads, and unstable internal semantics. The exhaustive `WarpCtrlBehavior` review mapping is not a current foundation-branch requirement. It should land in a later action-review or `zach/warp-cli-v2/cli-catalog-docs` branch after the public catalog and generated docs surface are mature enough to enforce it consistently. Once added, the mapping is a code-level forcing function, not an automatic exposure mechanism. It answers whether each internal app action is: - `Exposed` through a specific public `ControlAction` kind. - `CoveredBy` an existing public `ControlAction` kind because several internal actions map to one stable CLI behavior. @@ -308,7 +308,7 @@ Recommended shape: - Define review enums such as `WarpCtrlActionReview`, `WarpCtrlExclusionReason`, and `WarpCtrlDeferredReason`. - Implement `WarpCtrlBehavior` for the major user-visible action enums, starting with `WorkspaceAction` and `TerminalAction`. - Keep the mapping one-way from internal behavior to public catalog metadata. `WarpCtrlBehavior::Exposed(ControlActionKind::TabCreate)` means the action is represented by the public `tab.create` command; it does not mean raw `WorkspaceAction::AddTerminalTab` is serializable or dispatchable over the protocol. -- Add tests that collect reviewed action kinds and verify every `ControlActionKind` has protocol metadata, permission metadata, CLI parser coverage, generated-doc coverage, and an app-side handler before it can be advertised as supported. +- Add tests that collect reviewed action kinds and verify every `ControlActionKind` has protocol metadata, authorization metadata, CLI parser coverage, generated-doc coverage, and an app-side handler before it can be advertised as supported. The `warpui::Action` trait should not be extended for this purpose because it currently has a blanket implementation for any `Any + Debug + Send + Sync` type. The enforcement point is the concrete user-visible action enums and binding/action registration surfaces, where exhaustive review can be required without weakening the allowlisted protocol boundary. ### 9. First slice: prove discovery and `tab.create` The first `warpctrl` implementation slice should land the minimum cross-cutting architecture plus a single representative tab mutation: @@ -316,14 +316,14 @@ The first `warpctrl` implementation slice should land the minimum cross-cutting - `FeatureFlag::WarpControlCli` and Cargo feature `warp_control_cli`, with app-side runtime gating for settings, discovery, bridge registration, and local-control endpoints. - New top-level Settings > Scripting page rendered only while `FeatureFlag::WarpControlCli` is enabled. The current foundation exposes one local-control mode with disabled as the default, enabled within Warp as a reserved mode that rejects requests until proof support exists, and enabled everywhere as the only mode that allows outside-Warp credential requests. - Protected local-only mode storage where outside-Warp control defaults off unless the broadest mode is selected. -- The local-control mode lives in the typed `LocalControlSettings` group as a private setting with `SyncToCloud::Never`, an explicit private storage key, and no `toml_path`. This keeps it out of the user-visible settings file and generated settings schema. It is persisted through Warp's secure-storage provider, migrates earlier private-preferences values only after a protected write succeeds, and allows explicitly documented weaker owner-only fallback storage on platforms whose secure provider is unavailable. +- The local-control mode lives in the typed `LocalControlSettings` group as a private setting with `SyncToCloud::Never`, an explicit private storage key, and no `toml_path`. This keeps it out of the user-visible settings file and generated settings schema. It is persisted only through Warp's secure-storage provider, never imports a value from ordinary or private preferences, and fails closed to disabled when no valid protected value is available. - Discovery registry and CLI instance selection. - A `warpctrl` wrapper entrypoint that invokes the existing channel-specific Warp binary with a hidden `--warpctrl` control-mode flag and runs control commands without starting the GUI app runtime. - Per-process authenticated local-control server that refuses sensitive work when outside-Warp control is disabled and rejects inside-Warp credential requests until verified terminal proof support is implemented. - Scoped credential issuance/storage with no raw credentials in plaintext discovery records, including execution-context fields and authenticated-user grant fields. - App-side request bridge and selector resolver. -- Action-category mapping and app-side safety-grant enforcement. -- Action metadata for `tab.create` that deliberately classifies it as a logged-out-safe app-state mutation only when the selected local-control mode allows the invocation context. +- Exact-action credential issuance and app-side exact-action enforcement. +- Action metadata for `tab.create` that deliberately marks it logged-out-safe and outside-Warp-only in the foundation slice. - Read-only `ping/version` plus `warpctrl instance list` or equivalent minimal discovery command. - End-to-end `warpctrl tab create` for the selected instance, reusing the same app behavior as the user-visible new-terminal-tab action. Why `tab.create` first: @@ -376,13 +376,13 @@ The active durable review stack is the recovered `zach/warp-cli-v2/*` stack. Thi Spec ownership is part of the branch architecture. The only v2 branch that may intentionally change `specs/warp-control-cli/PRODUCT.md`, `TECH.md`, `SECURITY.md`, or `README.md` is `zach/warp-cli-v2/contract-spec-sync`. After a spec change lands there, propagate it upward through every higher v2 branch with raw git rebases so those files remain byte-identical across the stack. Higher implementation branches must not make independent spec edits except when resolving a propagation conflict in a way that preserves the bottom-branch content. The intended v2 stack is: 1. `zach/warp-cli-v2/contract-spec-sync` — create from `origin/master`. It owns the product, technical, security, and README specs plus the shared contract/foundation: protocol crate shape, discovery/auth scaffolding, initial instance selector and error types, action metadata/catalog structure, Scripting settings surface, protected local-control settings, local-control server/bridge skeleton, `warpctrl` wrapper/control-mode entrypoint, packaging hooks, module split, and the minimum first-slice smoke path needed to prove the end-to-end architecture. -2. `zach/warp-cli-v2/auth-security` — create from `zach/warp-cli-v2/contract-spec-sync`. It owns authentication and security enforcement that applies across command families: scoped grants, execution-context policy, authenticated-user plumbing, denial paths, Settings > Scripting security controls, and tests proving high-risk actions cannot run without the required identity and permission category. -3. `zach/warp-cli-v2/readonly-capability-targets` — create from `zach/warp-cli-v2/auth-security`. It owns structural metadata reads, capability/action metadata, target selector parsing and resolution, opaque IDs, active window/tab/pane/session targeting, metadata-read permission checks, and read-only CLI output for these surfaces. It must not expose terminal output, input buffers, history, Drive object contents, or other underlying user data. -4. `zach/warp-cli-v2/appstate-file-drive-views` — create from `zach/warp-cli-v2/readonly-capability-targets`. It owns read-only app-state, file view, Drive view, block/input/history-style underlying-data read surfaces that are approved for the public catalog, along with the required underlying-data-read permission checks. It must keep local filesystem content reads/writes outside the v0 public catalog unless the specs are updated first. +2. `zach/warp-cli-v2/auth-security` — create from `zach/warp-cli-v2/contract-spec-sync`. It owns authentication and security enforcement that applies across command families: scoped grants, execution-context policy, authenticated-user plumbing, denial paths, Settings > Scripting security controls, and tests proving high-risk actions cannot run without the required identity and exact-action grant. +3. `zach/warp-cli-v2/readonly-capability-targets` — create from `zach/warp-cli-v2/auth-security`. It owns structural metadata reads, capability/action metadata, target selector parsing and resolution, opaque IDs, active window/tab/pane/session targeting, exact-action checks for structural metadata reads, and read-only CLI output for these surfaces. It must not expose terminal output, input buffers, history, Drive object contents, or other underlying user data. +4. `zach/warp-cli-v2/appstate-file-drive-views` — create from `zach/warp-cli-v2/readonly-capability-targets`. It owns read-only app-state, file view, Drive view, block/input/history-style underlying-data read surfaces that are approved for the public catalog, along with the exact-action checks for content-bearing reads. It must keep local filesystem content reads/writes outside the v0 public catalog unless the specs are updated first. 5. `zach/warp-cli-v2/metadata-config-mutations` — create from `zach/warp-cli-v2/appstate-file-drive-views`. It owns metadata/configuration mutations: allowlisted settings changes, labels/titles/appearance/configuration updates, settings or surface-opening commands that are metadata/configuration rather than underlying-data mutations, and tests proving unallowlisted or private settings are rejected. 6. `zach/warp-cli-v2/drive-data-mutations` — create from `zach/warp-cli-v2/metadata-config-mutations`. It owns authenticated underlying-data mutations for Warp Drive objects, including typed object create/update/delete/insert and the approved v0 personal-to-team sharing path. It must use disposable resources in tests and must not implement local file content reads, writes, appends, deletes, or other filesystem-content mutations. -7. `zach/warp-cli-v2/execution-underlying` — create from `zach/warp-cli-v2/drive-data-mutations`. It owns authenticated execution-underlying actions such as `input.run` and typed workflow execution where supported. It must require underlying-data-mutation permission plus authenticated scripting identity, deterministic target resolution, audit records, and tests proving accepted-command submission and agent-prompt submission remain unavailable. -8. `zach/warp-cli-v2/cli-catalog-docs` — create from `zach/warp-cli-v2/execution-underlying`. It owns the final CLI/catalog/docs integration pass: generated or curated command catalog output, help/completion polish, user-facing docs, Agent skill updates, command-family documentation, future `WarpCtrlBehavior` action-review scaffolding if it has not landed earlier, and consistency checks that every advertised action has protocol metadata, permission metadata, parser coverage, handler coverage, and tests. +7. `zach/warp-cli-v2/execution-underlying` — create from `zach/warp-cli-v2/drive-data-mutations`. It owns authenticated execution-underlying actions such as `input.run` and typed workflow execution where supported. It must require an exact execution-action grant plus authenticated scripting identity, deterministic target resolution, audit records, and tests proving accepted-command submission and agent-prompt submission remain unavailable. +8. `zach/warp-cli-v2/cli-catalog-docs` — create from `zach/warp-cli-v2/execution-underlying`. It owns the final CLI/catalog/docs integration pass: generated or curated command catalog output, help/completion polish, user-facing docs, Agent skill updates, command-family documentation, future `WarpCtrlBehavior` action-review scaffolding if it has not landed earlier, and consistency checks that every advertised action has protocol metadata, authorization metadata, parser coverage, handler coverage, and tests. 9. `zach/warp-cli-v2/fanin-finalize` — create from `zach/warp-cli-v2/cli-catalog-docs`. It owns fan-in cleanup only: conflict-resolution preservation, naming/format consistency, final test fixes, validation matrix updates, and integration fixes required for the recovered stack to compile and pass tests. It should not introduce broad new command families. Recommended raw-git setup for a clean local reconstruction: ```bash @@ -397,7 +397,7 @@ git checkout -b zach/warp-cli-v2/execution-underlying git checkout -b zach/warp-cli-v2/cli-catalog-docs git checkout -b zach/warp-cli-v2/fanin-finalize ``` -If a lower branch changes after higher branches exist, rebase each higher branch onto its immediate lower branch in stack order. Resolve conflicts by preserving the lower branch's shared contracts, permission model, and spec files while also keeping the higher branch's owned behavior. +If a lower branch changes after higher branches exist, rebase each higher branch onto its immediate lower branch in stack order. Resolve conflicts by preserving the lower branch's shared contracts, exact-action authorization model, and spec files while also keeping the higher branch's owned behavior. The previous `zach/warp-cli-integration-fanin` branch and its backup are preservation/history refs for the integrated implementation work. They are not review branches. Earlier non-v2 proposed branch names and the older broad stacks are migration-source/history material only unless the stack is deliberately renamed in a future explicit reslicing. ### Feature flag and rollout gate The entire feature should be gated behind a Warp feature flag, proposed as `FeatureFlag::WarpControlCli` with Cargo feature `warp_control_cli`. @@ -421,7 +421,7 @@ Keep PR boundaries aligned with the v2 stack: - PR1: `zach/warp-cli-v2/contract-spec-sync` into `master` for specs, shared contracts, protocol, CLI skeleton, settings, bridge, module scaffolding, and first-slice smoke behavior. - PR2: `zach/warp-cli-v2/auth-security` into `zach/warp-cli-v2/contract-spec-sync` or its merged successor for auth/security enforcement, execution-context policy, scoped grants, and Settings > Scripting security controls. - PR3: `zach/warp-cli-v2/readonly-capability-targets` into `zach/warp-cli-v2/auth-security` or its merged successor for metadata reads, capabilities, target selectors, and read-only structural command output. -- PR4: `zach/warp-cli-v2/appstate-file-drive-views` into `zach/warp-cli-v2/readonly-capability-targets` or its merged successor for approved underlying-data read surfaces, app-state/file/Drive views, and underlying-data-read permission tests. +- PR4: `zach/warp-cli-v2/appstate-file-drive-views` into `zach/warp-cli-v2/readonly-capability-targets` or its merged successor for approved content-bearing read surfaces, app-state/file/Drive views, and exact-action denial tests. - PR5: `zach/warp-cli-v2/metadata-config-mutations` into `zach/warp-cli-v2/appstate-file-drive-views` or its merged successor for metadata/configuration mutations, allowlisted settings changes, and configuration-denial tests. - PR6: `zach/warp-cli-v2/drive-data-mutations` into `zach/warp-cli-v2/metadata-config-mutations` or its merged successor for authenticated Warp Drive underlying-data mutations. - PR7: `zach/warp-cli-v2/execution-underlying` into `zach/warp-cli-v2/drive-data-mutations` or its merged successor for authenticated execution-underlying actions. @@ -450,7 +450,7 @@ sequenceDiagram CLI->>HTTP: Authenticated POST tab.create request HTTP->>HTTP: Verify context-specific enablement + credential + execution context HTTP->>BRIDGE: Typed request + response channel - BRIDGE->>BRIDGE: Recheck enablement + permission + auth-user policy + BRIDGE->>BRIDGE: Recheck enablement + exact action + auth-user policy BRIDGE->>RES: Resolve window/tab/pane/session selectors RES-->>BRIDGE: Concrete target handles or typed error BRIDGE->>ACT: Execute allowlisted ControlAction @@ -473,7 +473,7 @@ Map tests directly to `PRODUCT.md` behavior. - Tests proving discovery in disabled state exposes no actionable endpoint authority or credential reference. - Credential-storage tests proving raw credentials are not written into plaintext discovery records. - Execution-context tests proving external clients cannot receive grants reserved for verified Warp-terminal invocations. - - Permission-category enforcement tests proving insufficient grants fail with `insufficient_permissions` before selector resolution or handler dispatch, including separate denial cases for app-state mutation, metadata/configuration mutation, and underlying-data mutation. + - Exact-action enforcement tests proving a valid credential for one action fails with `insufficient_permissions` when used for any other action, before selector resolution or handler dispatch. - Authenticated-user tests proving user-authenticated actions fail without a logged-in app user or authenticated-user grant. - External-context tests proving authenticated-user actions remain unavailable outside verified Warp-terminal invocations and fail before selector resolution or handler dispatch. - Settings > Scripting tests proving mode changes invalidate credentials and prevent new grants for invocation contexts no longer allowed. @@ -504,7 +504,7 @@ Map tests directly to `PRODUCT.md` behavior. - Shell completions/help output checks once final command naming is selected. ### Computer-use CLI verification Before any stacked PR is considered ready for review, run an end-to-end computer-use verification pass against a cloud built validation artifact from that branch. The validation artifact is a Warp app build plus its packaged `warpctrl` wrapper from the exact commit under review, with `warp_control_cli` compiled in and `FeatureFlag::WarpControlCli` enabled at runtime. -The verifier must launch the built Warp app, enable the Scripting settings needed for the command category under test, and capture screenshots or screen recordings that prove both the terminal invocation and the user-visible result of each basic command family. Every `warpctrl` invocation executed during verification must have a durable screenshot captured immediately after the command runs, showing the exact command line and full terminal output in either pretty or JSON mode. Screenshots should be saved as durable artifacts and named by branch, invocation context, command family, command name, and whether the image shows terminal output or app UI state. +The verifier must launch the built Warp app, enable the Scripting settings and satisfy the direct policy requirements for the command under test, and capture screenshots or screen recordings that prove both the terminal invocation and the user-visible result of each basic command family. Every `warpctrl` invocation executed during verification must have a durable screenshot captured immediately after the command runs, showing the exact command line and full terminal output in either pretty or JSON mode. Screenshots should be saved as durable artifacts and named by branch, invocation context, command family, command name, and whether the image shows terminal output or app UI state. Verification screenshots should make the cause and effect visible in a single image whenever possible. The preferred composition is a staggered two-window layout where the terminal running the `warpctrl` command remains visible and unobscured, while the target Warp window or terminal is also visible enough to prove the UI state before or after the command. For outside-Warp invocations, use one external terminal window for the CLI command and one built Warp app window, staggered so the screenshot shows both the command/output and the Warp UI result. For inside-Warp invocations, use two Warp terminal windows or panes when possible: one Warp terminal running `warpctrl` and a second Warp terminal or Warp window showing the target/result state, staggered so both are visible in the same screenshot. Avoid screenshots that show only the CLI terminal or only the Warp UI when a combined view can be captured. Before/after screenshots for visible mutations should preserve the same staggered layout so reviewers can compare the command context and UI state directly. If a single combined screenshot is not possible because of window-manager, display-size, or focus limitations, the verifier must capture paired screenshots with the same ordinal: one terminal-output screenshot that fully shows the command and output, and one UI screenshot that shows the resulting Warp state. The manifest entry should explain why the combined composition was not possible. Screenshots should not crop out the command, exit status, selected Warp target, or relevant visible UI effect. Before every computer-use scenario, the verifier must explicitly ask and answer, "What is the best way to show the impact of this CLI command?" The verifier should then put Warp into a state where the expected effect is clearly visible before running the command. For example, syntax-highlighting changes should start with recognizable text in the input editor that will visibly change; font-size and zoom changes should start with enough terminal text or UI chrome to compare scale; tab or pane rename/color commands should keep the affected tab or pane label visible; app-state mutation commands should make the target workspace, tab, pane, input box, or surface visible; and denial paths should show the relevant Settings > Scripting state or target state that makes the denial meaningful. Each manifest entry for a visible or user-facing command should describe the chosen proof setup, the expected visual effect, and any setup screenshot used to establish the before state. @@ -512,17 +512,17 @@ After each command that has a visible or user-facing result, the verifier must u The verifier must exercise every invocation context implemented by the branch under review. For the current foundation branch, inside-Warp verification means proving `InsideWarp` requests are rejected because proof verification does not exist yet, while the default disabled mode blocks all contexts until the user changes it. Once verified Warp-terminal support is implemented, the verifier must also run `warpctrl` from a terminal session inside the feature-flag-enabled Warp app and prove the app-issued execution-context proof is accepted and Settings > Scripting mode gates the invocation context. The outside-Warp path must run the packaged `warpctrl` wrapper from an external terminal or automation shell outside the built Warp app and prove outside-Warp control is default-off, then works only after selecting the broadest mode in Settings > Scripting. The verification matrix should cover every implemented command in `PRODUCT.md` at least once in JSON output mode. The verifier must capture a terminal-output screenshot for every command invocation, including successful calls, expected denials, unsupported stubs, and fail-closed policy gates. Where there is a visible UI effect, the verifier must also capture app UI screenshots after the command runs, and before the command when that is needed to prove a before/after state transition. At minimum: - read-only metadata commands show successful CLI output in a terminal screenshot and, for active/focus/list commands, a visible target that matches the output; -- underlying data read commands show successful CLI output only when the underlying-data-read permission is enabled, plus terminal screenshots for disabled-permission denials; +- content-bearing read commands show successful CLI output only with a credential for that exact action, plus terminal screenshots proving another action credential is denied; - app-state mutation commands show terminal-output screenshots plus before/after app UI screenshots proving the visible Warp UI changed; - metadata/configuration mutation commands show terminal-output screenshots plus before/after app UI screenshots proving the persisted setting or label changed; -- underlying data mutation commands run only in a disposable test workspace/session with test Warp Drive objects, show terminal screenshots for denial without the underlying-data-mutation permission, then show terminal screenshots and any relevant app/file/Drive state evidence for success with the permission enabled; +- commands that mutate user data or execute code run only in a disposable test workspace/session with test Warp Drive objects, show terminal screenshots proving a different action credential is denied, then show terminal screenshots and any relevant app/file/Drive state evidence for success with the exact action grant and required approval; - authenticated-user commands show terminal screenshots for both the logged-out or missing-grant denial path and the enabled authenticated path when a test account/environment is available. -The verifier should produce a verification manifest checked into or attached to the PR artifacts. The manifest should list each command, branch under test, invocation context, required permission category, expected result, actual result, terminal screenshot artifact path, UI screenshot artifact path when applicable, and any skipped case with a reason. Missing terminal screenshots for any executed `warpctrl` invocation block review readiness. Missing UI screenshots for visible commands also block review readiness. +The verifier should produce a verification manifest checked into or attached to the PR artifacts. The manifest should list each command, branch under test, invocation context, required exact action and direct policy requirements, expected result, actual result, terminal screenshot artifact path, UI screenshot artifact path when applicable, and any skipped case with a reason. Missing terminal screenshots for any executed `warpctrl` invocation block review readiness. Missing UI screenshots for visible commands also block review readiness. ## Parallelization The durable review stack should remain linear, but implementation can still happen in parallel with Oz cloud agents. For the recovered v2 stack, parallel work should feed short-lived shard branches that are merged or cherry-picked into exactly one v2 review branch by the lead integrator. Shard branches should not become long-lived PRs by default. The completed recovery used fan-in shard work as source material, then sliced it into the v2 stack. Future parallel work should use the same contract-first fan-out pattern: - Start from the lowest v2 branch that already contains the contracts needed by the shard. -- Give each shard a single owned command family or permission boundary. +- Give each shard a single owned command family or authorization boundary. - Keep `specs/warp-control-cli/*` unchanged on shards unless the shard explicitly starts from and targets `zach/warp-cli-v2/contract-spec-sync` for a spec update. - Have the lead integrator merge or cherry-pick shard work into the appropriate v2 branch, then rebase all higher v2 branches upward. Suggested shard-to-stack ownership: @@ -536,7 +536,7 @@ Suggested shard-to-stack ownership: Each cloud shard prompt should include: - The exact base branch and shard branch name. - The v2 review branch it is intended to feed. -- Owned command families and permission categories. +- Owned command families and action-specific policy decisions. - Owned files/modules. - Files/modules the shard must not edit without calling out the need for integration. - Selector resolution requirements. @@ -581,7 +581,7 @@ flowchart LR - Over-broad settings mutation: - Mitigation: allowlisted setting keys only, with private/debug/derived settings rejected. - Command execution risk: - - Mitigation: keep `input.run`/typed workflow execution in the catalog but implement it only in `zach/warp-cli-v2/execution-underlying` after authenticated scripting identity, underlying-data-mutation permission, deterministic target resolution, and audit coverage are in place. + - Mitigation: keep `input.run`/typed workflow execution in the catalog but implement it only in `zach/warp-cli-v2/execution-underlying` after authenticated scripting identity, an exact `input.run` or workflow-execution grant, deterministic target resolution, approval policy, and audit coverage are in place. - Packaging churn due to provisional wrapper naming: - Mitigation: document `warpctrl` as provisional and settle final aliases before broad release workflow rollout. - Heavyweight CLI startup caused by sharing the app binary: From 8d9da2394a9ed9a4a2e639a39dc2ecfe421f8844 Mon Sep 17 00:00:00 2001 From: zachlloyd <zachlloyd@gmail.com> Date: Sat, 6 Jun 2026 17:13:54 +0000 Subject: [PATCH 47/48] Fix local control cross-platform compilation Co-Authored-By: Oz <oz-agent@warp.dev> --- crates/local_control/Cargo.toml | 5 ++++- crates/local_control/src/client.rs | 17 +++++++++++++++++ crates/local_control/src/discovery.rs | 11 +++++++++-- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/local_control/Cargo.toml b/crates/local_control/Cargo.toml index 37d2a4e319..d37fb8c16a 100644 --- a/crates/local_control/Cargo.toml +++ b/crates/local_control/Cargo.toml @@ -10,14 +10,17 @@ license.workspace = true base64.workspace = true chrono.workspace = true rand.workspace = true -reqwest.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true thiserror.workspace = true uuid.workspace = true +[target.'cfg(not(target_family = "wasm"))'.dependencies] +reqwest.workspace = true [target.'cfg(unix)'.dependencies] libc.workspace = true +[target.'cfg(windows)'.dependencies] +command.workspace = true [dev-dependencies] command.workspace = true diff --git a/crates/local_control/src/client.rs b/crates/local_control/src/client.rs index c9f589c09d..fc591d0738 100644 --- a/crates/local_control/src/client.rs +++ b/crates/local_control/src/client.rs @@ -38,6 +38,7 @@ use crate::protocol::{ }; /// Requests an action-scoped credential and sends one authenticated control request. +#[cfg(not(target_family = "wasm"))] pub fn send_request( instance: &InstanceRecord, request: &RequestEnvelope, @@ -91,6 +92,22 @@ pub fn send_request( )) } +/// Fails closed on platforms without a native local-control HTTP transport. +#[cfg(target_family = "wasm")] +pub fn send_request( + instance: &InstanceRecord, + request: &RequestEnvelope, +) -> Result<ResponseEnvelope, ControlError> { + request_credential( + instance, + request.action.kind, + InvocationContext::OutsideWarp, + )?; + Err(ControlError::new( + ErrorCode::LocalControlDisabled, + "outside-Warp local control requires a native HTTP transport", + )) +} #[cfg(unix)] /// Resolves the selected instance's validated broker path and requests a credential. fn request_credential_over_owner_ipc( diff --git a/crates/local_control/src/discovery.rs b/crates/local_control/src/discovery.rs index 3c5fa726ab..1c484832d7 100644 --- a/crates/local_control/src/discovery.rs +++ b/crates/local_control/src/discovery.rs @@ -30,6 +30,8 @@ use std::os::unix::fs::PermissionsExt as _; use std::path::{Path, PathBuf}; use chrono::{DateTime, Utc}; +#[cfg(windows)] +use command::blocking::Command; use serde::{Deserialize, Serialize}; use crate::protocol::{ActionMetadata, ControlError, ErrorCode, PROTOCOL_VERSION}; @@ -330,14 +332,18 @@ fn is_pid_alive(pid: u32) -> bool { unsafe { libc::kill(pid as libc::pid_t, 0) == 0 } } -#[cfg(not(unix))] +#[cfg(windows)] fn is_pid_alive(pid: u32) -> bool { - std::process::Command::new("tasklist") + Command::new("tasklist") .args(["/FI", &format!("PID eq {pid}"), "/NH"]) .output() .map(|o| !String::from_utf8_lossy(&o.stdout).contains("No tasks")) .unwrap_or(true) } +#[cfg(all(not(unix), not(windows)))] +fn is_pid_alive(_: u32) -> bool { + false +} fn record_path(dir: &Path, instance_id: &InstanceId) -> PathBuf { dir.join(format!("{}.json", instance_id.0)) @@ -382,6 +388,7 @@ fn set_private_permissions(_path: &Path) -> Result<(), ControlError> { )) } +#[cfg(unix)] fn permissions_error(operation: &str, error: std::io::Error) -> ControlError { ControlError::with_details( ErrorCode::Internal, From c6185d28c48800f226d277864e114e6bce502b1b Mon Sep 17 00:00:00 2001 From: Zachary Lloyd <zachlloyd@gmail.com> Date: Sat, 6 Jun 2026 11:42:32 -0600 Subject: [PATCH 48/48] Fix Windows local-control CI failures Co-Authored-By: Oz <oz-agent@warp.dev> --- app/src/local_control/mod.rs | 13 +++++++++++-- crates/local_control/src/discovery_tests.rs | 18 +++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/app/src/local_control/mod.rs b/app/src/local_control/mod.rs index 161b813e14..a1138745ea 100644 --- a/app/src/local_control/mod.rs +++ b/app/src/local_control/mod.rs @@ -73,7 +73,9 @@ use std::net::SocketAddr; use std::os::unix::fs::PermissionsExt as _; use std::sync::{Arc, Mutex}; -use ::local_control::auth::{CredentialGrant, CredentialRequest, ScopedCredential}; +use ::local_control::auth::CredentialGrant; +#[cfg(any(unix, test))] +use ::local_control::auth::{CredentialRequest, ScopedCredential}; use ::local_control::{ ActionKind, AuthToken, ControlEndpoint, ControlError, ControlResponse, ErrorCode, ErrorResponseEnvelope, InstanceId, InstanceRecord, RegisteredInstance, RequestEnvelope, @@ -87,12 +89,16 @@ use axum::response::{IntoResponse, Response}; use axum::routing::post; use axum::{Json, Router}; pub use bridge::LocalControlBridge; +#[cfg(any(unix, test))] use chrono::Duration; -use permissions::{ensure_action_allowed, ensure_feature_enabled, ensure_protocol_version}; +use permissions::ensure_feature_enabled; +#[cfg(any(unix, test))] +use permissions::{ensure_action_allowed, ensure_protocol_version}; #[cfg(unix)] use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; use warp_core::channel::ChannelState; use warpui::{Entity, ModelContext, ModelSpawner, SingletonEntity}; +#[cfg(any(unix, test))] const MAX_ACTIVE_CREDENTIALS: usize = 128; /// App-owned authority shared by one instance's broker and HTTP listener. @@ -428,6 +434,7 @@ fn ensure_peer_uid(stream: &tokio::net::UnixStream, expected_uid: u32) -> Result Ok(()) } +#[cfg(unix)] fn serialize_credential_broker_response( response: &impl serde::Serialize, ) -> Result<Vec<u8>, ControlError> { @@ -444,6 +451,7 @@ fn serialize_credential_broker_response( /// /// The bearer secret and its grant are retained only in the running instance's /// process-local map; neither is written back into the discovery registry. +#[cfg(any(unix, test))] async fn issue_credential( state: &ControlServerState, request: CredentialRequest, @@ -608,6 +616,7 @@ async fn handle_control_request( (status, Json(response)).into_response() } +#[cfg(any(unix, test))] fn insert_credential( credentials: &mut HashMap<String, CredentialGrant>, secret: String, diff --git a/crates/local_control/src/discovery_tests.rs b/crates/local_control/src/discovery_tests.rs index b573ab792b..fa99d0db2f 100644 --- a/crates/local_control/src/discovery_tests.rs +++ b/crates/local_control/src/discovery_tests.rs @@ -246,9 +246,25 @@ fn discovery_record_is_owner_only_on_unix() { impl RegisteredInstance { fn register_in_dir_for_test(record: InstanceRecord, dir: &Path) -> Result<Self, ControlError> { fs::create_dir_all(dir).expect("create dir"); + #[cfg(unix)] set_private_dir_permissions(dir)?; let path = record_path(dir, &record.instance_id); - write_record(&path, &record)?; + let bytes = serde_json::to_vec_pretty(&record).map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to serialize local-control discovery test record", + err.to_string(), + ) + })?; + fs::write(&path, bytes).map_err(|err| { + ControlError::with_details( + ErrorCode::Internal, + "failed to write local-control discovery test record", + err.to_string(), + ) + })?; + #[cfg(unix)] + set_private_permissions(&path)?; Ok(Self { record, path,