Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/crisp-documentation-screenshots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@internal/docs": patch
---

Recapture documentation screenshots at twice the pixel density, with consistent margins and frames for clearer text and enlarged views.
5 changes: 5 additions & 0 deletions .changeset/crisp-website-screenshots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@internal/website": patch
---

Replace feature screenshots with sharp, lossless captures and consistent product-colored frames.
5 changes: 5 additions & 0 deletions .changeset/document-desktop-ssh-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@internal/docs": patch
---

Explain how to connect with a key from your machine, including browser risks, destination confirmation, temporary local signing, and compatibility requirements. Document retry behavior, passphrase retention, failed-connection cleanup, and resolved addresses in recent suggestions. Remove the outdated authentication screenshot.
10 changes: 10 additions & 0 deletions .changeset/ssh-leaves-the-agent-list.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@subshell-ai/plugin-api": minor
"@subshell-ai/plugin-ssh": patch
"@internal/server": patch
"@internal/node": patch
---

The New subshell Agent list no longer offers SSH, on web and on mobile. A pick there carries no destination, so it died at ssh's usage prompt; the SSH terminal tab's connect form is the real door, and it keeps working as before.

Plugins now say this about themselves: a `pickable: false` in the manifest's `subshell` block keeps a plugin out of the launch pickers while leaving it launchable by id. The picker holds no id list; the SSH plugin carries the declaration.
10 changes: 10 additions & 0 deletions .changeset/use-a-key-from-my-machine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@internal/server": minor
"@internal/node": patch
"@internal/desktop-server": minor
"@internal/desktop-client": minor
---

Connect an SSH subshell using a key selected on your own machine in a browser or desktop app. The key stays in the local signer, authorizes one host-bound authentication, and is released after use or a short timeout. Add native key selection and encrypted-key unlock, an authenticated temporary signing bridge, browser signing with explicit website-trust and memory-cleanup warnings, and guidance on verification and cleanup.

