From 077b9fe96f8f5dae0dde129c50a25acdfbfdf2ba Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Wed, 26 Aug 2026 16:23:59 -0500 Subject: [PATCH 1/3] Require --read-only or --read-write for plain portal start The database mode is no longer defaulted, so it is always visible in the command itself. That lets an agent's permission classifier (or a human reading a permission prompt) tell a read-only session from a writable one without knowing the previous default. --writable is renamed to --read-write to pair with --read-only. --- .claude/skills/plain-portal/SKILL.md | 18 ++++--- plain-portal/plain/portal/README.md | 51 ++++++++++--------- .../.claude/skills/plain-portal/SKILL.md | 18 ++++--- plain-portal/plain/portal/cli.py | 28 ++++++++-- 4 files changed, 70 insertions(+), 45 deletions(-) diff --git a/.claude/skills/plain-portal/SKILL.md b/.claude/skills/plain-portal/SKILL.md index bf7ff0094d..f58bc51e95 100644 --- a/.claude/skills/plain-portal/SKILL.md +++ b/.claude/skills/plain-portal/SKILL.md @@ -11,13 +11,15 @@ 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` | + +`start` requires either `--read-only` or `--read-write`. Always use `--read-only` unless the user has explicitly asked for database writes. **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. @@ -64,7 +66,7 @@ Kill the `connect` process to end the session. This also frees the remote proces ## 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..aad5e3436e 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,15 +72,18 @@ 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 ` @@ -176,16 +179,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 +233,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 +322,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 +376,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..f58bc51e95 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,13 +11,15 @@ 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` | + +`start` requires either `--read-only` or `--read-write`. Always use `--read-only` unless the user has explicitly asked for database writes. **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. @@ -64,7 +66,7 @@ Kill the `connect` process to end the session. This also frees the remote proces ## 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..2ddb240f34 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) ) From cd8ad1100161fa212a520309fb25023b3c10f54f Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Wed, 26 Aug 2026 16:39:20 -0500 Subject: [PATCH 2/3] Run plain portal connect as a background daemon connect was a foreground process holding the tunnel and a file lock for its lifetime, so an agent had to background it and the session died whenever that shell was reaped. It now spawns connect --foreground as a detached process (spawn, not fork -- os.fork() crashes on macOS), waits for the Unix socket to appear, and returns. plain portal disconnect stops the daemon via a pid file. Also: - Move the socket to the system temp dir keyed by a project hash. Under .plain/ it exceeded the AF_UNIX path limit on deep checkouts. - Flush the portal code from start immediately so it appears when stdout is a pipe or file. - Fix the README, which still described the pre-0.2 daemon. --- .claude/skills/plain-portal/SKILL.md | 12 +- plain-portal/plain/portal/README.md | 22 +-- .../.claude/skills/plain-portal/SKILL.md | 12 +- plain-portal/plain/portal/cli.py | 27 +++- plain-portal/plain/portal/local.py | 140 +++++++++++++++++- plain-portal/plain/portal/remote.py | 4 +- 6 files changed, 190 insertions(+), 27 deletions(-) diff --git a/.claude/skills/plain-portal/SKILL.md b/.claude/skills/plain-portal/SKILL.md index f58bc51e95..fff48474eb 100644 --- a/.claude/skills/plain-portal/SKILL.md +++ b/.claude/skills/plain-portal/SKILL.md @@ -21,14 +21,16 @@ The remote side must be running first. Either start it yourself (if you have acc `start` requires either `--read-only` or `--read-write`. Always use `--read-only` unless the user has explicitly asked for database writes. -**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` 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. -Then connect (also use `run_in_background`): +**`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: @@ -62,7 +64,11 @@ 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 diff --git a/plain-portal/plain/portal/README.md b/plain-portal/plain/portal/README.md index aad5e3436e..9280b48037 100644 --- a/plain-portal/plain/portal/README.md +++ b/plain-portal/plain/portal/README.md @@ -87,16 +87,19 @@ Exactly one of `--read-only` or `--read-write` is required. There is no default, ### `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 ` @@ -132,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 @@ -164,9 +163,10 @@ 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. +- `plain portal disconnect` stops the daemon (via the PID recorded in `.plain/portal/portal.pid`) and cleans up the socket. +- Only one session per project at a time. A file lock in `.plain/portal/` guards against a second `connect`. 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. 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 f58bc51e95..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 @@ -21,14 +21,16 @@ The remote side must be running first. Either start it yourself (if you have acc `start` requires either `--read-only` or `--read-write`. Always use `--read-only` unless the user has explicitly asked for database writes. -**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` 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. -Then connect (also use `run_in_background`): +**`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: @@ -62,7 +64,11 @@ 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 diff --git a/plain-portal/plain/portal/cli.py b/plain-portal/plain/portal/cli.py index 2ddb240f34..3c3ae1e089 100644 --- a/plain-portal/plain/portal/cli.py +++ b/plain-portal/plain/portal/cli.py @@ -96,11 +96,32 @@ def start( 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..3035caa8cb 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 @@ -39,17 +44,137 @@ def _portal_dir() -> str: return d +@functools.lru_cache 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: return os.path.join(_portal_dir(), "portal.lock") +def _pid_path() -> str: + return os.path.join(_portal_dir(), "portal.pid") + + +def _log_path() -> str: + return os.path.join(_portal_dir(), "connect.log") + + _lock_fd = None +def spawn_connect_daemon(code: str, *, relay_host: str) -> None: + """Run `connect --foreground` as a detached background process. + + Returns once the daemon is listening on the Unix socket, so callers can + go straight to `exec`/`pull`/`push`. Exits non-zero (with the daemon's + output) if it fails to connect. + + This is a spawn, not a fork -- os.fork() after the interpreter is up + crashes on macOS (ObjC runtime fork safety). + """ + # Refuse early if a session is already active, so we don't remove a + # live socket below. The daemon takes the lock for real once it starts. + with open(_lock_path(), "w") as probe: + try: + fcntl.flock(probe, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + print("A portal session is already active.", file=sys.stderr) + sys.exit(1) + + # A stale socket from a crashed session would look like readiness. + _cleanup() + + log_path = _log_path() + with open(log_path, "w") as log, open(os.devnull) as devnull: + process = subprocess.Popen( + [ + sys.executable, + "-m", + "plain", + "portal", + "connect", + "--foreground", + "--relay-host", + relay_host, + code, + ], + stdin=devnull, + stdout=log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + + with open(_pid_path(), "w") as f: + f.write(str(process.pid)) + + deadline = time.monotonic() + _DAEMON_STARTUP_TIMEOUT + while time.monotonic() < deadline: + if os.path.exists(_socket_path()): + print("Connected to remote. Session active.") + return + if process.poll() is not None: + break + time.sleep(0.1) + + # The daemon exited or never came up -- surface whatever it printed. + if process.poll() is None: + process.terminate() + _remove_pid_file() + 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.""" + try: + with open(_pid_path()) as f: + pid = int(f.read().strip()) + except (FileNotFoundError, ValueError): + print("No active portal session.") + return + + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + _remove_pid_file() + _cleanup() + print("No active portal session.") + return + + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + try: + os.kill(pid, 0) + except ProcessLookupError: + break + time.sleep(0.1) + else: + os.kill(pid, signal.SIGKILL) + + _remove_pid_file() + _cleanup() + print("Portal session disconnected.") + + +def _remove_pid_file() -> None: + try: + os.unlink(_pid_path()) + except FileNotFoundError: + pass + + async def _send_framed(writer: asyncio.StreamWriter, data: bytes) -> None: """Write a length-prefixed message to a stream.""" writer.write(struct.pack("!I", len(data))) @@ -57,6 +182,9 @@ async def _send_framed(writer: asyncio.StreamWriter, data: bytes) -> None: await writer.drain() +# How long `connect` waits for the daemon to reach the relay and open its socket +_DAEMON_STARTUP_TIMEOUT = 30 + # 75MB — large enough for 50MB files base64-encoded (~67MB), prevents unbounded allocation _MAX_FRAME_SIZE = 75 * 1024 * 1024 @@ -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("Connected to remote. Session active.", 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..02498ecf32 100644 --- a/plain-portal/plain/portal/remote.py +++ b/plain-portal/plain/portal/remote.py @@ -113,10 +113,12 @@ async def run_remote( code = generate_code() mode = "writable" if writable else "read-only" + # 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(f"Portal code: {code}") print(f"Session mode: {mode}") print("Waiting for connection...") - print() + print(flush=True) cid = channel_id(code) relay_url = make_relay_url(relay_host, cid, "start") From c655dc12ed05284c2eef59f02c8b9a1d39b6fa86 Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Wed, 26 Aug 2026 16:46:23 -0500 Subject: [PATCH 3/3] Record the daemon pid in the portal lock file The daemon now writes its pid into the flock'd lock file instead of a separate pid file the parent wrote. Liveness comes from the lock, so disconnect never signals a stale or recycled pid. connect no longer probes the lock or clears the socket itself -- the daemon does both, and readiness is its own 'Session active' line in the log. --- plain-portal/plain/portal/README.md | 3 +- plain-portal/plain/portal/local.py | 132 ++++++++++++++-------------- plain-portal/plain/portal/remote.py | 4 +- 3 files changed, 69 insertions(+), 70 deletions(-) diff --git a/plain-portal/plain/portal/README.md b/plain-portal/plain/portal/README.md index 9280b48037..a437bb1fc8 100644 --- a/plain-portal/plain/portal/README.md +++ b/plain-portal/plain/portal/README.md @@ -165,8 +165,7 @@ The local side uses a background daemon and Unix socket: - `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` stops the daemon (via the PID recorded in `.plain/portal/portal.pid`) and cleans up the socket. -- Only one session per project at a time. A file lock in `.plain/portal/` guards against a second `connect`. +- 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. diff --git a/plain-portal/plain/portal/local.py b/plain-portal/plain/portal/local.py index 3035caa8cb..93c0d634d8 100644 --- a/plain-portal/plain/portal/local.py +++ b/plain-portal/plain/portal/local.py @@ -44,7 +44,6 @@ def _portal_dir() -> str: return d -@functools.lru_cache def _socket_path() -> str: """Unix socket path, kept short and project-scoped. @@ -58,44 +57,76 @@ def _socket_path() -> str: 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") -def _pid_path() -> str: - return os.path.join(_portal_dir(), "portal.pid") - - def _log_path() -> str: return os.path.join(_portal_dir(), "connect.log") -_lock_fd = None +# 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 is listening on the Unix socket, so callers can + 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. + 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). """ - # Refuse early if a session is already active, so we don't remove a - # live socket below. The daemon takes the lock for real once it starts. - with open(_lock_path(), "w") as probe: - try: - fcntl.flock(probe, fcntl.LOCK_EX | fcntl.LOCK_NB) - except OSError: - print("A portal session is already active.", file=sys.stderr) - sys.exit(1) - - # A stale socket from a crashed session would look like readiness. - _cleanup() - log_path = _log_path() - with open(log_path, "w") as log, open(os.devnull) as devnull: + with open(log_path, "w") as log: process = subprocess.Popen( [ sys.executable, @@ -108,28 +139,26 @@ def spawn_connect_daemon(code: str, *, relay_host: str) -> None: relay_host, code, ], - stdin=devnull, + stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, start_new_session=True, ) - with open(_pid_path(), "w") as f: - f.write(str(process.pid)) - deadline = time.monotonic() + _DAEMON_STARTUP_TIMEOUT while time.monotonic() < deadline: - if os.path.exists(_socket_path()): - print("Connected to remote. Session active.") + 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. - if process.poll() is None: - process.terminate() - _remove_pid_file() with open(log_path) as log: output = log.read().strip() print(output or "Portal connect failed to start.", file=sys.stderr) @@ -138,43 +167,26 @@ def spawn_connect_daemon(code: str, *, relay_host: str) -> None: def disconnect_daemon() -> None: """Stop the background connect process, if there is one.""" - try: - with open(_pid_path()) as f: - pid = int(f.read().strip()) - except (FileNotFoundError, ValueError): + pid = _live_daemon_pid() + if pid is None: print("No active portal session.") return - try: - os.kill(pid, signal.SIGTERM) - except ProcessLookupError: - _remove_pid_file() - _cleanup() - 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: - try: - os.kill(pid, 0) - except ProcessLookupError: + if _live_daemon_pid() is None: break time.sleep(0.1) else: os.kill(pid, signal.SIGKILL) - _remove_pid_file() _cleanup() print("Portal session disconnected.") -def _remove_pid_file() -> None: - try: - os.unlink(_pid_path()) - except FileNotFoundError: - pass - - async def _send_framed(writer: asyncio.StreamWriter, data: bytes) -> None: """Write a length-prefixed message to a stream.""" writer.write(struct.pack("!I", len(data))) @@ -182,9 +194,6 @@ async def _send_framed(writer: asyncio.StreamWriter, data: bytes) -> None: await writer.drain() -# How long `connect` waits for the daemon to reach the relay and open its socket -_DAEMON_STARTUP_TIMEOUT = 30 - # 75MB — large enough for 50MB files base64-encoded (~67MB), prevents unbounded allocation _MAX_FRAME_SIZE = 75 * 1024 * 1024 @@ -209,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) @@ -372,7 +372,7 @@ async def relay_listener() -> None: finally: os.umask(old_umask) - print("Connected to remote. Session active.", flush=True) + print(_SESSION_ACTIVE_LINE, flush=True) loop = asyncio.get_running_loop() diff --git a/plain-portal/plain/portal/remote.py b/plain-portal/plain/portal/remote.py index 02498ecf32..4dfa606c15 100644 --- a/plain-portal/plain/portal/remote.py +++ b/plain-portal/plain/portal/remote.py @@ -113,11 +113,11 @@ async def run_remote( code = generate_code() mode = "writable" if writable else "read-only" - # 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(f"Portal code: {code}") print(f"Session mode: {mode}") print("Waiting for connection...") + # 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)