Skip to content
Draft
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
30 changes: 19 additions & 11 deletions .claude/skills/plain-portal/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,26 @@ Open an encrypted tunnel to a remote machine and run Python code on it.

The remote side must be running first. Either start it yourself (if you have access to the platform CLI) or ask the user to start it:

| Platform | Command |
| ---------- | --------------------------------------------------- |
| Heroku | `heroku run plain portal start` |
| Fly.io | `fly ssh console -C "plain portal start"` |
| Kubernetes | `kubectl exec -it deploy/app -- plain portal start` |
| Docker | `docker exec -it container plain portal start` |
| SSH | `ssh server plain portal start` |
| Platform | Command |
| ---------- | --------------------------------------------------------------- |
| Heroku | `heroku run plain portal start --read-only` |
| Fly.io | `fly ssh console -C "plain portal start --read-only"` |
| Kubernetes | `kubectl exec -it deploy/app -- plain portal start --read-only` |
| Docker | `docker exec -it container plain portal start --read-only` |
| SSH | `ssh server plain portal start --read-only` |

**Both `start` and `connect` are long-running foreground processes.** If you run `start` yourself, use `run_in_background` so you don't block. Once it prints a portal code (e.g. `7-crossword-pineapple`), read the code from the output. If the user ran it, ask them for the code.
`start` requires either `--read-only` or `--read-write`. Always use `--read-only` unless the user has explicitly asked for database writes.

Then connect (also use `run_in_background`):
**`start` is a long-running foreground process** -- it keeps the remote dyno/container alive. If you run it yourself, use `run_in_background` so you don't block, then read the portal code (e.g. `7-crossword-pineapple`) from its output. If the user ran it, ask them for the code.

**`connect` is an ordinary blocking command.** It starts a background daemon, waits until the tunnel is up, and returns. Do not use `run_in_background` for it:

```
uv run plain portal connect <code>
```

It prints `Connected to remote. Session active.` on success, or exits non-zero with the daemon's output if the connection failed (a wrong or expired code, for example).

## 2. Run commands

Execute Python code on the remote machine:
Expand Down Expand Up @@ -60,11 +64,15 @@ Push is restricted to `/tmp/` on the remote machine.

## 3. Disconnect

Kill the `connect` process to end the session. This also frees the remote process.
```
uv run plain portal disconnect
```

This stops the local daemon and frees the remote process.

## Important

- Sessions are **read-only** by default. Database writes will fail unless the remote was started with `--writable --yes`.
- Use `--read-only` sessions. Database writes will fail unless the remote was started with `--read-write --yes`, which should only happen at the user's explicit request.
- Each `exec` gets a **fresh namespace**. Variables don't carry between commands. Put setup and queries in one code block if they depend on each other.
- Use `plain portal exec` for quick queries. For heavy data export, write to `/tmp/` on the remote and `pull` the file.
- If the session drops, the remote side must be restarted and a new code used to reconnect.
72 changes: 37 additions & 35 deletions plain-portal/plain/portal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Portal requires only outbound internet access on both sides. No firewall rules,
**1. Start a session on the remote machine** (via whatever mechanism your platform provides):