The desktop picker supports Ed25519, ECDSA and RSA private and host keys. Native RSA uses AWS-LC instead of the RustCrypto RSA backend; browser RSA uses Web Crypto. Failed browser attempts retain the selected file and passphrase for retry until the signing flow succeeds or the form is closed.
12 changes: 9 additions & 3 deletions .claude/rules/security-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,12 @@ execution during `ssh -G`; trust the connecting account’s SSH configuration. F
a conflicting pin. Host-pin recovery APIs remain even though their settings UI is
gone. Passwords enter through the terminal, are not saved as credentials, and are
still visible to trusted transport endpoints. Saved destinations are not trust pins.
**Local key signing** (`/api/ssh/local-key*`, cookie-session only) feeds signatures
through a session-scoped, sequence-checked, one-use bridge: one key, ≤8 requests,
90 s. The browser worker's disposal is a lifetime boundary, NOT a defense against
the page it runs in (that path's trust-the-site checkbox is load-bearing); the
native path keeps private material outside the remote page and approves the
destination natively.

## Network plugins

Expand All @@ -157,11 +163,11 @@ Design: `docs/superpowers/specs/2026-09-15-updates-design.md` (§15 "Amendments

Per-app mechanics (command lists and shapes, assistant screens, the reset path, resolution ladders, entitlement plists, tray/window details) live in each app's `AGENTS.md` and `docs/`; they load when you touch that app; read them before working there. The cross-cutting security rules:

- **The boundary is window KIND, not count** (§8b): commands touching a CLI, config, service manager or filesystem are granted to BUNDLED pages only; each app has one remote window and one bundled page. Each app's ACL manifest is **load-bearing by existence** (no manifest ⇒ Tauri leaves app commands ungated for local windows); the `csp` in `tauri.conf.json` governs bundled pages only; the remote window carries whatever the plane sends.
- **Subshell Server's remote window** opens on `APP_BASE_URL` when https, else the managed server's loopback page; it may NAVIGATE anywhere http(s) (OAuth-proxy sign-in needs it), but the app commands answer only while the COMMITTED main-frame page is on loopback (either spelling, http, any port) or the configured `APP_BASE_URL` (`trust.rs`, keyed on the calling webview). The capability's wildcard `remote.urls` is NOT the boundary. Arming is by committed main-frame load only, never a navigation request or iframe; `on_navigation` refuses OS-actionable schemes and arms nothing. The grant is scoped to the WINDOW, not an admin session. Six of the seven commands cannot touch the machine; `desktop_set_supervision` is the ONE deliberate CLI-touching grant: it applies the restart route's pane-safety refusal (failing closed on an unreadable definition), refuses to interleave with itself (refuse, never queue), and its audit row (`server.supervision.request`) is best-effort, posted by the CALLER: legible, not a control.
- **The boundary is window KIND, not count** (§8b): commands touching a CLI, config, service manager or filesystem are granted to BUNDLED pages only, with one narrow exception: the SSH signer commands let a REMOTE page ask for native confirmation and a native file picker, but it cannot name a file or receive key contents; each app has one remote window, one bundled management page, and a temporary bundled SSH-key unlock window. Each app's ACL manifest is **load-bearing by existence** (no manifest ⇒ Tauri leaves app commands ungated for local windows); the `csp` in `tauri.conf.json` governs bundled pages only; the remote window carries whatever the plane sends.
- **Subshell Server's remote window** opens on `APP_BASE_URL` when https, else the managed server's loopback page; it may NAVIGATE anywhere http(s) (OAuth-proxy sign-in needs it), but the app commands answer only while the COMMITTED main-frame page is on loopback (either spelling, http, any port) or the configured `APP_BASE_URL` (`trust.rs`, keyed on the calling webview). The capability's wildcard `remote.urls` is NOT the boundary. Arming is by committed main-frame load only, never a navigation request or iframe; `on_navigation` refuses OS-actionable schemes and arms nothing. The grant is scoped to the WINDOW, not an admin session. `main` holds eleven commands now: the four `desktop_ssh_key_*` signer commands require HTTPS except on loopback, bind each selection to the COMMITTED page, and need native approval before a key loads; they keep nothing past one signature. Six of the original seven cannot touch the machine; `desktop_set_supervision` is the ONE deliberate CLI-touching grant: it applies the restart route's pane-safety refusal (failing closed on an unreadable definition), refuses to interleave with itself (refuse, never queue), and its audit row (`server.supervision.request`) is best-effort, posted by the CALLER: legible, not a control.
- **The Server assistant (`wizard`, bundled)** owns first run, recovery, update and reset. `desktop_reset` takes ONLY a typed hostname (compared to Rust's memoized `hostname(1)`), never a path; deletion paths come all-or-nothing from the server's own `status --json` `paths` block (partial = refuse); a recursive delete that would contain the installed `~/.local/bin/subshell-server` is refused before any mutation; a reset does NOT reach enrolled remote nodes or a same-machine `subshell` agent.
- **Server config routes** (cookie-admin, bearer refused) rewrite config.env for port, bind, base URL, trusted origins; NEVER `DATABASE_PATH`, never the auth secret; a key sourced from process env is refused 409, never written (a write the next boot masks is a fake success). Restart is refused unless the manager reports THIS pid and again when the definition would take tmux panes down absent `{force:true}`. **Stop, start, install, uninstall and reset have NO route**: each leaves the server unreachable; they stay with the CLI and the assistant. The config validator is SHARED with the CLI through one `applyConfig`; never mirror it.
- **Subshell Client's remote window** is granted exactly ONE command, `desktop_open_in_browser`: it takes a PATH (`/`-prefixed, validator shared in `crates/desktop-core`; no authority, no backslash, no whitespace/control) and joins it onto `PlanePin`, the origin the window was OPENED with (moved by a deliberate plane switch, never by a redirect). Refusals are invisible by design; the window's `SubshellClient/…` marker is what `isServerDesktop()`/`isDesktop()` gate on in the SPA. Everything privileged lives on the **bundled node page** (`capabilities/node.json`): reset/enroll/unenroll/service/install-agent, plane verbs, updater verbs. A pasted setup key reaches `subshell enroll --key` as one argv element; the returned node key is never surfaced; enrolling twice is destructive, so the app spawns nothing until confirmed.
- **Subshell Client's remote window** is granted FIVE narrow commands: `desktop_open_in_browser` plus the four `desktop_ssh_key_*` signer commands. The signer commands bind to `PlanePin`, require HTTPS except on loopback, and require native approval before a key loads; the page never names a file or receives key material or a passphrase (§8b "Use a key from my machine"). `desktop_open_in_browser` takes a PATH (`/`-prefixed, validator shared in `crates/desktop-core`; no authority, no backslash, no whitespace/control) and joins it onto `PlanePin`, the origin the window was OPENED with (moved by a deliberate plane switch, never by a redirect). Refusals are invisible by design; the window's `SubshellClient/…` marker is what `isServerDesktop()`/`isDesktop()` gate on in the SPA. Everything privileged lives on the **bundled node page** (`capabilities/node.json`): reset/enroll/unenroll/service/install-agent, plane verbs, updater verbs. A pasted setup key reaches `subshell enroll --key` as one argv element; the returned node key is never surfaced; enrolling twice is destructive, so the app spawns nothing until confirmed.
- **Entitlements:** each app keeps a SEPARATE plist (one Tauri slot per bundle; sharing one silently widens whichever pipeline was not edited), carrying only `allow-jit` + `allow-unsigned-executable-memory`, pinned by test; `disable-library-validation`, `allow-dyld-environment-variables` and `disable-executable-page-protection` are ABSENT.
- **They execute what they find:** each resolution ladder runs `<candidate> version`, and the installed copy in `~/.local/bin` is executed on every launch from a user-writable directory (same OS user; accepted. But validate a hand-chosen binary before persisting it and bound every spawn); the login-shell PATH probe runs the user's own profile, arbitrary code by construction.
- **Desktop-as-supervisor:** the server believes `SUBSHELL_SUPERVISOR*` only when the named pid is its actual parent; a forged claim buys `restart.available: true` AND pane-safety-refusal suppression, accepted because the forger is whoever launched the process. The app signals the MAIN pid, never the process group, which earns its `paneSafety: "keeps"`.
Expand Down
20 changes: 20 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,16 @@ jobs:
# whichever Mac image the fleet hands the shard.
targets: aarch64-apple-darwin,x86_64-apple-darwin

- name: Ensure native crypto build dependencies
if: startsWith(matrix.app, 'desktop-')
run: |
if [ "$(uname -s)" = Linux ]; then
apt-get update
apt-get install -y --no-install-recommends cmake clang libclang-dev
elif ! command -v cmake >/dev/null; then
brew install cmake
fi

- name: Cache cargo
if: startsWith(matrix.app, 'desktop-')
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
Expand Down Expand Up @@ -1369,6 +1379,16 @@ jobs:
# whichever Mac image the fleet hands the shard.
targets: aarch64-apple-darwin,x86_64-apple-darwin

- name: Ensure native crypto build dependencies
if: startsWith(matrix.app, 'desktop-')
run: |
if [ "$(uname -s)" = Linux ]; then
apt-get update
apt-get install -y --no-install-recommends cmake clang libclang-dev
elif ! command -v cmake >/dev/null; then
brew install cmake
fi

- name: Cache cargo
if: startsWith(matrix.app, 'desktop-')
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
Expand Down
19 changes: 18 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,11 @@ jobs:
- name: Build workspace packages
run: bun run build

# Browser signer tests generate real OpenSSH key fixtures.
- name: Ensure OpenSSH key generation
run: |
command -v ssh-keygen >/dev/null || { apt-get update && apt-get install -y --no-install-recommends openssh-client; }

- name: Run web tests
run: bunx turbo run test --continue --filter=@internal/server-web

Expand Down Expand Up @@ -775,6 +780,11 @@ jobs:
# The image installs rustup with `--profile minimal`, which does NOT
# carry rustfmt or clippy — and this job is nothing but those two plus
# tests. Idempotent, so it is a no-op once the image bakes them in.
- name: Ensure native crypto build dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends cmake clang libclang-dev

- name: Ensure rustfmt and clippy
run: |
set -euo pipefail
Expand Down Expand Up @@ -850,6 +860,11 @@ jobs:
- name: Trust the bind-mounted workspace
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"

- name: Ensure native crypto build dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends cmake clang libclang-dev

- name: Ensure rustfmt and clippy
run: |
set -euo pipefail
Expand Down Expand Up @@ -1040,7 +1055,9 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: e2e/playwright-report/
path: |
e2e/playwright-report/
e2e/test-results/
retention-days: 7

- name: Un-root the workspace
Expand Down
16 changes: 13 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,11 @@ Client, as a second window: whoever makes their laptop a node is usually also
watching subshells on it, and shipping that as two installs would ask a user to
understand a split that serves only us. **That window existing is the whole
"node functionality" toggle**; there is no mode flag. The window loading the
control plane's page is granted exactly ONE Tauri command ("open this page in
the system browser", which takes a path and can name no host), and everything
privileged lives on the bundled node page; see `apps/client/desktop/AGENTS.md`.
control plane's page is granted narrow Tauri commands for opening a path in the system browser and
one-use native SSH signing. The signer requires native destination approval,
keeps keys and passphrase entry outside the remote page, checks the committed
pinned origin, and ends access after one signature or 90 seconds. General node
management stays on the bundled node page; see `apps/client/desktop/AGENTS.md`.

`apps/server/web` is the SERVER's SPA and nothing else's, which is what the
nesting says out loud. `apps/client/mobile` is a client because it is a person's
Expand Down Expand Up @@ -379,6 +381,14 @@ this is the human-facing statement of it.)

### Development

**When the user asks to spin up development for testing, always provide the
hot-reload frontend URL**, normally `http://localhost:5174`, together with
the test login. Start Vite against an isolated backend using
`DEV_API_ORIGIN` (see `apps/server/web/AGENTS.md`) and verify login through
that URL. Don't hand over the backend's built-SPA URL as the testing entry
point: UI edits should appear without manual rebuilds or page refreshes.
Preserve an existing test account and database when possible.

```bash
bun run start # Start dev mode with watch (turbo watch dev)
turbo watch dev # Same as above
Expand Down
Loading
Loading