Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 16 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,22 @@ terminal.
2. From the same machine, `wrapper attach` connects to that local server and
mirrors the session. This path needs no account and no network.
3. When you press `Ctrl+\ s`, the CLI asks the Convex backend for a short-lived
host ticket, connects to the relay on Fly.io, and marks the session shared.
Another device runs `wrapper attach --relay` to join over the relay.
4. If `WRAPPER_P2P=1` is set on both ends, the relay is used only to exchange
WebRTC signaling, and the actual keystrokes and output move over a direct
peer-to-peer data channel for lower latency. The relay stays as the fallback.
host ticket, connects to the relay on Fly.io, marks the session shared, and
prints a secret share code. You join your own devices with
`wrapper attach --relay --id <id>`; anyone else must pass the code with
`--code <code>`, so knowing the session id alone is not enough.
4. By default the relay is used only to exchange WebRTC signaling, and the actual
keystrokes and output move over a direct peer-to-peer data channel for lower
latency. The relay stays as the fallback, and you can force it with
`WRAPPER_P2P=0`.

## Repository layout

This is a Bun and Turborepo monorepo.

```
apps/
cli/ Wrapper CLI: shell wrapping, session registry, local + relay attach, device auth, optional P2P
cli/ Wrapper CLI: shell wrapping, session registry, local + relay attach, device auth, default-on P2P
relay/ Relay service: authenticated WebSocket routing for shared sessions, deployed on Fly.io
web/ Next.js app on Vercel: landing page, device-login approval, onboarding, Pro upgrade
docs/ Public documentation site (Mintlify)
Expand All @@ -50,7 +53,7 @@ tools/
The CLI is the heart of the project. See
[`apps/cli/README.md`](./apps/cli/README.md) for how the wrapping flow works
and what every command does. The transport layer (relay WebSocket and the
optional direct WebRTC path) is documented in
default direct WebRTC path) is documented in
[`apps/cli/transport/README.md`](./apps/cli/transport/README.md).

## Where to read next
Expand All @@ -70,10 +73,12 @@ optional direct WebRTC path) is documented in
## Status

The CLI core, the Convex auth and backend, the relay transport, and the web
onboarding flow are implemented in this repository. Sharing runs over the relay
with an optional direct WebRTC P2P fast path (`WRAPPER_P2P`, off by default, with
the relay as the fallback). The next major phase is the mobile app, which lives
in the `apps/mobile` submodule and is planned in the docs site.
onboarding flow are implemented in this repository. Sharing attempts a direct
WebRTC P2P data path by default, with the relay kept online for signaling and
automatic fallback (`WRAPPER_P2P=0` forces relay-only mode). The terminal title
shows the role, session, and active transport without reserving a screen row.
The next major phase is the mobile app, which lives in the `apps/mobile`
submodule and is planned in the docs site.

The active focus before mobile work is operational hardening and release
channels:
Expand Down
11 changes: 6 additions & 5 deletions apps/cli/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ WRAPPER_RELAY_URL="ws://localhost:8080"
WRAPPER_AUTH_ORIGIN="http://localhost:3000"
WRAPPER_HUD="on"

# Opt-in WebRTC P2P fast path for relay sessions. Off by default (relay is used).
# When "1"/"true"/"on", host and viewer try a direct data channel (lower latency)
# and fall back to the relay if it can't be established. Requires testing across
# real networks. STUN is public; P2P exposes peer IPs to each other (WebRTC norm).
WRAPPER_P2P="false"
# WebRTC P2P fast path for relay sessions. ON by default: host and viewer try a
# direct data channel (lower latency) and fall back to the relay automatically if
# it can't be established. Set to "0"/"false"/"off" to force the relay path.
# Note: a direct P2P connection exposes each peer's IP to the other, and STUN
# discloses your public IP to the STUN server (normal WebRTC behavior).
WRAPPER_P2P="1"

# Optional logging and telemetry
WRAPPER_LOG="info"
Expand Down
116 changes: 89 additions & 27 deletions apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ sequenceDiagram
- periodic tick -> `session:heartbeat`
- shutdown -> `session:close`
8. On `share`, it issues a relay host ticket and starts a relay bridge.
9. It keeps a non-disruptive session HUD in the terminal title and reveals the
context-specific controls when `Ctrl+\` is armed.

Important safety guards:

Expand All @@ -97,7 +99,8 @@ Important safety guards:
1. Resolve target session by `--id`, `--port`, or picker from registry.
2. Local path: run `session:authorizeAttach`, then connect to `ws://127.0.0.1:<port>`.
3. Relay path (`--relay` or no local match for `--id`): issue `relay:issueViewerTicket`
and connect to `WRAPPER_RELAY_URL/ws?ticket=...`.
(passing `--code` when joining someone else's session) and connect to
`WRAPPER_RELAY_URL/ws?ticket=...`.
4. Bridge stdin/stdout via protocol messages.
5. Support detach without ending host session (`Ctrl+\` then `d`).

Expand Down Expand Up @@ -131,14 +134,59 @@ be either:

- **Relay WebSocket** (`WebSocketTransport`), always used, and it also carries WebRTC
signaling and is the fallback.
- **WebRTC data channel** (`WebRtcDataChannelTransport`, opt-in via
`WRAPPER_P2P`), a direct P2P connection negotiated over the relay. When it
opens, session traffic flows directly (SSH-like latency); if ICE can't connect,
the session transparently stays on the relay.
- **WebRTC data channel** (`WebRtcDataChannelTransport`), a direct P2P connection
negotiated over the relay. It is **on by default** (opt out with
`WRAPPER_P2P=0`). When it opens, session traffic flows directly (SSH-like
latency); if ICE can't connect, the session transparently stays on the relay.

Full design, security model, and testing steps live in
[`transport/README.md`](./transport/README.md). P2P applies only to `--relay`
attaches; local `127.0.0.1` attaches are already direct.
attaches; local `127.0.0.1` attaches are already direct. A direct connection
exposes each peer's IP to the other, so opt out if that matters for a session.

The active path is visible in the session HUD as `local`, `connecting`, `relay`,
`p2p`, or `offline`. If a P2P channel fails or stays disconnected, the viewer
switches input and output back to the relay immediately. ICE candidates that
arrive before the SDP offer or answer are queued instead of dropped. A live P2P
channel can also keep running if the signaling WebSocket closes.

## Sharing and access

Sharing is capability-based, so only people you explicitly invite can join:

- Pressing `Ctrl+\` `s` marks the session shared and prints a **share code** plus
the exact join command. The backend stores only the hash of that code.
- The owner can attach to their own session from any device with no code.
- Anyone else must pass the code:
`wrapper attach --relay --id <sessionId> --code <code>`. Knowing the session id
alone is not enough, and guessing is rate limited.
- Pressing `Ctrl+\` `u` (or ending the shell) revokes access immediately by
clearing the stored code and shared flag.

Behaviors to know once viewers join:

- **Shared control:** every connected viewer can type into the same shell. This
is intended for pair-prompting. There is no watch-only viewer mode yet.
- **Multiple viewers:** many viewers can attach to one session at once; host
output is broadcast to all of them (and fanned out over each P2P channel).
- **Consensus resize:** the terminal is sized to the smallest connected viewer,
so a small window shrinks everyone's view.
- **One host per session:** a second host connecting to the same session id
replaces the first.

### Local attach security

The local WebSocket server listens on `127.0.0.1`, which any process on the
machine can reach. To stop another local user from attaching to your shell, the
host generates a per-session loopback token, stores it only in the `0600`
registry inside your `0700` config dir, and requires it as `?token=` on every
local connection. `wrapper attach` reads the token from the registry, so only a
user who can read your registry (you, or root) can attach locally. The token is
redacted from logs.

Long-running hosts also keep their backend auth fresh: the short-lived Convex JWT
is re-minted from the stored session token before it expires, so a session that
runs for hours does not start failing its heartbeats.

## Commands

Expand Down Expand Up @@ -171,6 +219,7 @@ wrapper attach
wrapper attach --id <sessionId>
wrapper attach --port <port>
wrapper attach --relay --id <sessionId>
wrapper attach --relay --id <sessionId> --code <shareCode>
wrapper logs --follow
```

Expand All @@ -188,12 +237,24 @@ bun run dev:host

## In-session prefix shortcuts

Wrapper prints a one-time controls hint when a host reaches its first idle
prompt and when a viewer attaches. The terminal title then stays updated with
the role, short session id, and active transport. Pressing `Ctrl+\` changes the
title into a context-aware controls menu for 1.5 seconds.

Wrapper does not reserve a permanent row inside the terminal. A fixed bottom
bar would conflict with alternate-screen applications such as Vim, Neovim,
`less`, `htop`, and tmux. The title-based HUD remains visible without changing
PTY output, shell prompts, or full-screen layouts. If a shell or TUI writes its
own OSC title, Wrapper repaints the session HUD immediately afterward. Set
`WRAPPER_HUD=off` to disable title updates.

Inside host shell:

| Keys | Action |
| ----------------- | ------------------------- |
| `Ctrl+\` `s` | mark shared |
| `Ctrl+\` `u` | mark unshared |
| `Ctrl+\` `s` | share + print share code |
| `Ctrl+\` `u` | unshare + revoke access |
| `Ctrl+\` `?` | status overlay |
| `Ctrl+\` `Ctrl+\` | send literal control byte |
| `Ctrl+\` `Esc` | cancel prefix mode |
Expand All @@ -205,8 +266,9 @@ Inside attach viewer:
| `Ctrl+\` `d` | detach viewer |
| `Ctrl+\` `?` | viewer status |

When shared and authenticated, `Ctrl+\` + `s` starts a relay bridge and enables
remote attach with `wrapper attach --relay --id <sessionId>`.
When shared and authenticated, `Ctrl+\` + `s` starts a relay bridge, prints a
share code, and enables remote attach with
`wrapper attach --relay --id <sessionId> --code <shareCode>`.

## Debugging workflow

Expand All @@ -231,22 +293,22 @@ remote attach with `wrapper attach --relay --id <sessionId>`.

## Environment variables

| Variable | Description | Default |
| ----------------------- | ----------------------------------------- | -------------------------------------------- |
| `NODE_ENV` | runtime mode (`production` or dev) | unset (treated as dev) |
| `CI` | enable CI mode | unset |
| `WRAPPER_LOG` | `debug/info/warn/error/off` | dev=`info`, prod=`warn` |
| `WRAPPER_LOG_FILE` | override log file path | platform default |
| `WRAPPER_TELEMETRY` | `"false"` disables telemetry | enabled after consent |
| `WRAPPER_POSTHOG_KEY` | PostHog key | empty |
| `WRAPPER_TELEMETRY_URL` | telemetry endpoint | `https://telemetry.wrapper.sh` |
| `WRAPPER_RELAY_URL` | relay endpoint override | dev localhost, prod `wss://relay.wrapper.sh` |
| `WRAPPER_AUTH_ORIGIN` | auth callback origin | dev localhost, prod `https://wrapper.sh` |
| `WRAPPER_HUD` | HUD (`on/off`) | `on` |
| `WRAPPER_P2P` | opt-in WebRTC P2P fast path (`1/true/on`) | off (relay used) |
| `WRAPPER_CONVEX_URL` | Convex deployment URL for backend | falls back to `CONVEX_URL` if set |
| `WRAPPER_DISABLE` | disable hook in one terminal | unset |
| `WRAPPER_WRAPPED` | set by `shell-host` in inner shell | unset |
| Variable | Description | Default |
| ----------------------- | -------------------------------------------- | -------------------------------------------- |
| `NODE_ENV` | runtime mode (`production` or dev) | unset (treated as dev) |
| `CI` | enable CI mode | unset |
| `WRAPPER_LOG` | `debug/info/warn/error/off` | dev=`info`, prod=`warn` |
| `WRAPPER_LOG_FILE` | override log file path | platform default |
| `WRAPPER_TELEMETRY` | `"false"` disables telemetry | enabled after consent |
| `WRAPPER_POSTHOG_KEY` | PostHog key | empty |
| `WRAPPER_TELEMETRY_URL` | telemetry endpoint | `https://telemetry.wrapper.sh` |
| `WRAPPER_RELAY_URL` | relay endpoint override | dev localhost, prod `wss://relay.wrapper.sh` |
| `WRAPPER_AUTH_ORIGIN` | auth callback origin | dev localhost, prod `https://wrapper.sh` |
| `WRAPPER_HUD` | session title + armed controls HUD | `on` |
| `WRAPPER_P2P` | WebRTC P2P fast path; `0/false/off` opts out | on (relay is the fallback) |
| `WRAPPER_CONVEX_URL` | Convex deployment URL for backend | falls back to `CONVEX_URL` if set |
| `WRAPPER_DISABLE` | disable hook in one terminal | unset |
| `WRAPPER_WRAPPED` | set by `shell-host` in inner shell | unset |

`NODE_ENV=development` redirects state into `wrapper-dev`, uses localhost defaults, and
mirrors logs to stderr for easier local debugging.
Expand Down Expand Up @@ -276,7 +338,7 @@ relay/
host-bridge.ts host relay bridge; per-viewer P2P negotiation
transport/
transport.ts Transport interface + WebSocketTransport
webrtc.ts WebRTC P2P transport (werift, opt-in)
webrtc.ts WebRTC P2P transport (werift, default-on)
README.md transport + P2P design/security/testing
shell/
detect.ts shell detection
Expand Down
Loading
Loading