diff --git a/.claude/skills/plain-portal/SKILL.md b/.claude/skills/plain-portal/SKILL.md index bf7ff0094d..fff48474eb 100644 --- a/.claude/skills/plain-portal/SKILL.md +++ b/.claude/skills/plain-portal/SKILL.md @@ -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 ``` +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: @@ -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. diff --git a/plain-portal/plain/portal/README.md b/plain-portal/plain/portal/README.md index e09fa7fa77..a437bb1fc8 100644 --- a/plain-portal/plain/portal/README.md +++ b/plain-portal/plain/portal/README.md @@ -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... @@ -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 ` -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 ` @@ -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 @@ -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 ` 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 ` spawns `plain portal connect --foreground ` 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. @@ -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] ``` @@ -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 @@ -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 ` in your normal terminal. No special setup needed. @@ -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 diff --git a/plain-portal/plain/portal/agents/.claude/skills/plain-portal/SKILL.md b/plain-portal/plain/portal/agents/.claude/skills/plain-portal/SKILL.md index bf7ff0094d..fff48474eb 100644 --- a/plain-portal/plain/portal/agents/.claude/skills/plain-portal/SKILL.md +++ b/plain-portal/plain/portal/agents/.claude/skills/plain-portal/SKILL.md @@ -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 ``` +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: @@ -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. diff --git a/plain-portal/plain/portal/cli.py b/plain-portal/plain/portal/cli.py index 311261bc07..3c3ae1e089 100644 --- a/plain-portal/plain/portal/cli.py +++ b/plain-portal/plain/portal/cli.py @@ -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", @@ -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?" @@ -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) ) @@ -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") diff --git a/plain-portal/plain/portal/local.py b/plain-portal/plain/portal/local.py index 9456acc968..93c0d634d8 100644 --- a/plain-portal/plain/portal/local.py +++ b/plain-portal/plain/portal/local.py @@ -1,8 +1,9 @@ """Local side of a portal session. -Runs on the developer's machine. `connect` establishes the encrypted -tunnel through the relay and listens on a Unix socket. Subsequent -commands (exec, pull, push) talk to the connect process over the socket. +Runs on the developer's machine. `connect` spawns a background daemon that +establishes the encrypted tunnel through the relay and listens on a Unix +socket. Subsequent commands (exec, pull, push) talk to the daemon over the +socket, and `disconnect` stops it. """ from __future__ import annotations @@ -10,11 +11,15 @@ import asyncio import fcntl import functools +import hashlib import json import os import signal import struct +import subprocess import sys +import tempfile +import time from collections.abc import Callable import websockets.exceptions @@ -40,14 +45,146 @@ def _portal_dir() -> str: def _socket_path() -> str: - return os.path.join(_portal_dir(), "portal.sock") + """Unix socket path, kept short and project-scoped. + + AF_UNIX paths are limited to ~104 bytes on macOS, so the socket can't + live under the project's .plain/ directory -- a deep checkout path + fails with "AF_UNIX path too long". Hash the project dir into the + system temp dir instead. + """ + project_hash = hashlib.sha256(_portal_dir().encode()).hexdigest()[:12] + return os.path.join(tempfile.gettempdir(), f"plain-portal-{project_hash}.sock") def _lock_path() -> str: + """The daemon holds an exclusive flock on this file for its lifetime and + records its pid in it. Liveness comes from the lock (the kernel drops it + on crash), identity from the contents -- so a stale pid is never signalled.""" return os.path.join(_portal_dir(), "portal.lock") -_lock_fd = None +def _log_path() -> str: + return os.path.join(_portal_dir(), "connect.log") + + +# The daemon prints this once the Unix socket is listening. `connect` waits +# for it in the log to know the session is ready. +_SESSION_ACTIVE_LINE = "Connected to remote. Session active." + +# How long `connect` waits for the daemon to reach the relay and open its socket +_DAEMON_STARTUP_TIMEOUT = 30 + +_lock_fd: int | None = None + + +def _acquire_lock() -> bool: + """Claim the session lock for this process's lifetime, recording our pid. + + Returns False if another live daemon holds it. + """ + global _lock_fd + fd = os.open(_lock_path(), os.O_CREAT | os.O_RDWR, 0o644) + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + os.close(fd) + return False + os.ftruncate(fd, 0) + os.write(fd, str(os.getpid()).encode()) + _lock_fd = fd # Held open until process exit. + return True + + +def _live_daemon_pid() -> int | None: + """Return the pid of the daemon holding the lock, or None if nobody does.""" + try: + fd = os.open(_lock_path(), os.O_RDONLY) + except FileNotFoundError: + return None + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + # Locked -- a daemon is alive and its pid is in the file. + contents = os.read(fd, 32).decode().strip() + return int(contents) if contents else None + else: + # We got the lock, so no daemon holds it. Drop it again. + return None + finally: + os.close(fd) + + +def spawn_connect_daemon(code: str, *, relay_host: str) -> None: + """Run `connect --foreground` as a detached background process. + + Returns once the daemon reports the session is active, so callers can + go straight to `exec`/`pull`/`push`. Exits non-zero (with the daemon's + output) if it fails to connect for any reason -- bad code, relay down, + session already active. + + This is a spawn, not a fork -- os.fork() after the interpreter is up + crashes on macOS (ObjC runtime fork safety). + """ + log_path = _log_path() + with open(log_path, "w") as log: + process = subprocess.Popen( + [ + sys.executable, + "-m", + "plain", + "portal", + "connect", + "--foreground", + "--relay-host", + relay_host, + code, + ], + stdin=subprocess.DEVNULL, + stdout=log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + + deadline = time.monotonic() + _DAEMON_STARTUP_TIMEOUT + while time.monotonic() < deadline: + with open(log_path) as log: + output = log.read() + if _SESSION_ACTIVE_LINE in output: + print(_SESSION_ACTIVE_LINE) + return + if process.poll() is not None: + break + time.sleep(0.1) + else: + process.terminate() + + # The daemon exited or never came up -- surface whatever it printed. + with open(log_path) as log: + output = log.read().strip() + print(output or "Portal connect failed to start.", file=sys.stderr) + sys.exit(1) + + +def disconnect_daemon() -> None: + """Stop the background connect process, if there is one.""" + pid = _live_daemon_pid() + if pid is None: + print("No active portal session.") + return + + os.kill(pid, signal.SIGTERM) + + # The lock is released when the daemon exits. + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if _live_daemon_pid() is None: + break + time.sleep(0.1) + else: + os.kill(pid, signal.SIGKILL) + + _cleanup() + print("Portal session disconnected.") async def _send_framed(writer: asyncio.StreamWriter, data: bytes) -> None: @@ -81,16 +218,7 @@ async def connect( print(f"Invalid portal code: {code}", file=sys.stderr) sys.exit(1) - # Acquire an exclusive file lock before anything else. Holds for the - # lifetime of the process — released automatically on exit/crash. - # Stored at module level to prevent GC from closing the fd. - global _lock_fd - _lock_fd = open(_lock_path(), "w") # noqa: SIM115, ASYNC230 — held for the process lifetime - try: - fcntl.flock(_lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) - except OSError: - _lock_fd.close() - _lock_fd = None + if not _acquire_lock(): print("A portal session is already active.", file=sys.stderr) sys.exit(1) @@ -108,8 +236,6 @@ async def connect( encryptor = await perform_key_exchange(ws, code, side="connect") - print("Connected to remote. Session active.") - # Exec requests use queues (for streaming exec_stdout + exec_result). # All other request types use single-shot futures. pending_responses: dict[int, asyncio.Future] = {} @@ -246,6 +372,8 @@ async def relay_listener() -> None: finally: os.umask(old_umask) + print(_SESSION_ACTIVE_LINE, flush=True) + loop = asyncio.get_running_loop() def _handle_signal() -> None: diff --git a/plain-portal/plain/portal/remote.py b/plain-portal/plain/portal/remote.py index 813b4e3f6c..4dfa606c15 100644 --- a/plain-portal/plain/portal/remote.py +++ b/plain-portal/plain/portal/remote.py @@ -116,7 +116,9 @@ async def run_remote( print(f"Portal code: {code}") print(f"Session mode: {mode}") print("Waiting for connection...") - print() + # Flush so the code shows up immediately even when stdout is a pipe or + # file (e.g. an agent running `heroku run ...` in the background). + print(flush=True) cid = channel_id(code) relay_url = make_relay_url(relay_host, cid, "start")