```console
$ heroku run plain portal start
$ heroku run plain portal start --read-only
Portal code: 7-crossword-pineapple
Session mode: read-only
Waiting for connection...
Expand Down Expand Up @@ -72,28 +72,34 @@ Portal session disconnected.
Start a portal session on the remote machine. Connects to the relay, prints a portal code, and waits for a local client to connect.

```console
$ plain portal start
$ plain portal start --writable
$ plain portal start --timeout 60
$ plain portal start --read-only
$ plain portal start --read-write
$ plain portal start --read-only --timeout 60
```

| Option | Description | Default |
| ------------ | ------------------------------------------------ | --------------- |
| `--writable` | Allow database writes (prompts for confirmation) | Off (read-only) |
| `--timeout` | Idle timeout in minutes (0 to disable) | 30 |
Exactly one of `--read-only` or `--read-write` is required. There is no default, so the database mode is always visible in the command itself -- useful when an agent (or a permission prompt) needs to judge whether a command is safe.

| Option | Description | Default |
| -------------- | ------------------------------------------------ | ------- |
| `--read-only` | Enforce a read-only database connection | -- |
| `--read-write` | Allow database writes (prompts for confirmation) | -- |
| `--timeout` | Idle timeout in minutes (0 to disable) | 30 |

### `plain portal connect <code>`

Connect to a remote portal session. Establishes the encrypted tunnel and backgrounds itself.
Connect to a remote portal session. Starts a background daemon that holds the encrypted tunnel open, waits until it is ready, then returns -- so the next command can be `exec` straight away.

```console
$ plain portal connect 7-crossword-pineapple
Connected to remote. Session active.
$ plain portal connect 7-crossword-pineapple --foreground
```

| Option | Description | Default |
| -------------- | ------------------------------------------ | ------- |
| `--foreground` | Run in foreground instead of backgrounding | Off |
| Option | Description | Default |
| -------------- | ------------------------------------------------------- | ------- |
| `--foreground` | Run in the foreground instead of as a background daemon | Off |

If the daemon fails to connect, `connect` exits non-zero and prints the daemon's output. The daemon's log is kept at `.plain/portal/connect.log`.

### `plain portal exec <code>`

Expand Down Expand Up @@ -129,11 +135,7 @@ Pushed ./fix.py -> /tmp/fix.py (892 bytes)

### `plain portal disconnect`

Kill the background daemon and clean up the local session.

### `plain portal status`

Show whether a portal session is active and its process ID.
Stop the background daemon and clean up the local session. The remote side exits when the tunnel closes.

## How it works

Expand Down Expand Up @@ -161,9 +163,9 @@ Production (heroku run, fly ssh, kubectl exec, etc.) Local machine

The local side uses a background daemon and Unix socket:

- `plain portal connect <code>` establishes the WebSocket connection, performs the key exchange, then forks into the background and listens on a Unix socket (`/tmp/plain-portal.sock`).
- `plain portal connect <code>` spawns `plain portal connect --foreground <code>` as a detached process (a spawn, not a fork -- forking after the interpreter is up crashes on macOS). The daemon establishes the WebSocket connection, performs the key exchange, and listens on a project-scoped Unix socket in the system temp directory. `connect` returns once that socket exists.
- `exec`, `pull`, and `push` connect to the local Unix socket, send a request through the tunnel, and print the response.
- `plain portal disconnect` kills the background process and cleans up the socket.
- Only one session per project at a time. The daemon holds a file lock on `.plain/portal/portal.lock` for its lifetime and records its pid there -- the lock is what proves it is alive, so a second `connect` is refused and `plain portal disconnect` never signals a stale pid.

The tunnel stays open across commands, but each `exec` gets a fresh Python namespace on the remote side. If you need setup code, put it all in one code block. Users who want a stateful interactive REPL should use `plain shell` directly on the remote machine.

Expand All @@ -176,16 +178,16 @@ The tunnel stays open across commands, but each `exec` gets a fresh Python names

## Read-only mode

By default, the remote session enforces a read-only database connection. Any INSERT, UPDATE, DELETE, or DDL statement raises a database error.
With `--read-only`, the remote session enforces a read-only database connection. Any INSERT, UPDATE, DELETE, or DDL statement raises a database error.

```console
$ plain portal start
$ plain portal start --read-only
```

To allow writes, pass `--writable`. This prompts for confirmation before starting:
To allow writes, pass `--read-write` instead. This prompts for confirmation before starting:

```console
$ plain portal start --writable
$ plain portal start --read-write
This session allows writes to the production database. Continue? [y/N]
```

Expand Down Expand Up @@ -230,7 +232,7 @@ $ plain portal exec "exec(open('/tmp/backfill.py').read())"

- **Max file size**: 50 MB per transfer. Files are chunked into 256 KB messages so individual WebSocket frames stay small.
- **Push destination**: `push` only writes to `/tmp/` on the remote side. Attempts to write outside `/tmp/` are rejected.
- **`--writable` is independent**: `push` always works regardless of read-only mode. Pushing a script to `/tmp/` and running it read-only is a valid workflow.
- **`--read-write` is independent**: `push` always works regardless of read-only mode. Pushing a script to `/tmp/` and running it read-only is a valid workflow.

## Output

Expand Down Expand Up @@ -319,19 +321,19 @@ The portal does not add its own authorization layer. Security comes from three b

The portal is intentionally unrestricted once connected -- it can run any Python code, just like `plain shell`. The access control question is "can you start the remote process?" If you can, you already have full access anyway.

`--writable` controls database write access only, not general code execution.
`--read-write` controls database write access only, not general code execution.

## Platform compatibility

Portal works anywhere you can run a process with outbound internet access:

| Platform | How to start the remote side |
| ------------- | --------------------------------------------------- |
| Heroku | `heroku run plain portal start` |
| Fly.io | `fly ssh console -C "plain portal start"` |
| Kubernetes | `kubectl exec -it deploy/app -- plain portal start` |
| Docker | `docker exec -it container plain portal start` |
| Any VM/server | `ssh myserver plain portal start` |
| Platform | How to start the remote side |
| ------------- | --------------------------------------------------------------- |
| Heroku | `heroku run plain portal start --read-only` |
| Fly.io | `fly ssh console -C "plain portal start --read-only"` |
| Kubernetes | `kubectl exec -it deploy/app -- plain portal start --read-only` |
| Docker | `docker exec -it container plain portal start --read-only` |
| Any VM/server | `ssh myserver plain portal start --read-only` |

On the local side, run `plain portal connect <code>` in your normal terminal. No special setup needed.

Expand Down Expand Up @@ -373,15 +375,15 @@ Waiting for connection...

This is important for the **support use case**: a customer running a self-hosted app can start a portal and share the code with the developer. The developer connects and debugs, but the customer watches the full session on their terminal. They see every command executed and every file transferred, and can Ctrl-C to kill the session at any time.

The customer does not need to grant SSH access, open firewall ports, or share credentials. They run `plain portal start`, share the code, and supervise.
The customer does not need to grant SSH access, open firewall ports, or share credentials. They run `plain portal start --read-only`, share the code, and supervise.

### Idle timeout

The remote side auto-disconnects after 30 minutes of inactivity (no commands received). A warning is printed before disconnecting. The timeout is configurable:

```console
$ plain portal start --timeout 60 # 60 minutes
$ plain portal start --timeout 0 # no timeout
$ plain portal start --read-only --timeout 60 # 60 minutes
$ plain portal start --read-only --timeout 0 # no timeout
```

## Installation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,26 @@ Open an encrypted tunnel to a remote machine and run Python code on it.

The remote side must be running first. Either start it yourself (if you have access to the platform CLI) or ask the user to start it:

| Platform | Command |
| ---------- | --------------------------------------------------- |
| Heroku | `heroku run plain portal start` |
| Fly.io | `fly ssh console -C "plain portal start"` |
| Kubernetes | `kubectl exec -it deploy/app -- plain portal start` |
| Docker | `docker exec -it container plain portal start` |
| SSH | `ssh server plain portal start` |
| Platform | Command |
| ---------- | --------------------------------------------------------------- |
| Heroku | `heroku run plain portal start --read-only` |
| Fly.io | `fly ssh console -C "plain portal start --read-only"` |
| Kubernetes | `kubectl exec -it deploy/app -- plain portal start --read-only` |
| Docker | `docker exec -it container plain portal start --read-only` |
| SSH | `ssh server plain portal start --read-only` |

**Both `start` and `connect` are long-running foreground processes.** If you run `start` yourself, use `run_in_background` so you don't block. Once it prints a portal code (e.g. `7-crossword-pineapple`), read the code from the output. If the user ran it, ask them for the code.
`start` requires either `--read-only` or `--read-write`. Always use `--read-only` unless the user has explicitly asked for database writes.

Then connect (also use `run_in_background`):
**`start` is a long-running foreground process** -- it keeps the remote dyno/container alive. If you run it yourself, use `run_in_background` so you don't block, then read the portal code (e.g. `7-crossword-pineapple`) from its output. If the user ran it, ask them for the code.

**`connect` is an ordinary blocking command.** It starts a background daemon, waits until the tunnel is up, and returns. Do not use `run_in_background` for it:

```
uv run plain portal connect <code>
```

It prints `Connected to remote. Session active.` on success, or exits non-zero with the daemon's output if the connection failed (a wrong or expired code, for example).

## 2. Run commands

Execute Python code on the remote machine:
Expand Down Expand Up @@ -60,11 +64,15 @@ Push is restricted to `/tmp/` on the remote machine.

## 3. Disconnect

Kill the `connect` process to end the session. This also frees the remote process.
```
uv run plain portal disconnect
```

This stops the local daemon and frees the remote process.

## Important

- Sessions are **read-only** by default. Database writes will fail unless the remote was started with `--writable --yes`.
- Use `--read-only` sessions. Database writes will fail unless the remote was started with `--read-write --yes`, which should only happen at the user's explicit request.
- Each `exec` gets a **fresh namespace**. Variables don't carry between commands. Put setup and queries in one code block if they depend on each other.
- Use `plain portal exec` for quick queries. For heavy data export, write to `/tmp/` on the remote and `pull` the file.
- If the session drops, the remote side must be restarted and a new code used to reconnect.
55 changes: 47 additions & 8 deletions plain-portal/plain/portal/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,16 @@ def cli() -> None:

@cli.command()
@click.option(
"--writable", is_flag=True, help="Allow database writes (default: read-only)."
"--read-only",
"read_only",
is_flag=True,
help="Enforce a read-only database connection.",
)
@click.option(
"--read-write",
"read_write",
is_flag=True,
help="Allow database writes (prompts for confirmation).",
)
@click.option(
"--timeout",
Expand All @@ -52,10 +61,19 @@ def cli() -> None:
hidden=True,
)
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.")
def start(writable: bool, timeout: int, relay_host: str, yes: bool) -> None:
"""Start a portal session on the remote machine."""
def start(
read_only: bool, read_write: bool, timeout: int, relay_host: str, yes: bool
) -> None:
"""Start a portal session on the remote machine.

The database mode must be stated explicitly with --read-only or --read-write,
so the intent is visible in the command itself.
"""
if read_only == read_write:
raise click.UsageError("Specify exactly one of --read-only or --read-write.")

if (
writable
read_write
and not yes
and not click.confirm(
"This session allows writes to the production database. Continue?"
Expand All @@ -66,7 +84,7 @@ def start(writable: bool, timeout: int, relay_host: str, yes: bool) -> None:
from .remote import run_remote

asyncio.run(
run_remote(writable=writable, timeout_minutes=timeout, relay_host=relay_host)
run_remote(writable=read_write, timeout_minutes=timeout, relay_host=relay_host)
)


Expand All @@ -78,11 +96,32 @@ def start(writable: bool, timeout: int, relay_host: str, yes: bool) -> None:
default=DEFAULT_RELAY_HOST,
hidden=True,
)
def connect(code: str, relay_host: str) -> None:
"""Connect to a remote portal session."""
@click.option(
"--foreground",
is_flag=True,
help="Run in the foreground instead of as a background daemon.",
)
def connect(code: str, relay_host: str, foreground: bool) -> None:
"""Connect to a remote portal session.

Starts a background daemon that holds the tunnel open, then returns.
Use `plain portal disconnect` to end the session.
"""
from .local import connect as do_connect
from .local import spawn_connect_daemon

if foreground:
asyncio.run(do_connect(code, relay_host=relay_host))
else:
spawn_connect_daemon(code, relay_host=relay_host)


@cli.command()
def disconnect() -> None:
"""Disconnect the active portal session."""
from .local import disconnect_daemon

asyncio.run(do_connect(code, relay_host=relay_host))
disconnect_daemon()


@cli.command("exec")
Expand Down
Loading
Loading