diff --git a/CHANGELOG.md b/CHANGELOG.md index 05367ae..ab1084f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve ## Unreleased +### Added + +- A blog at shell.online/blog/, listing every shell.online post published so + far (23 posts, first published on pilotprotocol.network, which stays their + canonical home). Posts are listed in `blog/posts.json` with their bodies in + `blog/posts/`, and the build renders the index, each post, an RSS feed and a + sitemap as static pages. See blog/README.md. +- `/blogs` and `/blogs//` redirect to the same page under `/blog/`. + ## [0.25.0] — 2026-10-02 ### Added diff --git a/blog/posts.json b/blog/posts.json index fe51488..4ff77a5 100644 --- a/blog/posts.json +++ b/blog/posts.json @@ -1 +1,391 @@ -[] +[ + { + "slug": "end-to-end-encrypted-terminal-sharing-has-a-second-secret-on-the-host-shell-onli", + "title": "End-to-End Encrypted Terminal Sharing Has a Second Secret on the Host: shell.online v0.24.1 Takes the Session Password Out of Process Environments", + "description": "What end-to-end encrypted terminal sharing hides from the relay, and why shell.online v0.24.1 moved the session password out of process environments.", + "category": "Security", + "tags": [ + "shell-online", + "security", + "end-to-end-encryption", + "coding-agents", + "secrets" + ], + "iso_date": "2026-09-30", + "author": "Artemii Amelin", + "banner": "banners/end-to-end-encrypted-terminal-sharing-has-a-second-secret-on-the-host-shell-onli.svg", + "canonical": "https://pilotprotocol.network/blog/end-to-end-encrypted-terminal-sharing-has-a-second-secret-on-the-host-shell-onli" + }, + { + "slug": "let-a-coding-agent-share-its-own-terminal-session-in-the-browser-the-shell-onlin", + "title": "Let a Coding Agent Share Its Own Terminal Session in the Browser: the shell.online Skill and shell --json", + "description": "How the shell.online agent skill tells Claude Code or Codex to share a terminal session in the browser and hand you the link, read-only when it should be.", + "category": "Guide", + "tags": [ + "shell-online", + "agent-skills", + "coding-agents", + "terminal-sharing", + "claude-code" + ], + "iso_date": "2026-09-29", + "author": "Artemii Amelin", + "banner": "banners/let-a-coding-agent-share-its-own-terminal-session-in-the-browser-the-shell-onlin.svg", + "canonical": "https://pilotprotocol.network/blog/let-a-coding-agent-share-its-own-terminal-session-in-the-browser-the-shell-onlin" + }, + { + "slug": "watch-a-coding-agent-remotely-without-shrinking-its-terminal-shell-online-v0-23", + "title": "Watch a Coding Agent Remotely Without Shrinking Its Terminal: shell.online v0.23.2 Gives the Host Its Grid and Each Viewer Its Own Layout", + "description": "How to watch a coding agent remotely from a phone now that shell.online v0.23.2 lets the host own its terminal grid and each browser viewer reflow its own view.", + "category": "Engineering", + "tags": [ + "shell-online", + "terminal-sharing", + "claude-code", + "coding-agents", + "mobile" + ], + "iso_date": "2026-09-27", + "author": "Artemii Amelin", + "banner": "banners/watch-a-coding-agent-remotely-without-shrinking-its-terminal-shell-online-v0-23.svg", + "canonical": "https://pilotprotocol.network/blog/watch-a-coding-agent-remotely-without-shrinking-its-terminal-shell-online-v0-23" + }, + { + "slug": "self-hosted-terminal-sharing-run-the-shell-online-relay-on-one-docker-host-inste", + "title": "Self-Hosted Terminal Sharing: Run the shell.online Relay on One Docker Host Instead of Cloudflare", + "description": "Cloudflare had a Durable Objects incident on 25 September. Here is self-hosted terminal sharing with the standalone shell.online relay, step by step.", + "category": "Operations", + "tags": [ + "shell-online", + "self-hosting", + "docker", + "cloudflare", + "terminal-sharing" + ], + "iso_date": "2026-09-26", + "author": "Artemii Amelin", + "banner": "banners/self-hosted-terminal-sharing-run-the-shell-online-relay-on-one-docker-host-inste.svg", + "canonical": "https://pilotprotocol.network/blog/self-hosted-terminal-sharing-run-the-shell-online-relay-on-one-docker-host-inste" + }, + { + "slug": "start-a-terminal-session-from-the-browser-on-a-mac-that-sat-idle-for-days-the-la", + "title": "Start a Terminal Session From the Browser on a Mac That Sat Idle for Days: the launchd Priority Fix in shell.online v0.23.3", + "description": "Why browser-started sessions on an idle Mac lagged for seconds, and what v0.23.3 changes when you start a terminal session from the browser.", + "category": "Engineering", + "tags": [ + "shell-online", + "macos", + "launchd", + "remote-start", + "coding-agents" + ], + "iso_date": "2026-09-25", + "author": "Artemii Amelin", + "banner": "banners/start-a-terminal-session-from-the-browser-on-a-mac-that-sat-idle-for-days-the-la.svg", + "canonical": "https://pilotprotocol.network/blog/start-a-terminal-session-from-the-browser-on-a-mac-that-sat-idle-for-days-the-la" + }, + { + "slug": "watch-claude-code-from-your-phone-as-a-conversation-not-an-80-column-grid", + "title": "Watch Claude Code From Your Phone as a Conversation, Not an 80-Column Grid", + "description": "How to watch Claude Code from your phone with the shell.online chat renderer, what it reads from the screen, and where the transcript is kept.", + "category": "Engineering", + "tags": [ + "shell-online", + "claude-code", + "coding-agents", + "mobile", + "end-to-end-encryption" + ], + "iso_date": "2026-09-24", + "author": "Artemii Amelin", + "banner": "banners/watch-claude-code-from-your-phone-as-a-conversation-not-an-80-column-grid.svg", + "canonical": "https://pilotprotocol.network/blog/watch-claude-code-from-your-phone-as-a-conversation-not-an-80-column-grid" + }, + { + "slug": "share-a-terminal-session-password-with-a-teammate-without-the-service-seeing-it", + "title": "Share a Terminal Session Password With a Teammate Without the Service Seeing It: shell.online v0.23.1 Password Requests", + "description": "shell.online v0.23.1 lets a teammate ask a session owner for its password; the copy is sealed in the owner's browser and the service never holds it.", + "category": "Security", + "tags": [ + "shell-online", + "terminal-sharing", + "end-to-end-encryption", + "coding-agents", + "team-collaboration" + ], + "iso_date": "2026-09-23", + "author": "Artemii Amelin", + "banner": "banners/share-a-terminal-session-password-with-a-teammate-without-the-service-seeing-it.svg", + "canonical": "https://pilotprotocol.network/blog/share-a-terminal-session-password-with-a-teammate-without-the-service-seeing-it" + }, + { + "slug": "which-agent-session-needs-you-shell-online-v0-22-adds-a-passive-pulse-to-monitor", + "title": "Which Agent Session Needs You? shell.online v0.22 Adds a Passive Pulse to Monitor a Coding Agent in the Browser", + "description": "shell.online v0.22.0 adds a passive session pulse to monitor a coding agent in the browser across tabs, with no model calls, prompts, or extra connections.", + "category": "Engineering", + "tags": [ + "shell-online", + "coding-agents", + "terminal-sharing", + "observability", + "claude-code" + ], + "iso_date": "2026-09-22", + "author": "Artemii Amelin", + "banner": "banners/which-agent-session-needs-you-shell-online-v0-22-adds-a-passive-pulse-to-monitor.svg", + "canonical": "https://pilotprotocol.network/blog/which-agent-session-needs-you-shell-online-v0-22-adds-a-passive-pulse-to-monitor" + }, + { + "slug": "mcp-terminal-access-for-an-ai-agent-scoped-grants-that-expire-and-revoke", + "title": "MCP Terminal Access for an AI Agent: Scoped Grants That Expire and Revoke", + "description": "How shell.online v0.21 gives an AI agent MCP terminal access through scoped, expiring, revocable grants, and what that costs you in end-to-end encryption.", + "category": "Security", + "tags": [ + "shell-online", + "mcp", + "ai-agents", + "terminal-sharing", + "security" + ], + "iso_date": "2026-09-21", + "author": "Artemii Amelin", + "banner": "banners/mcp-terminal-access-for-an-ai-agent-scoped-grants-that-expire-and-revoke.svg", + "canonical": "https://pilotprotocol.network/blog/mcp-terminal-access-for-an-ai-agent-scoped-grants-that-expire-and-revoke" + }, + { + "slug": "a-read-only-terminal-link-for-your-coding-agent-after-plugin4shell", + "title": "A Read-Only Terminal Link for Your Coding Agent, After Plugin4Shell", + "description": "Plugin4Shell showed how much a coding agent can reach. A read-only terminal link lets a reviewer watch that agent without holding a credential that types.", + "category": "Security", + "tags": [ + "shell-online", + "read-only-terminal", + "coding-agents", + "plugin4shell", + "terminal-sharing" + ], + "iso_date": "2026-09-20", + "author": "Artemii Amelin", + "banner": "banners/a-read-only-terminal-link-for-your-coding-agent-after-plugin4shell.svg", + "canonical": "https://pilotprotocol.network/blog/a-read-only-terminal-link-for-your-coding-agent-after-plugin4shell" + }, + { + "slug": "how-to-revoke-a-shared-terminal-link-without-killing-the-process-behind-it", + "title": "How to Revoke a Shared Terminal Link Without Killing the Process Behind It", + "description": "Revoke a shared terminal link with one command: shell password rotate swaps the key, drops viewers, and leaves the process running.", + "category": "Security", + "tags": [ + "shell-online", + "terminal-sharing", + "credential-rotation", + "security", + "developer-tools" + ], + "iso_date": "2026-09-19", + "author": "Artemii Amelin", + "banner": "banners/how-to-revoke-a-shared-terminal-link-without-killing-the-process-behind-it.svg", + "canonical": "https://pilotprotocol.network/blog/how-to-revoke-a-shared-terminal-link-without-killing-the-process-behind-it" + }, + { + "slug": "end-to-end-encrypted-terminal-sharing-what-the-relay-sees-and-what-it-can-no-lon", + "title": "End-to-End Encrypted Terminal Sharing: What the Relay Sees, and What It Can No Longer Replay", + "description": "What end-to-end encrypted terminal sharing in shell.online hides from the relay, what it leaks, and how envelope v2 stops replayed keystrokes.", + "category": "Security", + "tags": [ + "shell-online", + "end-to-end-encryption", + "terminal-sharing", + "security", + "coding-agents" + ], + "iso_date": "2026-09-18", + "author": "Artemii Amelin", + "banner": "banners/end-to-end-encrypted-terminal-sharing-what-the-relay-sees-and-what-it-can-no-lon.svg", + "canonical": "https://pilotprotocol.network/blog/end-to-end-encrypted-terminal-sharing-what-the-relay-sees-and-what-it-can-no-lon" + }, + { + "slug": "end-to-end-encrypted-terminal-sharing-what-the-relay-can-still-do-after-shell-on", + "title": "End-to-End Encrypted Terminal Sharing: What the Relay Can Still Do After shell.online v0.16.1", + "description": "What end-to-end encrypted terminal sharing hides from a relay, what it never hid, and how shell.online v0.16.1 stops replayed keystrokes.", + "category": "Security", + "tags": [ + "shell-online", + "end-to-end-encryption", + "terminal-sharing", + "aes-gcm", + "replay-protection" + ], + "iso_date": "2026-09-17", + "author": "Artemii Amelin", + "banner": "banners/end-to-end-encrypted-terminal-sharing-what-the-relay-can-still-do-after-shell-on.svg", + "canonical": "https://pilotprotocol.network/blog/end-to-end-encrypted-terminal-sharing-what-the-relay-can-still-do-after-shell-on" + }, + { + "slug": "track-terminal-sessions-across-machines-by-name-shell-online-v0-16-adds-name-and", + "title": "Track Terminal Sessions Across Machines by Name: shell.online v0.16 Adds --name and shell ls", + "description": "shell.online v0.16.0 lets you name a session at start and list terminal sessions across machines with shell ls, as agents move into remote sandboxes.", + "category": "Engineering", + "tags": [ + "shell-online", + "terminal-sharing", + "coding-agents", + "cli", + "remote-development" + ], + "iso_date": "2026-09-16", + "author": "Artemii Amelin", + "banner": "banners/track-terminal-sessions-across-machines-by-name-shell-online-v0-16-adds-name-and.svg", + "canonical": "https://pilotprotocol.network/blog/track-terminal-sessions-across-machines-by-name-shell-online-v0-16-adds-name-and" + }, + { + "slug": "a-browser-terminal-on-a-ros-2-robot-without-a-vpn-and-why-it-should-be-read-only", + "title": "A Browser Terminal on a ROS 2 Robot Without a VPN, and Why It Should Be Read-Only by Default", + "description": "How to get a browser terminal on a ROS 2 robot with shell.online: one command, no VPN, read-only links, reconnects, and persistent URLs across reboots.", + "category": "Guide", + "tags": [ + "shell-online", + "ros2", + "robotics", + "remote-access", + "terminal-sharing" + ], + "iso_date": "2026-09-15", + "author": "Artemii Amelin", + "banner": "banners/a-browser-terminal-on-a-ros-2-robot-without-a-vpn-and-why-it-should-be-read-only.svg", + "canonical": "https://pilotprotocol.network/blog/a-browser-terminal-on-a-ros-2-robot-without-a-vpn-and-why-it-should-be-read-only" + }, + { + "slug": "connect-an-ai-agent-to-a-live-terminal-without-giving-it-the-machine-shell-onlin", + "title": "Connect an AI Agent to a Live Terminal Without Giving It the Machine: shell.online v0.15 Agent Handoffs", + "description": "How to connect an AI agent to a live terminal with shell.online v0.15: a revocable invitation, explicit completion evidence, and a process that stays yours.", + "category": "Engineering", + "tags": [ + "shell-online", + "ai-agents", + "terminal", + "refstream", + "openai" + ], + "iso_date": "2026-09-14", + "author": "Artemii Amelin", + "banner": "banners/connect-an-ai-agent-to-a-live-terminal-without-giving-it-the-machine-shell-onlin.svg", + "canonical": "https://pilotprotocol.network/blog/connect-an-ai-agent-to-a-live-terminal-without-giving-it-the-machine-shell-onlin" + }, + { + "slug": "share-a-terminal-session-in-the-browser-without-handing-over-the-filesystem-shel", + "title": "Share a Terminal Session in the Browser Without Handing Over the Filesystem: shell.online v0.14 File Access", + "description": "shell.online v0.14 adds opt-in, rooted file access, so you can share a terminal session in the browser and open only the files you chose.", + "category": "Security", + "tags": [ + "shell-online", + "terminal-sharing", + "coding-agents", + "sandbox", + "end-to-end-encryption" + ], + "iso_date": "2026-09-13", + "author": "Artemii Amelin", + "banner": "banners/share-a-terminal-session-in-the-browser-without-handing-over-the-filesystem-shel.svg", + "canonical": "https://pilotprotocol.network/blog/share-a-terminal-session-in-the-browser-without-handing-over-the-filesystem-shel" + }, + { + "slug": "self-hosted-terminal-sharing-on-one-docker-box-the-shell-online-relay-without-a", + "title": "Self-Hosted Terminal Sharing on One Docker Box: the shell.online Relay Without a Cloudflare Account", + "description": "shell.online v0.12.2 adds self-hosted terminal sharing: a standalone relay on any Docker host, Caddy TLS, no Cloudflare account, same E2EE.", + "category": "Operations", + "tags": [ + "shell-online", + "self-hosting", + "docker", + "terminal-sharing", + "end-to-end-encryption" + ], + "iso_date": "2026-09-12", + "author": "Artemii Amelin", + "banner": "banners/self-hosted-terminal-sharing-on-one-docker-box-the-shell-online-relay-without-a.svg", + "canonical": "https://pilotprotocol.network/blog/self-hosted-terminal-sharing-on-one-docker-box-the-shell-online-relay-without-a" + }, + { + "slug": "a-read-only-terminal-link-for-a-training-run-you-check-from-your-phone", + "title": "A Read-Only Terminal Link for a Training Run You Check From Your Phone", + "description": "Share a training run with a read-only terminal link: one command, a phone-sized grid, relay-enforced no-input, E2EE, and what happens when the network drops.", + "category": "Guide", + "tags": [ + "shell-online", + "read-only-terminal", + "training-runs", + "remote-monitoring", + "mobile-terminal" + ], + "iso_date": "2026-09-11", + "author": "Artemii Amelin", + "banner": "banners/a-read-only-terminal-link-for-a-training-run-you-check-from-your-phone.svg", + "canonical": "https://pilotprotocol.network/blog/a-read-only-terminal-link-for-a-training-run-you-check-from-your-phone" + }, + { + "slug": "vs-code-1-137-runs-agent-sessions-in-their-own-process-terminal-sharing-without", + "title": "VS Code 1.137 Runs Agent Sessions in Their Own Process. Terminal Sharing Without SSH or VPN Does That for a Failing Build", + "description": "How to pair-debug a failing build with terminal sharing without SSH or VPN, and how that compares to the Agent Host in VS Code 1.137.", + "category": "Guide", + "tags": [ + "shell-online", + "terminal-sharing", + "vscode", + "pair-debugging", + "ci" + ], + "iso_date": "2026-09-10", + "author": "Artemii Amelin", + "banner": "banners/vs-code-1-137-runs-agent-sessions-in-their-own-process-terminal-sharing-without.svg", + "canonical": "https://pilotprotocol.network/blog/vs-code-1-137-runs-agent-sessions-in-their-own-process-terminal-sharing-without" + }, + { + "slug": "team-terminal-sharing-sealed-so-the-service-cant-open-it", + "title": "Team Terminal Sharing, Sealed So the Service Can't Open It", + "description": "Share terminal sessions with your team on shell.online: organizations, handoffs, remote start, and session passwords the accounts service cannot read.", + "category": "Security", + "tags": [ + "shell-online", + "terminal-sharing", + "end-to-end-encryption", + "oauth", + "team-collaboration" + ], + "iso_date": "2026-09-09", + "author": "Artemii Amelin", + "banner": "banners/team-terminal-sharing-sealed-so-the-service-cant-open-it.svg", + "canonical": "https://pilotprotocol.network/blog/team-terminal-sharing-sealed-so-the-service-cant-open-it" + }, + { + "slug": "how-to-watch-a-coding-agent-remotely-after-you-turn-off-its-permission-prompts", + "title": "How to Watch a Coding Agent Remotely After You Turn Off Its Permission Prompts", + "description": "Claude Code 2.1.259 runs headless with no prompts. Here is how to watch a coding agent remotely from a phone with a read-only, end-to-end encrypted link.", + "category": "Guide", + "tags": [ + "shell-online", + "claude-code", + "coding-agents", + "read-only-terminal", + "remote-monitoring" + ], + "iso_date": "2026-09-08", + "author": "Artemii Amelin", + "banner": "banners/how-to-watch-a-coding-agent-remotely-after-you-turn-off-its-permission-prompts.svg", + "canonical": "https://pilotprotocol.network/blog/how-to-watch-a-coding-agent-remotely-after-you-turn-off-its-permission-prompts" + }, + { + "slug": "shell-online-live-terminal-browser-link", + "title": "shell.online: A Live Browser Link for Any Terminal", + "description": "shell.online turns a running terminal command into a browser link you can watch or type into from any device. Open source, built by Pilot Protocol.", + "category": "Blog", + "tags": [ + "shell-online", + "terminals", + "agents", + "open-source" + ], + "iso_date": "2026-09-02", + "banner": "banners/shell-online-live-terminal-browser-link.svg", + "canonical": "https://pilotprotocol.network/blog/shell-online-live-terminal-browser-link" + } +] diff --git a/blog/posts/a-browser-terminal-on-a-ros-2-robot-without-a-vpn-and-why-it-should-be-read-only.html b/blog/posts/a-browser-terminal-on-a-ros-2-robot-without-a-vpn-and-why-it-should-be-read-only.html new file mode 100644 index 0000000..849cfe8 --- /dev/null +++ b/blog/posts/a-browser-terminal-on-a-ros-2-robot-without-a-vpn-and-why-it-should-be-read-only.html @@ -0,0 +1,65 @@ +

Agility Robotics unveiled Digit 5 today, and the part of the announcement that matters to anyone who runs robots for a living is not the 50 lb payload. It is the independent safety controller that "oversees Digit's response to the detection of people within an unsafe distance," and the Agility Arc fleet platform that shows operators uptime, throughput, and mean time between incidents. Fleet-scale humanoids get fleet-scale tooling. The ROS 2 robot on your bench gets whatever you can reach it with. This post is about getting a browser terminal on a ROS 2 robot with shell.online: one command on the robot, one link on your phone, no VPN, and a clear account of what that link does and does not expose.

+ +

The timing is not only about Digit. The ROS 2 release team shipped Humble Hawksbill Patch Release 15 yesterday, and the current LTS, Lyrical Luth, has been out since May with Ubuntu 26.04 as its Tier 1 platform. Plenty of fleets are mid-migration between the two, and somebody has to look at the actual terminal on the actual robot when a launch file behaves differently than it did in the sim.

+ +
+

Why ROS needs no adapter

+

shell.online transports terminal bytes. It does not know what ROS is, and it does not need to. The platforms page says it directly: source the ROS environment first, then wrap roscore, roslaunch, ros2 run, ros2 launch, colcon build, or a node with the normal shell command. No bridge node, no DDS discovery across a NAT. The PTY runs on the robot, and the browser sees what a person at the robot's console would see.

+ +

The one caveat, also from that page: ROS itself supports fewer OS and architecture pairs than the CLI does. Sharing a terminal does not make an unsupported ROS combination work.

+
+ +
+

Putting a browser terminal on a ROS 2 robot

+

Install the CLI on the robot's compute. On Linux, including ARM64 boards, the installer is one line and verifies the SHA-256 checksum of the binary it downloads:

+
curl -fsSL https://shell.online/install | sh
+

Then source your workspace and wrap the launch exactly as you would run it locally:

+
source /opt/ros/lyrical/setup.bash
+source ~/ros2_ws/install/setup.bash
+shell ros2 launch my_robot_bringup bringup.launch.py
+

The CLI prints a URL, a generated password, and a QR code. Scan the QR with a phone and the same terminal opens in the browser with the launch output streaming. The encryption page explains what the QR carries: the printed URL holds a random salt fragment, the QR adds the password to that fragment, and URL fragments never reach the relay. The CLI and the browser derive the same AES-256-GCM key locally with PBKDF2-HMAC-SHA256 at 600,000 iterations. Frames are encrypted on the robot before they go anywhere.

+ +

The launch runs in the background on the robot. If a colleague is standing next to it with a keyboard, they can take over locally without disconnecting the browser viewers:

+
shell list
+shell attach <session-id>
+

Ctrl-X then D detaches and leaves the process running. Local input and the resulting output stay visible to everyone in a browser, so nobody is typing into a terminal the other person cannot see.

+
+ +
+

What a phone can and cannot do to a robot

+

This is the part to read before you paste the link into a group chat. The URL and password together are a bearer credential. The security model calls it a bearer capability: anyone holding both can open the share, and on an interactive share they can type with the permissions of the wrapped process. On a robot, the wrapped process is usually running as a user that can publish to cmd_vel, restart nodes, or call an e-stop service. A terminal on a robot is a control surface, and the link is the key to it.

+ +

Digit 5 solves the physical version of this with hardware: an independent safety controller that acts regardless of what the main software is doing. The equivalent on the terminal side is deciding, before you share, whether anyone on the far end should be able to type at all. For a robot that a support engineer only needs to observe, share the launch read-only:

+
shell --read-only ros2 launch my_robot_bringup bringup.launch.py
+

Read-only is enforced at the relay, not in the browser. Per the security page, ordinary browser input and confirmed EOF are rejected by the Worker, and DevTools or handcrafted WebSocket frames cannot turn the link into an interactive session. That matters when the process is a launch file holding a motor controller.

+ +

An interactive shell.online link on a robot lets anyone who holds the URL and password send keystrokes with the permissions of the process you wrapped. If the goal is to let someone watch a bringup, a colcon build, or a bag replay, share it read-only. Rotate the password with shell password rotate if a link went further than intended; rotation disconnects current viewers without restarting the process.

+ +

One more behavior worth knowing: phone viewers change the terminal grid. The mobile page documents it. A desktop-only session uses a 120 by 36 grid, and the moment a phone connects, everyone switches to an 80 by 24 compatibility grid. Read-only phones count too. Wide output or a TUI will reflow when someone opens the link from a phone.

+
+ +
+

Network loss, restarts, and links that survive them

+

Robots lose Wi-Fi. They roam between access points, drive into the metal corner of a warehouse, or reboot when a battery swap goes badly. The reliability page is specific about what happens: the CLI and browser reconnect with bounded backoff, a temporary network failure does not stop the process, and the same share link remains recoverable for 12 hours after its host disconnects. A browser tab left open on your desk reconnects when the robot's compute comes back into range.

+ +

A reboot is different, because the process itself is gone. An ordinary share closes when its task exits, and rerunning the launch gives you a new URL and password. For a robot that you want reachable at one stable address across restarts, the CLI has a persistent mode:

+
shell --persistent /var/lib/robot/shell-state -- ros2 launch my_robot_bringup bringup.launch.py
+

The state file stores an owner-only host identity, the password, and the encryption material. Rerunning with that same file restores the same URL and password. Put that command in the robot's startup script and the link you saved last week still works after a power cycle. The shell.online source ships the same mechanism in its published Docker image, where a named volume plus restart: unless-stopped keeps one encrypted link across container restarts. Two edges: the persistent flag cannot be combined with the no-e2ee flag, and shell kill stops the process without deleting the identity file, so the next run reuses it.

+ +

For a session that should not live all afternoon, such as a bag replay for a bug report, add an earlier deadline:

+
shell --read-only --auto-close 30m ros2 bag play /data/incident_0915
+

The auto-close value can only bring the deadline forward. The share still closes the moment the wrapped process exits, whichever comes first.

+
+ +
+

Letting an agent on the robot hand you the link

+

If a coding agent runs on the robot's compute, bisecting a launch-file regression after a Humble patch or babysitting a colcon build, it can start the share itself and hand the operator the link without interactive setup, using the JSON mode in the CLI reference:

+
shell --read-only --json -- colcon build --symlink-install
+

The first JSON event carries the share URL, the password, the session ID, and flags for read-only and encrypted. The installable agent skill spells out the rules: preserve the full URL including its salt fragment, treat URL and password as a bearer secret, prefer read-only when the operator only needs to monitor, and never create a new session just to refresh a link. On a robot, where a stray keystroke has physical consequences, read-only by default is the right instinct for an agent.

+ +

None of this changes where the process runs. The launch, the build, the bag replay all stay on the robot's compute with its own environment sourced. The relay sees connection metadata, encrypted frame sizes, timing, labels, and lifecycle events, and nothing of the terminal content. That is the split we described when we first wrote up a live browser link for any terminal, and a robot is where it earns its keep.

+ +

Digit 5 is expected to reach early access in the first half of 2027. Most ROS robots today are not humanoids with a fleet dashboard. They are a compute box, a launch file, and a person who needs to see what it printed, from wherever they are, without a VPN and without handing a control surface to everyone who has the link.

+
+ +

Put a browser link on your robot's terminal

Source your ROS environment, wrap the launch with one command, and open the same terminal from a phone. End-to-end encrypted by default, read-only when you say so, and the process never leaves the robot.

Try shell.online
diff --git a/blog/posts/a-read-only-terminal-link-for-a-training-run-you-check-from-your-phone.html b/blog/posts/a-read-only-terminal-link-for-a-training-run-you-check-from-your-phone.html new file mode 100644 index 0000000..29c5900 --- /dev/null +++ b/blog/posts/a-read-only-terminal-link-for-a-training-run-you-check-from-your-phone.html @@ -0,0 +1,44 @@ +

Hugging Face shipped TRL v1.13.0 on September 10 with a guide that trains one 1,048,576-token sequence per step on a single 8×H100 node. Qwen3-8B, 380 seconds a step, 56.2 GB per GPU, twelve steps. That is a run you start, walk away from, and want to glance at from the couch or the train. A read-only terminal link is the least-privilege way to do that: the process stays on the training box, nobody gets a shell, and the phone shows the same tqdm bar the launching terminal shows. This post walks through the exact command with shell.online, what the viewer sees, and what the link does and does not grant.

+ +
+

The command, and what it prints

+

The guide launches its example with accelerate. Prefix that launch with the CLI from shell.online and add the read-only flag before the command. The double dash ends option parsing so accelerate's own flags are never mistaken for ours:

+
shell --read-only -- accelerate launch \\
+    --config_file examples/sft_qwen3_8b_1m_context/context_parallel_8gpu.yaml \\
+    examples/sft_qwen3_8b_1m_context/sft_qwen3_8b_1m_context.py
+

The wrapped command runs in the background inside a local PTY. The URL prints only after the relay connection exists and the task has survived a short startup handshake, so a launch that dies on a bad config path prints its real exit status and no dead link. In an interactive terminal you also get a QR code whose fragment carries the salted URL and the password together, so a phone opens it with one scan. Piped and JSON output stay machine-clean.

+

The launching terminal is now free. Use shell list to see the process uptime and the relay's independent online, reconnecting, expired, or unknown state; unknown means the check was inconclusive, not that the link died. To take over locally, shell attach with the session ID puts you back in the PTY without disconnecting any browser, and Ctrl-X then D detaches again. The CLI reference lists every option and the exit codes.

+

The guide notes that the first step lands after ten minutes or so of loading and tokenizing. A viewer who opens the link during that window sees the accelerate startup output and then the progress bar updating in place, because the process is writing to a real PTY and tqdm behaves exactly as it does locally.

+
+ +
+

What a read-only terminal link shows on a phone

+

Viewers never own PTY sizing. A session with only desktop browsers attached runs on a 120×36 grid. The moment a phone or portrait viewer connects, the relay switches everyone to an 80-column grid and each browser fits that grid to its own screen; the last portrait viewer leaving restores 120×36. Read-only phones count for this too. The mobile behavior page describes the 80×24 compatibility grid; as of v0.11.2 the CLI advertises support for a taller 80×40 grid in its host handshake header, the relay picks 80×40 when the host supports it, and older CLIs still get 80×24. The negotiation lives in pull request #100, which also added two-finger pinch zoom on the terminal alone, from 50% to 150%, without disturbing anyone else's view.

+

For a training run, 80 columns matters. The progress line in the guide is short enough to survive the switch unwrapped:

+
{'loss': 4.311, 'grad_norm': 29.25, 'num_tokens': 1049000.0, 'epoch': 0.25}
+  8%|▊ | 1/12 [06:20<1:09:41, 380.10s/it]
+

Single-finger gestures scroll terminal history. If output outruns the network or the phone's rendering, stale display work is dropped and sends one authoritative screen snapshot from a bounded local ring buffer, so a returning viewer sees the current screen rather than a replay of every intermediate redraw. The reliability page covers the bounded queues and write timeouts that keep a high-output process from ever blocking on Cloudflare.

+
+ +
+

Read-only is enforced by the relay, not the browser

+

With the read-only flag, ordinary browser input and the confirmed Ctrl-D EOF are rejected by the Worker. Opening DevTools or hand-crafting WebSocket frames cannot turn the link into an interactive session, and the access mode cannot be weakened after creation. That is the property you want when the link is going to sit in a group chat for an hour: the worst a holder can do is watch.

+

Watching is still access. The URL and the eight-character password together are a bearer credential. Anyone holding both can read everything the terminal prints, which for a training run means dataset names, checkpoint paths, loss values, and any secret a careless script echoes. Treat the pair like a token, and rotate by starting a new share.

+

What the relay sees is the other half of the trust model. Terminal payloads are encrypted on the training box with AES-256-GCM before they enter Cloudflare and decrypted in the browser. The printed URL carries only a random salt in its fragment, which never reaches the server, and the CLI and browser derive the same key locally with PBKDF2-HMAC-SHA256 at 600,000 iterations, the key derivation function specified in RFC 8018. Cloudflare still observes connection IPs, timing, encrypted frame sizes, opcodes, access mode, the command label, and lifecycle events, and it does not persist terminal contents. The end-to-end encryption page is explicit that E2EE cannot hide traffic shape or stop the relay from dropping or delaying a valid frame.

+

Eight generated base64url characters give 48 bits of entropy, which is fine for a link that lives as long as one task. For a multi-day run, or one whose logs you would not want indexed, set SHELL_ONLINE_E2EE_PASSWORD to a longer unique value before launching and send it separately from the URL. Derivation still happens on the machine. The security model page lays out the full boundary, including the point that an interactive share types with the wrapped process's operating-system permissions, which is exactly why this workflow uses the read-only mode.

+
+ +
+

Network loss, deadlines, and the end of the run

+

The link can disappear. The process should not. The CLI and browser reconnect with bounded backoff, a temporary network failure never stops the local task, and the same ordinary share link remains recoverable for 12 hours after its host disconnects. A phone that loses signal in a tunnel comes back to the current screen, because the relay keeps the session and the CLI keeps the process.

+

Lifetime is bounded by the task. When accelerate exits, the share closes and its server-side state is deleted; there is nothing to clean up. If you want the link to expire before the run does, add an earlier deadline. Relative units and local times both work:

+
shell --read-only --auto-close 2h -- accelerate launch ...
+shell --read-only --auto-close "tomorrow 09:00" -- accelerate launch ...
+

An auto-close value can only shorten the lifetime, never extend it past task exit, and a missing or invalid value returns status 2 instead of becoming part of the command. Since v0.11.0 long deadlines are honored rather than silently clipped to the relay's rolling 12-hour lease, which matters for exactly this kind of overnight job.

+

Two more cases come up with training runs specifically. First, the guide's run reaches 63.6 GB on an 80 GB card and still fails without the expandable-segments allocator setting. When a run dies like that during the handshake, the CLI reports the status and prints no URL, so a monitoring link never points at a corpse. Second, when you resume from a checkpoint after a crash, the persistent mode keeps the same URL and password across relaunches:

+
shell --read-only --persistent ~/.shell-train-state -- accelerate launch ...
+

The state file stores an owner-only host identity, password, and E2EE material. Whoever already has the link opens the resumed run without a new credential exchange, and shell kill stops the current process without deleting the identity file. The source repository documents this under platforms and in the CLI help, alongside the Docker image that combines the same mechanism with an automatic restart policy.

+

None of this needs an account. Linking a machine with shell login is optional and publishes only the share URL, command name, host name, and timing to the web app, never terminal contents or the password. For a solo training run watched from a phone, the plain command above is the whole setup. The broader idea, a browser link to any terminal process, is covered in the introduction to shell.online.

+
+ +

Watch the run, not the shell

One command wraps a training launch in a read-only, end-to-end encrypted link that opens on any phone and closes itself when the job ends.

Try shell.online
diff --git a/blog/posts/a-read-only-terminal-link-for-your-coding-agent-after-plugin4shell.html b/blog/posts/a-read-only-terminal-link-for-your-coding-agent-after-plugin4shell.html new file mode 100644 index 0000000..2b56da1 --- /dev/null +++ b/blog/posts/a-read-only-terminal-link-for-your-coding-agent-after-plugin4shell.html @@ -0,0 +1 @@ +

On September 17, researchers at Air published Plugin4Shell, a remote code execution flaw in the plugin systems of Claude Code, Codex, GitHub Copilot, and Gemini CLI. The practical fallout this week is a lot of people looking over a lot of shoulders: security teams checking agent versions, leads asking what plugins a given laptop has installed, teammates watching an agent run to see what it touches. Most of those observers need eyes and nothing else. A read-only terminal link is the smallest grant that gives them that, and this post walks through how shell.online builds one, where the restriction is enforced, and what a viewer still gets to see.

What Plugin4Shell says about how far an agent reaches

The bug is in SHA pinning, the mechanism that is supposed to lock an installed plugin to one reviewed commit. In Air's words, the agent "checks out the exact commit the marketplace pinned but never verifies it landed there." An attacker who controls the plugin's repository creates a branch named like the pinned hash, the checkout resolves to the branch, and the pin still looks honored. GitHub refuses branch names that resemble commit SHAs, but The Register's coverage carries the researchers' answer to that: marketplaces can also live on Bitbucket or self-hosted git servers, which do not.

The zero-click part comes from auto-update. Per Help Net Security's writeup, the same checkout re-runs on background auto-update, which is the default in Claude Code and Codex, so a swapped commit reaches plugins that are already installed with no user action. Anthropic fixed Claude Code in 2.1.179 and OpenAI fixed Codex in 0.146.0. Air reports that Microsoft has not shipped a fix for Copilot, and that Google deprecated Gemini CLI instead of patching it.

The sentence worth keeping from the Air post is the impact statement: full compromise of the agent and its host, "and with it full access to every asset and every piece of data the agent can reach." A coding agent runs as the developer. It holds their SSH keys, their cloud credentials, their checkout of the monorepo. Anything that can put keystrokes into that process inherits the same reach.

To be plain about scope: a read-only link does nothing against Plugin4Shell itself. The fix for that is updating the agent. The connection is narrower. Once you accept that the agent process is one of the most privileged things on a laptop, every attachment to it deserves the same question a plugin does: what can this thing do if the wrong party ends up holding it?

Creating a read-only terminal link for an agent session

A normal share on shell.online, which turns any terminal process into a browser link, is interactive. The command prints a URL, a password, and a QR code, and anyone holding the URL and password can view and type. Adding one flag before the command changes the grant:

shell --read-only claude

The agent starts in a local PTY on your machine and keeps running in the background. The session summary the CLI prints has an Access line, and for this session it reads "view only" next to "end-to-end encrypted". The caption under the QR code changes too. An interactive session warns that anyone who scans it can view and type in the session; a read-only one says they can view it. The same access mode shows up in the access column of shell list, and the browser labels the terminal as read-only so the viewer knows why their keyboard does nothing.

For a review with a natural end, give it one. A name helps whoever is looking at a list of sessions later:

shell --read-only --name "plugin audit" --auto-close 30m codex

The session always closes when the wrapped process exits. The auto-close value only adds an earlier deadline, and it accepts combined units like 1h30m or a date. A missing or invalid value exits with status 2 and never gets run as the command, which matters when the thing being wrapped is an agent with your permissions.

You still work in the session yourself. The flag blocks browser input, and shell attach with the session ID is a local PTY attach on the host machine, so the operator keeps typing from their own terminal while everyone on the link watches. Ctrl-X then D detaches and leaves both the process and the share running.

Where the input is rejected

A view-only mode that lives in the web page is a suggestion. Anyone can open DevTools, patch the client, or write WebSocket frames by hand. The security model page describes the enforcement as happening twice: ordinary browser input and confirmed EOF are rejected independently by the relay and by the CLI before they reach the PTY.

The relay check has been there since read-only links shipped in 0.4.0, where the changelog records access mode being stored as immutable session metadata and input being rejected inside the Worker. The CLI check is the one that runs on your own hardware, so it holds no matter what the hosted side does. In the open-source CLI it is a small function in cmd/shell/session_unix.go. After a frame is decrypted, its opcode goes through acceptsViewerInput, which returns true only when the session is not read-only and the opcode is Input or ConfirmedEOF. In a read-only session neither is ever written to the PTY. The EOF case is there because a Ctrl-D that ends the agent's shell is input as far as the wrapped process is concerned.

Access mode is also fixed at creation. The CLI reference states that it cannot be weakened after creation. Persistent sessions follow the same rule: if a state file was created read-only and you start it again without the flag, the CLI refuses with an error that the persistent state's access mode does not match the requested flags, instead of quietly reissuing the same URL with typing enabled. If a reviewer needs to type, start a separate interactive session and hand it over deliberately.

What a read-only viewer still gets

Read-only removes the keyboard. It removes nothing from the screen.

The URL and password together are a bearer credential. For an interactive share, anyone holding both can view and type with the operating-system permissions of the wrapped process. For a read-only share, anyone holding both can see everything the terminal prints, including any secret the agent happens to echo.

So the usual discipline applies: send the link and the password over different channels, and do not run a review session in a shell where tokens get printed. Terminal traffic is end-to-end encrypted by default, which keeps contents away from the relay, but the relay does see metadata, and the security page lists access mode and the command label among it.

Files are a separate capability with their own switch. If the host started the session with --files (or --files-root for a different directory), a read-only viewer can still browse and read the opted-in directory on demand. They cannot type, but they can read. For an audit link over an agent's working tree that may be exactly what you want. If it is not, leave the flag off, and no listing or file contents are available to any browser.

One smaller effect is easy to miss. Viewers never own the PTY size, but a connected phone moves the whole session to a portrait grid, and the mobile docs note that read-only phones count too. A reviewer opening the link on a handset will reflow the agent's TUI for everyone until they leave.

Network loss, leaked links, and ending the review

The process does not depend on the link. If the reviewer's connection drops, or the host's does, the CLI and browser reconnect with backoff and the returning viewer gets a screen snapshot from a bounded local buffer. An ordinary share stays recoverable for 12 hours after its host disconnects. The agent keeps working the whole time.

If the link ends up somewhere it should not be, rotate it:

shell password rotate <id>

That generates a new password and URL salt, disconnects current viewers, and leaves the wrapped process running. The old credential stops working even for viewing. When the review is over, shell kill with the session ID stops the process and the share together, or you let the agent exit and the session closes on its own. No terminal transcript is kept by the relay, so there is nothing to clean up on the hosted side afterwards. We covered the basic sharing flow in more detail in the introduction to live terminal links.

Plugin4Shell came down to a verification step that everyone assumed was happening and was not. The read-only flag is worth using for the opposite reason: the check sits in a few lines of Go that anyone can read, and it runs on the machine that owns the process.

Give reviewers a link that cannot type

Install the CLI, run shell --read-only in front of your agent command, and send the URL and password to whoever needs to watch. No SSH, VPN, or account required.

Try shell.online
diff --git a/blog/posts/connect-an-ai-agent-to-a-live-terminal-without-giving-it-the-machine-shell-onlin.html b/blog/posts/connect-an-ai-agent-to-a-live-terminal-without-giving-it-the-machine-shell-onlin.html new file mode 100644 index 0000000..9d0d475 --- /dev/null +++ b/blog/posts/connect-an-ai-agent-to-a-live-terminal-without-giving-it-the-machine-shell-onlin.html @@ -0,0 +1,57 @@ +

OpenAI opened its Agents API to public beta on September 10. The pitch is a managed Codex harness: you supply files, install packages, and add skills, in the announcement's words, "while we provision and manage the sandbox", with partner sandboxes from Cloudflare, E2B, Modal, and others if you would rather pick the container yourself. That is one answer to the question of where an agent should do its work. Today's shell.online release, v0.15.0, is built for the opposite arrangement. The terminal stays on your machine, with your files and your permissions, and you connect an AI agent to a live terminal through a single-use invitation that you can revoke at any moment without stopping the process.

+ +
+

What v0.15.0 and v0.15.1 changed

+

shell.online has always been one command that prints a URL and a password for a running process. The browser view of that process defaults to xterm.js. Since v0.14.0 there has been an optional second renderer, Refstream, which is still labelled an unstable alpha and which changes only the browser view, never the PTY, the relay permissions, or the encryption. Refstream is where the Connect agent button lives.

+

The v0.15.0 changelog lists two additions. First, reusable agent connections: a connected agent keeps its handle for follow-up requests, and task IDs, progress, and collected answers survive panel changes and reconnects. The README now says the agent connects once and follows up without pairing again. Second, tab-local session recovery for page reloads. The snapshot expires after four hours, disappears when the tab closes, and never goes to the relay. Recovery also still requires the local process to be alive, because a snapshot restores parser state and task records, not a shell.

+

v0.15.1, released the same morning, bumps the vendored renderer to Refstream v0.1.0-alpha.5 and fixes a real bug in that recovery path: a restored snapshot could answer historical terminal capability queries into the live process, which could corrupt tmux input. The README now states the rule directly: a restored browser snapshot cannot answer historical terminal queries into the live process.

+
+ +
+

How to connect an AI agent to a live terminal, step by step

+

Start with any process you would normally share. A coding agent is the obvious case, but a dev server or a plain shell works the same way.

+
shell claude
+# or
+shell npm run dev
+

The CLI prints the share URL, the password, and a QR code. Open the link in a browser, open Terminal controls, and switch the renderer to Refstream (unstable alpha). The choice is saved in that browser only. Then choose Connect agent and pick one of two grants, both documented in the CLI reference: Read terminal, which lets the agent read current output, search retained rows, inspect command boundaries, and wait for changes; or Read and control, which additionally lets it type, execute a command against a recent terminal sequence, and send key combinations such as Ctrl-C.

+

What you copy is not a password. It is a private invitation plus a standalone Node.js 22 or newer connector URL with a pinned SHA-256 checksum. The invitation is single-use and expires after five minutes. The agent verifies that file against the checksum, then you pass the invitation through its ordinary command tool, and the agent handoff protocol is explicit that it must arrive on stdin or through the connector's hidden prompt, never in command arguments, URLs, or logs.

+
node shell-agent.mjs connect
+# invitation pasted on stdin; returns a sessionId
+
+node shell-agent.mjs request SESSION_ID
+{"method":"ask","args":{"kind":"command","prompt":"pwd","taskId":"workdir-1","expectedSequence":12}}
+
+node shell-agent.mjs request SESSION_ID
+{"method":"wait_task","args":{"taskId":"workdir-1","afterRevision":1,"timeoutMs":15000}}
+
+node shell-agent.mjs request SESSION_ID
+{"method":"collect_task","args":{"taskId":"workdir-1"}}
+

The sessionId returned by connect is the handle the agent reuses for every request, for status, and for stop. Each handoff carries a stable task ID chosen by the agent. Retrying the same ID and prompt retrieves the existing task instead of running the command twice, and a new handoff is blocked until the previous answer is collected or explicitly cancelled. Collecting an answer leaves the connection open, which is the whole point of the v0.15.0 change.

+
+ +
+

Completion is evidence, not silence

+

The part of the protocol most worth reading is how a task becomes done. The statuses are waiting, needs_attention, completed, collected, and cancelled, and the protocol states plainly that "a sent receipt, output, silence, a redraw, and a background-job acknowledgement do not establish completion." Every final result records where its completion claim came from, and there are only three sources.

+ +

This matters when the wrapped process is an interactive TUI such as Claude Code rather than a shell that emits markers. A generic TUI cannot be treated as a structured conversation API. If the agent collects before there is completion evidence, the protocol stores an unconfirmed, truncated screen excerpt and leaves the task pending. If the shell emits no OSC 133 markers at all, the Commands view stays empty rather than guessing where a command ended.

+
+ +
+

What stops the agent from typing over you

+

A control grant is not a free keyboard. Every remote write requires an expectedSequence taken from a recent read, so a stale view of the screen is rejected. Local keystrokes are tracked before PTY echo, including IME composition, and any intervening human input removes the agent's ownership of a draft. Pressing Enter requires an agent-owned draft. No draft is ever cleared automatically. For an application whose composer state cannot be read from terminal cells, the agent must either wait, or the owner can inspect the input themselves and click the button labelled "I've checked: input is empty", which is a one-time acknowledgement that further input or output invalidates. Colors and cursor placement are display evidence, never permission to type.

+

Two boundaries hold regardless of the grant. Revoking access is immediate and does not send Ctrl-C or stop the shared process, so a runaway agent loses the terminal but your job keeps running. And the invitation grants terminal access only. Files stay unavailable unless the host separately started the share with the --files or --files-root flag, the opt-in described in the security model, and even then access is rooted, traversal and symlink escapes are rejected, and printing a filename never reads the disk.

+
+ +
+

Who can see what

+

The access rules underneath are unchanged. The share URL and password together are a bearer credential: anyone holding both can open the terminal, and unless the share was started with the read-only flag, can type with the permissions of the wrapped process. The agent invitation sits on top of that share, is scoped to this one live terminal, and cannot be replayed once used. The hosted grant lasts up to four hours.

+

The relay sees encrypted payloads, connection metadata, frame sizes, and timing, as it does for every shell.online session. Connector state on the agent's disk contains only its protected local IPC credentials, never terminal contents, answers, or the private invitation. Task history is bounded to the last 32 records with answers capped at 32,768 characters, and expired task IDs stay reserved across snapshots so an old retry cannot resubmit a command.

+

Refstream is an unstable alpha and is labelled that way in the renderer menu. It is not binary-compatible with the xterm.js addon ecosystem, and some private xterm behaviours are not implemented yet. If a TUI renders or handles input incorrectly, switch the renderer back to xterm.js. The process, relay connection, and permissions do not change when you switch.

+

The contrast with a hosted sandbox is not about which is safer in the abstract. It is about who holds the process. In a managed harness the provider runs the container and you get files, packages, and artifacts back. With a browser link to a terminal that never leaves your machine, you hold the process, the agent gets a scoped and revocable seat at it, and every claim of "done" carries a label saying how it was established. Both shell.online and the Refstream renderer are MIT licensed, so the grant model described here can be checked against the code rather than taken on trust. If you have not used the basic share before, the introduction to the live terminal link covers the one-command flow this builds on.

+
+ +

Give an agent a seat at your terminal, not your machine

One command shares the process. One invitation lets an agent in. One click revokes it, and the job keeps running.

Try shell.online
diff --git a/blog/posts/end-to-end-encrypted-terminal-sharing-has-a-second-secret-on-the-host-shell-onli.html b/blog/posts/end-to-end-encrypted-terminal-sharing-has-a-second-secret-on-the-host-shell-onli.html new file mode 100644 index 0000000..33b6a6d --- /dev/null +++ b/blog/posts/end-to-end-encrypted-terminal-sharing-has-a-second-secret-on-the-host-shell-onli.html @@ -0,0 +1,54 @@ +

End-to-end encrypted terminal sharing is usually explained from the relay's point of view: the server forwards ciphertext it cannot open, so your terminal output is safe in transit. That is true for shell.online, and it is only half the story. The key that opens a session is derived from an eight-character password, and that password has to live somewhere on the host machine for as long as the session runs. Today's shell.online v0.24.1 release is a security patch about exactly that: where the password sat on the host, which local programs could read it, and how it now travels between the CLI's own processes without touching an environment variable at all.

+ +

Last week GitGuardian published a breakdown of how AI coding agents leak credentials, and the pattern it describes is the one this release closes.

+ +
+

The credential a secret scanner never sees

+

GitGuardian's argument is that the leak surface for coding agents is the endpoint, not the repository. In their words, Cursor, Claude Code, and GitHub Copilot "store credentials across config files, env variables, logs, shell history, and temp files that repository and CI scanners never inspect." The same piece cites their State of Secrets Sprawl 2026 figures: 24,008 unique secrets found in public MCP configuration files, 2,117 of them still valid, and a 3.2 percent secret-leak rate in public commits assisted by Claude Code against a 1.5 percent baseline.

+ +

Environment variables are the interesting case because nobody thinks of them as a file. Earlier this year VentureBeat reported on a prompt injection, planted in a pull request title, that got three vendors' GitHub Actions coding agents to leak their own API keys, one of them by posting the key as a PR comment. The enabling detail was mundane: any secret stored as a workflow environment variable is readable by every step, the agent included. On a workstation the equivalent is even simpler. The Linux proc(5) manual page describes /proc/<pid>/environ as "the initial environment that was set when the currently executing program was started via execve(2)", readable by any process that passes a ptrace read check, which in practice means any process of the same user. The page also notes that if a program later modifies its environment, "this file will not reflect those changes." Unsetting a variable after startup buys you nothing.

+
+ +
+

What end-to-end encrypted terminal sharing actually hides

+

To see why this matters for a terminal link, it helps to know what the password does. When you run a shell.online session, the CLI prints a URL and a password. The URL carries a random salt in its fragment, and both the host and the browser run PBKDF2-HMAC-SHA256 over the password and that salt for 600,000 iterations to derive an AES-256-GCM frame key. The encryption details page lists what the relay still sees: timing, addresses, encrypted payload sizes, labels, lifecycle metadata, and the grid dimensions sent in plaintext control messages. Keystrokes, output, and snapshots are ciphertext to it. The iteration count is a constant in the e2ee package of the repo.

+ +

The consequence is that the password is the entire secret. The salt is public by design. Whoever holds the link and the password derives the same key the browser does, and unless the session was started with --read-only, they can type with the permissions of the wrapped process. So a copy of that password lying around on the host is not a minor hygiene problem. It is the one thing that turns the relay's ciphertext back into your terminal.

+
+ +

The link and the password together are a bearer credential. Anyone holding both can open the session, and unless it is read-only, type into it as the process that is running. Treat a leaked password like a leaked SSH key, and rotate it.

+ +
+

Where the password used to sit, and who could read it

+

Before v0.24.1, a session started from the web app got its password from the machine daemon through an environment variable named SHELL_ONLINE_E2EE_PASSWORD. The daemon passed it to the wrapper process that way, the wrapper passed it to the background process the same way, and the background process lives as long as the session does, which for a coding agent is hours. The changelog states the exposure plainly: any program running as the same user could read it, with ps -E on a Mac or /proc/<pid>/environ on Linux.

+ +

The second bug is the one that should worry anyone sharing an agent. The wrapper never stripped that variable, or the session name, origin, and command variables that travel with it, from the environment of the program being shared. The coding agent inherited all of them, and so did every MCP server it spawned and every install script it ran. A nested share was the visible symptom: a shell command run inside a browser-started session silently reused the parent's browser password, published the parent's command and name as its own, and carried the browser request id, so the web app could attach the parent's launch settings to the wrong session. Running shell password rotate from inside rotated to the same password. The invisible symptom is the point of the GitGuardian piece: the process you are sharing could read the credential that grants access to itself.

+
+ +
+

How v0.24.1 hands the password over a pipe

+

The fix lives in one small file, cmd/shell/password_handoff.go, and the whole design fits in a screen. Between the CLI's own processes, the password is now written to the child's standard input through a pipe. The only thing placed in the child's environment is a marker, SHELL_ONLINE_E2EE_PASSWORD_STDIN=1, which says "your password is on stdin" and holds no secret. The child reads stdin to end of file, capped at the 1,024-byte maximum a browser password may have, and after that it is in the same state a background process was always in, with stdin at end of file like the null device. This covers the daemon-to-wrapper hop and the wrapper-to-background hop, on Unix and on Windows.

+ +

The inheritance bug is closed in the function that builds the shared program's environment. It now removes the session name, origin, command, and password variables, plus the stdin marker, alongside the internal variables it already stripped. The shared program still sees SHELL_ONLINE=1, so a script can tell it is running inside a share, but nothing it could use to open one. On the accounts side, the service now lets only one session claim a given browser request, so a nested share cannot be mistaken for the one the browser asked for. Setting SHELL_ONLINE_E2EE_PASSWORD yourself, to pick your own password, still works as documented. The difference is that the long-lived process no longer carries it.

+ +

The pull request in the shell.online repository that landed this included a before-and-after check on a local relay: on the old build, ps -E found the password on a running shell process and the shared program could see the name, origin, command, and password variables; on the new build, neither is true, and a nested share registers its own command with a fresh password.

+
+ +
+

Upgrading, and what to do about sessions that are already running

+

Installing the update does not touch sessions that are already running. They keep the environment they started with until they restart, and browser-started sessions will use the old handoff until the machine daemon is restarted. The release notes say to restart the daemon after upgrading; shell daemon status tells you whether it is running. To bring a host current:

+ +
curl -fsSL https://shell.online/install | sh
+shell --version
+shell list
+ +

If a host was running other programs as your user while a session was up, whether that is a coding agent, an MCP server, or an install script, assume the password for that session could have been read and rotate it. The command reference documents shell password rotate as changing the password, the salt, and the cipher generation, disconnecting current viewers and revoking any MCP grants, without restarting the task underneath:

+ +
shell password rotate <ID>
+ +

Two habits apply regardless of version. Where a viewer only needs to watch, start with shell --read-only; the security model page notes that input is then rejected by both the relay and the CLI. The QR code the CLI prints includes the password, so treat it the same way. And as that page puts it, removing someone from an account cannot erase a password they already know. Rotation is the remedy.

+ +

Encrypting the transport was the easy half of this problem, and it is on by default in shell.online; the earlier post on this blog covers how the link itself works. The harder half is that a machine running an autonomous agent is a machine where you cannot assume the process next to yours is friendly. Keeping the session secret out of anything that process can read is a smaller change, and it is the one that makes the first half mean something.

+
+ +

Share a terminal, keep the password to yourself

One command prints a link and a password. The process stays on your machine, the relay sees only ciphertext, and as of v0.24.1 the password never sits in a process environment.

Try shell.online
diff --git a/blog/posts/end-to-end-encrypted-terminal-sharing-what-the-relay-can-still-do-after-shell-on.html b/blog/posts/end-to-end-encrypted-terminal-sharing-what-the-relay-can-still-do-after-shell-on.html new file mode 100644 index 0000000..4fb6f13 --- /dev/null +++ b/blog/posts/end-to-end-encrypted-terminal-sharing-what-the-relay-can-still-do-after-shell-on.html @@ -0,0 +1 @@ +

Anthropic's September 2026 threat intelligence report has two cases with the same shape. In one, victims were sent to lookalike verification domains "whose reverse proxy relayed the real know your customer (KYC) flow", so they passed a genuine identity check while the operator captured the verified session "from the proxy relay in the middle". In the other, a fraudulent reseller sold discounted Claude access, and customer traffic was "silently proxied to a different AI model" while the reseller's tooling installed a credential harvester. In both, the damage came from a box in the middle that the victim assumed would pass traffic along unchanged.

Any product that offers end-to-end encrypted terminal sharing through a hosted relay owes its users a precise answer to the question those cases raise: what can the middle do? shell.online is that kind of product. The terminal stays on your machine, a Cloudflare relay carries the bytes, and a browser opens them. On September 17 we released v0.16.1, which takes replay off the relay's list. This post walks through that list as it stands in the source, including the parts that did not change.

One command, and what leaves the machine

shell claude

The CLI starts the agent in a PTY on your machine and prints a URL, a password, and a QR code. If you have not seen the basic flow, the introduction to the live terminal browser link covers it. The generated password is ten characters drawn from a 64-symbol URL-safe alphabet, which is 60 random bits. The URL ends in a #salt= fragment, and RFC 3986 section 3.5 is the reason that matters: a fragment is "dereferenced solely by the user agent", so the browser never sends it to the relay.

The CLI and the browser each derive the same AES-256-GCM key locally from the password and that salt, using PBKDF2-HMAC-SHA256 with 600,000 iterations. That is the same count the OWASP Password Storage Cheat Sheet recommends for PBKDF2-HMAC-SHA256. Every terminal frame is sealed before it reaches the relay and opened in the browser. The Go side lives in internal/e2ee/e2ee.go in the shell.online repository, the browser side in web/e2ee.ts, and a cross-language test vector pins the two together.

What end-to-end encrypted terminal sharing hides, and what the relay still sees

Terminal input, output, snapshots, and latency probes travel as authenticated ciphertext. The relay cannot read what the agent printed or what you typed.

It can see plenty else, and the end-to-end encryption page in the docs lists it without softening: connection IPs, timing, encrypted frame sizes, frame opcodes, the access mode, labels, lifecycle metadata, and the terminal grid dimensions, which ride in plaintext control messages so that every viewer renders the same PTY size. Traffic shape is visible too: ciphertext sizes and timing are enough to tell a busy agent from an idle prompt.

The gap that AES-GCM alone leaves open

AES-GCM is an AEAD construction in the sense of RFC 5116. Flip one bit of a frame and the tag fails. What a valid tag does not say is how many times the frame was delivered, or whether it arrived in order.

Until this release the envelope was version 1: a version byte, a 12-byte nonce, then ciphertext and tag, with the frame's opcode as the only associated data. That is 29 bytes of overhead per frame. Our security policy said out loud what followed from it, listing relay-side "dropping, delaying, and replaying of valid ciphertext" as documented protocol limits.

For output, a replay is cosmetic. For input it is not. An input frame carries keystrokes, and a captured frame containing the Enter that answered a confirmation prompt would authenticate just as well the second time it was handed to the CLI. With a coding agent that stops to ask whether it may proceed, that is the frame you least want delivered twice.

Envelope v2: direction, stream, and sequence inside the authenticated data

Envelope v2 adds three fields between the version byte and the nonce:

The opcode, version, direction, stream, and sequence together form the GCM associated data, so none of them can be edited without breaking the tag. Overhead rises from 29 bytes to 46, and the relay's frame-length checks accept either size.

After the tag verifies, the receiver runs the sequence through a 64-frame sliding window kept per stream. A higher number advances the window. A number up to 63 behind the newest is accepted once. A duplicate, or anything older than the window, is refused with "replayed or stale E2EE frame". IPsec ESP uses the same mechanism: RFC 4303 section 3.4.3 says a window size of 64 "is preferred and SHOULD be employed as the default". A window is used instead of strict ordering because, as the comment on acceptSequence puts it, concurrent output and snapshot writers can legitimately reorder a few frames. The test TestFrameSequenceRejectsReplayAndAllowsLimitedReordering seals three frames, opens them in the order third, first, second, then replays one and requires the open to fail.

In the browser a replayed frame raises a dedicated E2EEReplayError and is dropped. Any other failure to open a frame still means the key is wrong and brings back the password prompt, so the two cases do not get confused.

One compatibility detail. The v0.16.1 CLI opens only v2 envelopes. The browser still opens v1 frames so that a session started by an older CLI stays viewable, answers that CLI in v1, and remembers up to 4,096 recent nonces to refuse duplicates on its own side. The policy is explicit that those sessions "do not gain v2's sequence guarantee until their CLI is upgraded". Rerun the installer on any machine that still hosts sessions:

curl -fsSL https://shell.online/install | sh

Two decisions the relay no longer gets a vote on

The first is whether a link is encrypted. The relay sends the viewer a control message that includes an encrypted flag. In the standalone viewer, a link that carries a #salt= or #key= fragment is now treated as encrypted from the start, and if the relay reports it as plaintext, the viewer closes the socket and shows "The relay reported this encrypted link as plaintext. The connection was blocked." The comment in web/main.ts states the rule: "The URL fragment is the viewer's cryptographic intent."

The second is read-only.

shell --read-only claude

The relay rejects browser input on a read-only session. As of v0.16.1 the CLI makes the same check independently. In cmd/shell/session_unix.go, acceptsViewerInput discards both ordinary input and confirmed-EOF frames when the session was created read-only, before anything is written to the PTY. The security model page now titles the card "Read-only is enforced twice", and the security policy puts a bypass of either check in scope for reports.

What is still on the relay's list, and on yours

The relay can still drop or delay ciphertext. No envelope format changes that. What a relay can do to the ciphertext of a current session is slow it down or cut it off, and the local process keeps running either way.

The URL and the password together are a bearer credential. Anyone holding both can view the session and, unless it was started with --read-only, type with the permissions of the wrapped process. Replay protection stops a relay from repeating your keystrokes. It does nothing about a person you handed the credential to.

Sixty bits is a deliberate tradeoff for short, task-bound shares. For long-lived or sensitive sessions, set a longer password in the SHELL_ONLINE_E2EE_PASSWORD environment variable and send it separately from the URL. If a credential gets out, rotate it without restarting the process:

shell password rotate <id>

Rotation replaces the salt, password, and key, and disconnects current viewers. It changes future access only. It cannot erase output a viewer already received.

The same commit added a section to the security policy called "Hosted-service trust boundary", and it is worth reading in full in .github/SECURITY.md. The JavaScript served by the hosted web app is trusted while it is open, because it derives session keys. The installer verifies binaries against SHA256SUMS, but both come from the same release path and are not independently signed. In the policy's words: "That catches corruption, not a compromised publisher. Build tagged source when publisher independence matters." And --no-e2ee remains an explicit opt-out that keeps HTTPS/WSS on each hop while terminal contents pass through the relay in memory.

Put a live terminal behind an encrypted link

Run shell followed by any command and open the URL with its password from a phone or desktop browser. Frames are sealed on your machine, and as of v0.16.1 the CLI accepts each input frame at most once.

Try shell.online
diff --git a/blog/posts/end-to-end-encrypted-terminal-sharing-what-the-relay-sees-and-what-it-can-no-lon.html b/blog/posts/end-to-end-encrypted-terminal-sharing-what-the-relay-sees-and-what-it-can-no-lon.html new file mode 100644 index 0000000..eba603b --- /dev/null +++ b/blog/posts/end-to-end-encrypted-terminal-sharing-what-the-relay-sees-and-what-it-can-no-lon.html @@ -0,0 +1,53 @@ +

On August 31, someone who was not Coder spent about fourteen hours answering requests for Coder's module registry. Coder's security advisory says an unidentified actor "gained access to Coder's Cloudflare infrastructure and added unauthorized IP addresses" to the pool behind the registry. Between 07:35 and 21:45 UTC, some requests were routed to the attacker's servers, which handed back Terraform modules that collected provisioner secrets, OIDC tokens, SSH keys and database passwords and sent them to a lookalike domain registered three days earlier. BleepingComputer's report has the timeline and the patched versions.

+ +

What the advisory describes is a change to Coder's own edge configuration: extra addresses in a pool. For most of a day, the middle of the connection belonged to somebody else. Any tool that routes developer traffic through a hosted edge should be designed for that day, and it is the threat model behind end-to-end encrypted terminal sharing in shell.online. Version 0.16.1, released on September 17, tightened that model in three places. This post walks through what the relay can see, what it cannot, and what changed.

+ +
+

Where the key comes from

+

The workflow is one command. Wrap whatever you want to share:

+
shell claude
+

The process and its PTY stay on your machine. The CLI prints a browser link for that terminal process, a ten-character password, and a QR code. The password is ten base64url characters, each an independent six-bit symbol, so 60 random bits. The URL ends in a random #salt= fragment. Fragments are resolved by the client and are not part of the request a browser sends (RFC 3986, section 3.5), so the salt never reaches the relay, and the password is not in the printed URL at all. The QR code is the exception by design: it adds the password to the fragment so a phone can open the session in one scan, which makes a photo of that QR the whole credential.

+

Both ends derive the same 32-byte key locally with PBKDF2-HMAC-SHA256 over a 16-byte salt at 600,000 iterations, which is the figure the OWASP password storage cheat sheet recommends for that construction. Frames are sealed with AES-256-GCM before they leave the machine and opened in the browser. The Go side is about 280 lines in internal/e2ee/e2ee.go in the shell.online repository, and the browser half is web/e2ee.ts. Sixty bits is sized for task-bound sharing: derivation is slow on purpose, sessions expire, and the online endpoints are rate-limited. For anything sensitive or long-lived, set your own longer password and send it over a different channel than the URL:

+
SHELL_ONLINE_E2EE_PASSWORD='use-a-long-unique-password' shell python train.py
+
+ +
+

What end-to-end encrypted terminal sharing still shows the relay

+

Encryption of the payload is not invisibility. Terminal input, output, snapshots and latency probes are authenticated ciphertext. The end-to-end encryption page in our docs lists what the Cloudflare relay can still observe:

+ +

Traffic shape leaks something. A burst of small viewer-to-host frames followed by a large host-to-viewer frame looks like someone typing a command and getting output, whatever the bytes are. If that matters for your work, the payload cipher will not help, and you should know it before you share the link.

+
+ +
+

A relay that cannot read keystrokes could still repeat them

+

AES-GCM authenticates each frame. It says nothing about when a frame was sent, or how many times it should arrive. An intermediary that cannot forge a keystroke can still record a valid encrypted input frame and deliver it again later. In a log viewer that is a nuisance. In a session wrapping a coding agent, the recorded frame might be "y" and Enter at a permission prompt, and a replay is an approval nobody gave.

+

Envelope v2 closes that. After the opcode byte, every sealed frame now carries a version byte, a direction byte, an 8-byte random stream ID chosen when the sender creates its cipher, an 8-byte big-endian sequence number that starts at 1, and the 12-byte nonce. With the 16-byte GCM tag that is 46 bytes of overhead per frame. The opcode, version, direction, stream and sequence are all fed to GCM as additional authenticated data, so changing any of them fails authentication.

+

The receiver does three things in order. It derives the expected direction from the opcode and rejects a mismatch. It opens the frame. Then it checks the sequence against a 64-frame sliding window kept per stream: a higher number moves the window forward, a number inside the window is accepted once, and anything older or already seen is dropped as "replayed or stale". This is the same anti-replay construction IPsec uses, and RFC 4303 section 3.4.3 names 64 as the preferred default window for ESP. The window exists at all because concurrent output and snapshot writers can legitimately reorder a few frames.

+

Sessions started by an older CLI stay viewable, and they pick up replay protection when that CLI is updated.

+
+ +
+

Two other things the middle no longer gets to decide

+

The first is whether your link is encrypted. When a browser connects, the relay sends a control message that includes an encrypted flag. The browser now treats the URL fragment as the viewer's intent: if the link carries E2EE material and the relay reports the session as plaintext, the page shows that the connection was blocked and closes the socket with code 4003, "encryption downgrade blocked". A relay under someone else's control cannot talk the browser into sending readable frames.

+

The second is read-only. A link made with the read-only flag is the right thing to hand out when people should watch a build or a training run and nothing else:

+
shell --read-only npm run build
+

The relay rejects browser input on such a link, but a relay check alone would be worth nothing on the day the relay is not yours. So the CLI rejects input and confirmed EOF a second time, on the machine, before anything reaches the PTY. The security model page states the consequence: DevTools, a modified browser, or handcrafted WebSocket frames cannot turn a read-only link into an interactive one.

+
+ +
+

What encryption does not fix

+

A relay can still drop or delay ciphertext. If it does, the view degrades and the process does not: the CLI and browser reconnect with bounded backoff, and an ordinary link stays recoverable for 12 hours after its host disconnects.

+

The hosted web application is trusted while it is open, because it is the code that derives the session key in your browser. The Coder incident is the uncomfortable version of this point: the attacker there was answering on the vendor's own hostname. Our security policy says so directly. E2EE protects frames from passive relay inspection and does not make hosted application code trustless. The same policy admits that the installer checks binaries against a SHA256SUMS file that comes down the same distribution path, which catches corruption and not a compromised publisher, and it tells you to build tagged source when publisher independence matters. If the hosted boundary is wrong for your environment, the repo also documents a standalone relay you can run on your own Docker host behind Caddy.

+

The URL and the password together are a bearer credential. Anyone holding both can view the session and, unless it was started read-only, type with the operating-system permissions of the wrapped process. End-to-end encryption protects the contents from the relay. It does nothing about a password pasted into the wrong channel.

+

When that happens, you do not have to kill the job:

+
shell password rotate <id>
+

Rotation generates a new password and URL salt, invalidates the old cipher generation, disconnects current viewers, and leaves the process running. It changes future access only, so it cannot erase output a viewer already received. The same applies to teams: removing a member deletes the copies sealed to them, but it cannot make them forget a password they already read, so rotate active sessions after an offboarding. For short jobs, add an earlier deadline with the auto-close flag (for example, shell --auto-close 5m followed by the command). Ordinary shares and their server state disappear when the task exits anyway.

+

The introduction to shell.online on this blog covers the basic workflow if you have not used it yet.

+
+ +

Share a terminal the relay cannot read

One command prints a link and a ten-character password. The process stays on your machine, frames are encrypted before they leave it, and the code that does it is MIT-licensed and short enough to read.

Try shell.online
diff --git a/blog/posts/how-to-revoke-a-shared-terminal-link-without-killing-the-process-behind-it.html b/blog/posts/how-to-revoke-a-shared-terminal-link-without-killing-the-process-behind-it.html new file mode 100644 index 0000000..07c2f06 --- /dev/null +++ b/blog/posts/how-to-revoke-a-shared-terminal-link-without-killing-the-process-behind-it.html @@ -0,0 +1,57 @@ +

On September 1, the security startup Strix published a write-up of what its pentest agent found while evaluating Baseten as an inference vendor. Pointed at the company's public hosts, the agent took about 25 minutes to pull a GitHub personal access token out of a public project on a Harbor container registry. The token was not in a filesystem layer. It sat in the image's history[].created_by field, where Docker had recorded a RUN step with GITHUB_TOKEN expanded into it, from a build dated March 3, 2023. According to Strix it still carried admin and push rights on Baseten's main product repo, the GitOps repo that drives their clusters, and their Homebrew tap. Techzine's coverage on September 16 gives the same timeline: reported late on July 13, token rotated the next afternoon, everything flagged resolved by July 17.

+ +

Two numbers in that story matter more than the 25 minutes. The credential stayed valid for more than three years because nobody knew it had escaped, and it was dead within a day once somebody did. Revocation was the cheap part. The same logic applies to a smaller credential that developers hand around every day, and this post is about how to revoke a shared terminal link on shell.online, our browser link to any terminal process, without stopping whatever is running behind it.

+ +
+

A terminal link is a bearer credential, and it travels through chat

+

Running shell claude or shell npm run dev prints a URL and a randomly generated ten-character password. The security model page is blunt about what that pair is: a bearer capability. Anyone holding both values for an interactive share can view and type with the operating-system permissions of the wrapped process. The term means the same thing it means in RFC 6750, where a bearer token is one that any party in possession of it can use, with no further proof of identity. A terminal link is not an OAuth token, but the property is identical. Possession is the whole check.

+

That would be fine if the pair only ever went to the intended person. In practice it goes into a Slack thread, a ticket, a meeting chat, a screenshot. GitGuardian's State of Secrets Sprawl 2026 report counted 28.65 million new hardcoded secrets in public GitHub commits during 2025, and says roughly 28% of secrets sprawl happens exclusively outside code repositories, in collaboration and productivity tools. Those are the places a terminal link gets pasted while two people debug something in a hurry.

+

So assume a link to a long-running process eventually ends up somewhere it should not be. If that process is a training job on hour thirty, killing it to close the link is an expensive way to fix a sharing mistake.

+
+ +
+

Revoke a shared terminal link with one command

+

Since v0.12.2 (September 12) the CLI has had a way to change a live session's credentials in place. Find the session, then rotate it:

+
shell list
+shell password rotate <id>
+

The ID can be the full session ID or an unambiguous prefix of at least six characters. The command has to run on the machine that owns the session, because it talks to the local process holding the PTY. On success it prints a new link and a new password, followed by one line: "Existing viewers were disconnected. Share the new link and password." The wrapped process is not restarted, and nothing about it changes except who can reach it.

+

If the replacement needs to be something you choose, for example a longer passphrase for a session that will live for weeks, set it in the environment for that one command:

+
SHELL_ONLINE_E2EE_PASSWORD='a much longer passphrase' shell password rotate <id>
+

Without the variable, the CLI generates ten random base64url characters, the same as at session start. The full grammar is in the CLI reference under "Manage passwords".

+
+ +
+

What the rotation does, in order

+

The ordering is the part worth reading closely, because a revocation with a gap in it is not a revocation. The sequence below comes from the CLI help text and the security policy in the repository.

+
    +
  1. The CLI generates a fresh password and a fresh URL salt. The browser and the CLI each derive the session's AES-256-GCM key locally from the password and the salt using PBKDF2-HMAC-SHA256 with 600,000 iterations, the figure OWASP's password storage guidance recommends for that construction. New password plus new salt means a new key.
  2. +
  3. For a session started with --persistent, the owner-only state file is updated with the new salt, password, and key before the host changes ciphers. Restarts keep the new credentials, and the stable session path stays the same.
  4. +
  5. The host activates the new cipher. From this point, input encrypted under the old key is rejected by the CLI itself.
  6. +
  7. Only then does the relay disconnect the existing browser viewers. The relay receives neither the old credential nor the new one.
  8. +
  9. If the machine is linked to an account, the account's copy of the URL and the owner's sealed vault copy are replaced atomically (the vault is optional, so only if the account has one), and copies that had been sealed to teammates are removed until the new password is shared with them again.
  10. +
+

Step 3 comes before step 4 on purpose. The help text gives the reason: because the host switches ciphers first, old-key input is rejected immediately, while its holder is still connected. The disconnect that follows is cleanup.

+

The implementation is short enough to read. The command entry point is runSessionPassword in cmd/shell/sessions.go, which validates or generates the replacement and hands it to the running session over its local control channel.

+
+ +
+

What rotating a password cannot do

+

Rotation changes future access only. It cannot erase terminal output that somebody already received, and it does nothing about secrets that were printed into that output.

+

This is the Baseten lesson again from the other side. Rotating the GitHub token closed the door, but it could not tell anyone who had walked through it during the previous three years. If a link leaked while the terminal was showing an API key, a database URL, or an agent's environment dump, rotate the link and then rotate those too.

+

The security policy is also specific about a subtler point: an old URL and password pair may still open an anonymous socket to the relay, but it cannot authenticate any encrypted frame sent after the swap. Old credentials cannot decrypt later frames, and the host accepts no input under the old key.

+

Rotation applies to end-to-end encrypted sessions. For one started with --no-e2ee, the command refuses with "this session has no browser password". For that case, and for any case where the process itself should not keep running, the right tool is shell kill <id>, which stops the process and closes its link.

+
+ +
+

Teams: offboarding is a rotation event, handoff is not

+

With accounts and organizations, a session password can be sealed to individual members, so colleagues can open the session without the service being able to read it. Removing a member deletes every copy sealed to that account. It cannot make the person forget a password they already opened, so the documented rule is to rotate each active session that person could reach when immediate revocation matters. Rotation also removes the stale teammate copies, so the new password has to be shared with the remaining team again.

+

Ordinary handoffs do not need any of this. Assigning a session to someone else, or transferring ownership, updates in place without a reconnect and without a window in which the former writer can still send input. The public link does not change.

+
+ +
+

Rotation is the backstop. Pick a narrower link first.

+

Most leaked-link scenarios are less serious if the link was narrow to begin with. A monitoring link for a build or a training run should be started with shell --read-only, which the relay and the CLI each enforce independently, so a modified browser cannot turn it interactive. A link meant for a twenty-minute pairing session should carry shell --auto-close 30m so it expires whether or not anyone remembers it. Every ordinary share closes when its process exits anyway. Rotation is for what is left over: the long-lived interactive session that has to keep running and whose credentials have gone further than intended.

+

The same applies when an agent is the one creating shares. The installable agent skill tells an agent asked to revoke access without stopping the task to use the rotate command, send only the new pair, and say plainly that the previous credentials no longer decrypt later frames. If you are new to the tool itself, our introduction to live terminal links covers the basics, and the CLI is MIT licensed with the full source on GitHub.

+
+ +

Share a terminal you can take back

One command prints a link and a password for any process. If the pair travels too far, rotate it and keep the process running.

Try shell.online
diff --git a/blog/posts/how-to-watch-a-coding-agent-remotely-after-you-turn-off-its-permission-prompts.html b/blog/posts/how-to-watch-a-coding-agent-remotely-after-you-turn-off-its-permission-prompts.html new file mode 100644 index 0000000..3c428a6 --- /dev/null +++ b/blog/posts/how-to-watch-a-coding-agent-remotely-after-you-turn-off-its-permission-prompts.html @@ -0,0 +1,88 @@ +

Claude Code 2.1.259 added a flag for runs where nobody is at the keyboard. The changelog entry describes --permission-prompts none as being "for unattended headless hosts: anything that would prompt is denied automatically while the active permission mode (including auto mode) keeps deciding". That is the correct default for a scheduled job. It also means the run makes every decision on its own, and the only record of what it did is the terminal it ran in. This post is about how to watch a coding agent remotely once you have taken the human out of its permission loop, using a read-only browser link from shell.online.

+ +

shell.online is the terminal-sharing tool we build at Pilot Protocol. The introduction post covers what a browser link to a terminal is. This one goes into the unattended case specifically: the exact command, what a phone shows, who can see it, and what happens when the network drops or the job ends.

+ +
+

What the new flag changes about an unattended run

+ +

The headless documentation is precise about the behaviour. With the flag set, "anything that would prompt is denied unless a PermissionRequest hook allows it, Claude is told that nobody can approve the request and not to retry it, and the run continues." Tools that need a person to answer, such as AskUserQuestion, are removed from the session entirely. Denials show up as permission_denied system messages in the stream-json output, and the final result message lists them under permission_denials.

+ +

So the run never blocks. That is the point, and it is also the problem. A job that used to hang on a prompt now records a denial and moves on. If it spends forty minutes working around a denied command, the transcript is where that shows, and the transcript lives in a PTY on the machine that ran it.

+ +

The same direction is visible elsewhere. VS Code 1.136, released on September 2, previews Agent Merge, which has agents "address review feedback, fix failed checks and merge conflicts, and rerun workflows" until a pull request is ready, per the release notes. Long loops with no human step are becoming the normal shape of agent work.

+
+ +
+

Where Remote Control stops

+ +

Anthropic's own answer is Remote Control, which connects claude.ai/code or the Claude mobile app to a Claude Code session on your machine. The documentation says Claude "keeps running locally the entire time". The constraints it lists matter for an unattended job:

+ + + +

A Codex run, a custom agent on any SDK, or the npm build the agent kicked off gets none of that. shell.online works one level down. It wraps the process in a local PTY and shares the terminal itself, so it does not care which agent is inside or which API it talks to.

+
+ +
+

Watch a coding agent remotely with a read-only link

+ +

Install the CLI, then start the unattended run behind a read-only share. The two dashes separate the share flags from the flags that belong to claude:

+ +
curl -fsSL https://shell.online/install | sh
+
+shell --read-only -- claude -p "Update the dependency pins and run the tests" \\
+  --permission-mode auto --permission-prompts none \\
+  --output-format stream-json --verbose
+ +

The command comes from the headless documentation's own unattended example, with two output flags added. In the default text mode a headless run prints only its final result, so the terminal would sit empty until the end. With stream-json and verbose, each event lands as one line as it happens, including the permission denials.

+ +

The share prints a URL and an eight-character password only after a short startup handshake. If claude exits immediately, for example because the API key is missing in a cron environment, shell.online reports the exit status and prints no dead link. Otherwise the process keeps running in the background.

+ +

Open the link on a phone and enter the password. The mobile guide explains that a session uses a 120 by 36 grid until a phone connects, then switches everyone to an 80 by 24 grid that fits a phone screen. Read-only phones count too, so a desktop viewer sees the same reflow.

+ +

Read-only is enforced by the relay, not the page. The security model states that with the flag set, "ordinary browser input and confirmed EOF are rejected by the Worker", and that developer tools or handcrafted WebSocket frames cannot turn the link into an interactive one.

+
+ +
+

Who can open it and what the relay can read

+ +

The URL and the password together are a bearer credential. Anyone holding both can see the terminal, and on an interactive share they can type with the permissions of the wrapped process. Read-only removes the typing but not the viewing, which is why the password should travel separately from the URL when the run touches anything sensitive.

+ +

An unattended agent's transcript is a good place to leak things. Environment dumps, tokens echoed by a failing script, and file contents the agent decided to print all end up on that screen. Treat the share like a log file that anyone with the link can read, because that is what it is.

+ +

The end-to-end encryption guide covers what the relay sees. The generated eight-character password gives 48 bits of entropy, which the guide describes as suited to convenient task-bound sharing. For a long-lived or sensitive run, set a longer password with SHELL_ONLINE_E2EE_PASSWORD. The URL carries only a random salt fragment, and the CLI and browser each derive the same AES-256-GCM key with PBKDF2-HMAC-SHA256 at 600,000 iterations. The relay receives neither the password nor the plaintext.

+ +

The guide is equally direct about limits: encryption "cannot hide traffic shape or prevent the relay from dropping, delaying, or replaying a valid encrypted frame". Cloudflare does not persist terminal contents. The CLI holds a bounded in-memory replay buffer while the process is alive, and ordinary task exit deletes the session state and closes every socket.

+
+ +
+

Network loss, reconnects, and the end of the job

+ +

Unattended runs are long, and phones drop off networks. The reliability page describes how the CLI and browser reconnect with bounded backoff, that a temporary network failure does not stop the process, and that the same ordinary share link stays recoverable for 12 hours after its host disconnects. A returning viewer is restored from the local ring buffer. If output outruns the network or the rendering, stale display work is dropped and one authoritative screen snapshot is sent instead.

+ +

The share always closes when the wrapped task exits. If you want it gone earlier, --auto-close only adds an earlier deadline:

+ +
shell --read-only --auto-close 2h -- claude -p "..." --permission-mode auto --permission-prompts none
+shell list
+shell kill <session-id>
+ +

shell list shows local process uptime alongside the relay's own view, which is one of online, starting, reconnecting, expired, or unknown. Unknown means the check was inconclusive, not that the link died. The owner-only record keeps the generated password, so you can hand the link to a second person without restarting the job. shell kill stops the process and the share together.

+ +

If a supervisor restarts the job, --persistent with an owner-only state file restores the same URL and password on the next start. That file holds the host identity, password, and E2EE material, and the CLI reference lists --no-e2ee as conflicting with persistence.

+
+ +
+

Letting the agent hand out its own link

+ +

An agent that spawns its own subtasks can create the share itself. shell --json emits the new-session event as one JSON line on stderr, including the password when the share is encrypted, so the wrapped process keeps stdout. The agent passes that to its operator over whatever channel it already uses. An installable skill at shell.online/skill covers this.

+ +

The source is MIT licensed. The Go CLI owns the local PTY, a Cloudflare Worker creates sessions and serves the site, one Durable Object coordinates each terminal's host and viewers, and the browser uses xterm.js. Release binaries are static and checksummed for macOS, Windows using ConPTY, and Linux across x86, ARM, MIPS, PPC64, RISC-V, s390x, and LoongArch, with each Linux artifact exercised under QEMU. The platform guide has the full matrix.

+ +

Turning off permission prompts is the right call for a scheduled agent. Keeping a live, read-only view of what it did with that freedom is the other half of the decision, and it is one prefix on the command you already run.

+
+ +

Watch the next unattended run from your phone

One command wraps the agent, prints a link and a password, and keeps the process on your machine with the terminal encrypted end to end.

Try shell.online
diff --git a/blog/posts/let-a-coding-agent-share-its-own-terminal-session-in-the-browser-the-shell-onlin.html b/blog/posts/let-a-coding-agent-share-its-own-terminal-session-in-the-browser-the-shell-onlin.html new file mode 100644 index 0000000..6c9c5fa --- /dev/null +++ b/blog/posts/let-a-coding-agent-share-its-own-terminal-session-in-the-browser-the-shell-onlin.html @@ -0,0 +1,51 @@ +

Two things shipped today that point in opposite directions. OpenAI told its DevDay audience that the Codex app now runs fully in the cloud, so a laptop can be closed while the agent works. Docker published its first tagged releases of a skills repository that teaches coding agents to build and debug containers on your own machine. The second world still needs a way for a human to look at what the agent is doing. The shell.online agent skill covers that case: it tells an agent how to share a terminal session in the browser, then hand its operator the link and password in the conversation it is already having.

+ +
+

Skills became the way you teach an agent a tool, and Docker joined today

+

The Agent Skills format is a folder with a SKILL.md file. The file carries metadata, at minimum a name and a description, followed by instructions, and it may bundle scripts and reference material. Agents load skills by progressive disclosure: at startup only the name and description are read, the full instructions are pulled in when a task matches, and any bundled files are read during execution. Anthropic developed the format and released it as an open standard, and the Agent Skills site now lists dozens of clients that read it, including Claude Code, Codex, Cursor, Gemini CLI and OpenCode.

+

Docker's docker/skills repository tagged v0.1.0, v0.2.0, v0.3.0 and v0.3.1 all on 29 September 2026. Its README describes the contents as "Docker-authored knowledge skills that improve AI coding agent output for Docker-related tasks", written "as portable SKILL.md directories and discovered automatically by any compliant agent through standard skill paths". The catalog covers Dockerfiles, Compose, Docker Sandboxes and Docker Agent, plus one cross-product skill whose whole job is to make an agent confirm before running an irreversible Docker operation.

+

Codex reads skills from a fixed set of paths: every .agents/skills directory from the working directory up to the repository root, then the user's home directory, then an admin location. Its documentation says the feature "builds on the open agent skills standard". The agent on your machine is not going away. What changes is how much of its work you are around to see.

+
+ +
+

What the shell.online skill instructs an agent to do

+

The skill is served at shell.online/skill as plain text, and it is byte-for-byte the file at public/skill/shell-online/SKILL.md in the shell.online repository. Its frontmatter name is shell-online and the description reads: "Share and manage shell.online terminal sessions, or observe and send explicitly authorized input through their scoped MCP grants. Use for remote progress monitoring, human handoff, and agent-to-agent terminal control."

+

The start-and-share procedure is short. The agent checks for the CLI, installs it if missing, and then wraps the process it wants to expose:

+
command -v shell || curl -fsSL https://shell.online/install | sh
+shell --json -- python train.py
+shell --read-only --json -- npm run build
+

The skill tells the agent to prefer the read-only form whenever the operator only needs to watch. It then tells the agent to read the first JSON event and extract three fields: share_url, e2ee_password and session_id. The example event in the skill looks like this:

+
{"type":"session","session_id":"…","share_url":"https://shell.online/s/…#salt=…","e2ee_password":"Ab3dE7-_xY","read_only":false,"encrypted":true,"background":true}
+

Two details matter for anyone wiring this into an agent harness. First, the event goes to stderr, not stdout. The flag definition in cmd/shell/main.go describes --json as "emit the session event as JSON on stderr", and the CLI reference repeats it: new-session JSON goes to stderr and includes e2ee_password for encrypted sessions. Second, the URL has a #salt= fragment that must be preserved. The salt is not the secret; nothing decrypts without the password, but a truncated URL will not open. The skill is explicit that the agent must send the complete URL.

+

The final step is the human part. The agent posts share_url and e2ee_password into the active conversation, says which process the link exposes, and says whether read_only is true. The operator is about to open a link on a phone and needs to know whether tapping a key will type into a running process.

+
+ +
+

Share a terminal session in the browser, then be clear about who holds the key

+

Once the operator has both values, they open the URL in any modern browser, enter the password, and see the same PTY the agent is driving. The process and the terminal stay on the machine that ran the command, which is the whole premise of a browser link to a terminal process rather than a remote machine. What travels through the relay is ciphertext: the CLI encrypts terminal frames before they reach the Cloudflare relay and the browser decrypts them. The security page lists what the relay can still observe: connection IPs, timing, encrypted sizes, frame types, labels, access mode, lifecycle and terminal grid dimensions. It cannot read the output.

+

The same page states the access rule plainly: "Anyone with the complete link and password can view and type with the wrapped process's operating-system permissions." The URL plus the password is a bearer credential. If an agent pastes both into a shared channel, everyone in that channel can type into the process with the agent's own permissions. The skill accounts for this in two ways. It says to treat the pair as a bearer secret and never send the host token, and for sensitive or long-lived work it suggests setting a longer password through the SHELL_ONLINE_E2EE_PASSWORD variable and sending the URL and password through separate operator-approved channels.

+

Read-only is the safer default for monitoring, and it is enforced away from the browser. The security page says input "is rejected by both the relay and the CLI. A modified browser cannot turn this into an interactive session." The agents page adds that a read-only session cannot be made interactive by an MCP grant either. So an agent that starts a read-only share for a build has given its operator a window, not a keyboard, and no client-side trick changes that.

+

The skill also constrains the one weakening option. --no-e2ee is only for an explicit operator request; the JSON event then reports encrypted: false and omits e2ee_password entirely, and the relay can read terminal input and output.

+
+ +
+

Handing off a live Claude Code conversation

+

There is one path in the skill that goes further than sharing a fresh process. When an operator asks an agent running inside Claude Code to share the current conversation, the skill says to run:

+
shell --json -- claude
+

Inside a Claude Code Bash subprocess, the CLI detects the CLAUDE_CODE_SESSION_ID variable and starts a shareable fork with claude --resume on the current session plus --fork-session. The operator receives a share link to a second Claude Code process that has the same conversation history and the same workspace. The skill is careful about the limits: the original process stays open, messages sent after the handoff do not synchronize between the two, and the agent must never claim that shell.online adopted the original PID or PTY. It is a fork with shared history, which is what you want when steering the same task from a phone.

+
+ +
+

Edge cases the skill already decides for the agent

+

Most mistakes an agent can make with a shared terminal are lifecycle mistakes, and the skill spends most of its length on them.

+ +

The reason to encode this in a skill rather than a prompt is the same reason Docker shipped a destructive-operations guardrail as a skill: the instructions are versioned, readable before the agent acts, and identical across every client that speaks the format. The shell.online skill file in the repo is the full statement of what an agent will do with your terminal.

+

For the underlying model of a share, one command printing a URL and password with the PTY staying local, the earlier post on live terminal links covers it. shell.online is developed by Pilot Protocol, and the skill's MCP section is where the two meet: scoped, expiring grants for another agent to observe or control a session, issued from the same CLI.

+
+ +

Give your agent the skill

Point your agent at the skill file, and the next time it runs something worth watching it can hand you an encrypted browser link with the read-only flag stated up front.

Try shell.online
diff --git a/blog/posts/mcp-terminal-access-for-an-ai-agent-scoped-grants-that-expire-and-revoke.html b/blog/posts/mcp-terminal-access-for-an-ai-agent-scoped-grants-that-expire-and-revoke.html new file mode 100644 index 0000000..0c8f47d --- /dev/null +++ b/blog/posts/mcp-terminal-access-for-an-ai-agent-scoped-grants-that-expire-and-revoke.html @@ -0,0 +1 @@ +

Safari 27 shipped on September 17 with an MCP server built into the browser. The WebKit release post describes it as a way to give Claude Code, Codex, or another agent control of a browser window, and it is off until a developer ticks "Allow remote automation and external agents" in Safari's Developer settings. A day later Microsoft's Agent Framework tightened the other end of the same pipe. Today we shipped our own piece of this: MCP terminal access in shell.online, where an agent can read a live terminal session, and optionally type into it, through a grant that is scoped, expires at a fixed time, and can be revoked without touching the browser link. This post covers the exact commands, the five tools an agent gets, and the part that deserves the most attention: a grant changes what the relay can decrypt.

Two releases in one week that narrowed MCP access

Apple's post is careful about boundaries. It says the Safari MCP server "runs entirely on your local machine", "makes no network calls of its own", and "does not have access to your personal information in Safari". The capability is large, so the default is off.

The python-1.19.0 release of Microsoft Agent Framework, published September 18, carries a breaking change titled "Scope provider-backed MCP sessions per invocation" and a fix that authenticates and scopes MCP requests and sessions "to the correct invocation identity, origin, and ownership". The MCP specification pushes the same way. Its 2026-07-28 authorization section tells clients to follow least privilege when requesting scopes and requires servers to check that a token was issued for them specifically.

A terminal is a harder case than a browser tab. Whatever can type into it acts with the permissions of the process inside it. So the design question for MCP access to a terminal is mostly about what the credential is allowed to do, for how long, and who can take it back.

How MCP terminal access works in shell.online

The normal product is unchanged: one command wraps a process and prints a browser link to that running terminal plus an eight-character password. The process and its PTY stay on your machine. MCP access, introduced in v0.21.0, is a separate door into the same session with a separate key. The repo's docs/MCP.md lays out the operator workflow, and it starts by putting the target somewhere it can do little damage:

shell --json -- opencode

The --json flag emits the new-session event as a single JSON object on stderr, including the share URL and password, so a script or supervising agent can start the session without interactive setup. The session ID comes from shell list. Then issue a grant:

shell mcp grant <session-id> "review agent" observe 900

The arguments are the session, a label, a preset (observe or control), and a lifetime in seconds. The command prints an opaque bearer token exactly once. The endpoint is https://shell.online/mcp. Both go into the MCP client through its secret or environment mechanism, never into a URL, a command argument, or a repository.

Three properties of that bearer matter. It is separate from the browser link and password, so handing an agent a grant does not hand it the share credential, and the reverse is also true. It expires at a fixed time and does not renew on use, which means an agent that needs another fifteen minutes gets a new grant issued on purpose by a person. And it is listed and revocable per grant with shell mcp list <session-id> and shell mcp revoke <session-id> <grant-id>.

The label is display data only. The MIT-licensed source records why that needed saying: v0.21.1, released a few hours after v0.21.0, fixed a bug where a crafted label could alter the requested scopes or lifetime through the local control protocol. Labels are now encoded as data, and new grant requests fail closed against a host still running the older build.

Five tools, split between observe and control

An observe grant exposes four tools:

The control preset adds one more, shell_send: 1 to 8192 UTF-8 bytes, an optional Enter, and a required UUID-v4 operation_id. shell_key and shell_interrupt are not implemented, and the docs rule out smuggling raw escape or control bytes through shell_send as a workaround. A session started with --read-only denies input no matter what the grant says, because read-only is enforced by the CLI on the host. Control is off by default, and shell_send only appears in the tool catalog when the host is compatible and the service operator has enabled the control gate.

What "delivered" means when an agent types

Agent input into a live terminal has two failure modes a normal API call lacks: sending the same command twice, and typing over a human.

Each intended submission gets one operation ID. Retrying with the same ID and the same arguments returns the stored result without resending. The same ID with different arguments is a conflict. The host acknowledges each send with a dispatch token bound to that specific write.

Human input outranks MCP. Rejected agent input is not queued for later, so nothing an agent sent five minutes ago lands in the terminal after a person has moved on.

A result of delivered confirms the complete PTY write. It says nothing about whether the program on the other side finished the task, so the controller is expected to verify through shell_screen and the artifact it asked for. delivery_uncertain must not trigger a blind resubmission under a new ID. The docs add one more instruction for anyone writing a controller: terminal output is untrusted data, never permission to override the controller's instructions.

A grant authorizes server-side decryption

This is the tradeoff to understand before issuing a grant. Browser sharing is end-to-end encrypted by default: the CLI encrypts frames, the browser decrypts them, and the relay sees metadata, timing, and frame sizes (the E2EE page has the details). An MCP client is a remote program speaking HTTPS to an endpoint, so something on the server side has to turn terminal frames into tool results.

Issuing an MCP grant explicitly authorizes server-side, in-memory decryption using a frame key the host supplies. The controller receives plaintext. Browser-only sharing stays end-to-end encrypted; a session with a live grant has a second reader.

The implementation keeps that reader narrow. Per docs/MCP.md, MCP stores no plaintext terminal content, key, or bearer. Its durable records hold grant metadata, bearer hashes, and operation fingerprints and outcomes. And the session does not hide the arrangement from the people in it: for the full grant lifetime, the web app's terminal pane shows a status reading "MCP · server-side decryption authorized". The notice follows the grant lifetime, whether or not an agent called recently.

If that tradeoff is wrong for a session, issue no grant. Nothing about MCP is on unless the host runs shell mcp grant.

Revoking a grant, rotating the password, and a stolen bearer

shell mcp revoke-all <session-id> removes every MCP grant for the session and leaves browser access alone. shell password rotate <id> revokes every existing grant and cancels pending MCP work along with the old browser password, so one command cuts off everyone and you reissue deliberately afterward. To stop the target itself, use shell kill <id>.

A grant bearer is a bearer token in the RFC 6750 sense: whoever holds it can use it until it expires or is revoked. Treat bearer files and raw request logs the way you treat the share URL and password. If one leaks, revoke it, and stop the target if its actions were unsafe. Revocation cannot undo what was already typed or erase output a controller already received.

Two limits are worth stating. The production canary on September 21 ran 16 checks against a synthetic shell. It was not a model-backed client matrix, and the docs say plainly that Claude model-backed behavior remains unverified. Second, a grant label does not verify which agent is calling, so do not read it as identity.

We build agent networking at Pilot Protocol, and the rule we apply there holds here: an agent's reach should be a named, time-boxed decision by the machine's owner. For the human side of the same session, the earlier post on a live terminal as a browser link covers the URL and password model this sits next to.

Give an agent fifteen minutes of read access, then take it back

Install the CLI, wrap a process, and issue an observe grant with a 900-second lifetime. The browser link stays end-to-end encrypted until you decide otherwise.

Try shell.online
diff --git a/blog/posts/self-hosted-terminal-sharing-on-one-docker-box-the-shell-online-relay-without-a.html b/blog/posts/self-hosted-terminal-sharing-on-one-docker-box-the-shell-online-relay-without-a.html new file mode 100644 index 0000000..db4d8fc --- /dev/null +++ b/blog/posts/self-hosted-terminal-sharing-on-one-docker-box-the-shell-online-relay-without-a.html @@ -0,0 +1,47 @@ +

Cloudflare's status page logged an incident titled "Cloudflare Tunnel Availability Issues" that opened at 21:55 UTC on September 11 and was marked resolved at 08:57 UTC on September 12, roughly eleven hours later, with the impact rated minor and the affected component listed simply as Tunnel. Cloudflare Tunnel is a common way to reach a home lab or a dev box without opening an inbound port: the cloudflared daemon makes outbound-only connections to Cloudflare's network and traffic to your origin rides through that tunnel. It is a good design, and it has one obvious property. When the relay in the middle has a bad night, so does your access path. That is the honest framing for the feature shell.online shipped today in v0.12.2: self-hosted terminal sharing through a relay you run yourself, on an ordinary Docker host, with no Cloudflare account involved.

+ +

To be clear about what this post is not claiming: we have no evidence the hosted shell.online relay was affected by that incident, and Cloudflare's status page publishes no detail beyond the title, timing, and component. The point is narrower. A relay is a dependency. The other self-hosting path in the repo is our Cloudflare Workers build deployed under your own account, which still means Cloudflare. The standalone relay needs only Docker, a domain name, and two open ports.

+ +
+

What the relay does, and what it never sees

+

A quick recap for anyone new to the tool. You run one command, for example shell claude, and the CLI prints a URL, a password, and a QR code. Anyone holding both the URL and the password opens the same live terminal in a phone or desktop browser. The process and its pseudo-terminal stay on your machine. The relay's only job is to move frames between the CLI and the browser and to enforce access rules. By default the CLI encrypts every terminal frame before it leaves the machine and the browser decrypts it locally, so the relay carries ciphertext. Our security model page spells out what the hosted relay can still observe: frame type, encrypted size, timing, connection IP, access mode, the command label, and lifecycle events. Terminal contents are not on that list. The URL's #salt= fragment never reaches the relay either, because browsers do not send fragments, and the CLI and browser derive the same AES-256-GCM key locally from the salt and the password with PBKDF2-HMAC-SHA256 at 600,000 iterations.

+

The self-hosted relay does not change any of that. The docs put it plainly: terminal frames remain opaque to the relay when the CLI's default end-to-end encryption is used. What changes is who operates the box that sees the metadata, and whose uptime you depend on.

+
+ +
+

Self-hosted terminal sharing: bringing up the standalone relay

+

The standalone deployment lives in the standalone directory of the shell.online repository. It is a single Node.js service speaking WebSockets, keeping session metadata in a local state file, fronted by Caddy for TLS. The published image is ghcr.io/teoslayer/shell.online-relay, built for amd64 and arm64. Requirements from the self-hosting document: Docker Engine with Compose, a public domain, and ports 80 and 443. Point the domain's A or AAAA record at the host first, then:

+
git clone https://github.com/TeoSlayer/shell.online.git
+cd shell.online/standalone
+
+SHELL_ONLINE_PUBLIC_URL=https://relay.example.com \\
+SHELL_ONLINE_SITE=relay.example.com \\
+docker compose up -d --build
+

Two variables, two jobs. SHELL_ONLINE_PUBLIC_URL is the exact public origin the relay writes into share links and checks browser origins against. SHELL_ONLINE_SITE is the Caddy site address; giving it a hostname is what turns on automatic HTTPS. Caddy's documentation is specific about why the ports matter: the HTTP challenge needs port 80 reachable from outside, the TLS-ALPN challenge needs port 443, and Caddy enables both by default. Leave it at the default of http://localhost and Caddy serves plain HTTP, which is what the local smoke test in the docs relies on:

+
docker compose up -d --build
+curl http://localhost/api/health
+

The compose file is worth reading before you trust it, and it is short. The relay container runs with a read-only root filesystem, drops all capabilities, sets no-new-privileges, runs as a non-root user created in the Dockerfile, and mounts a small tmpfs at /tmp. Caddy publishes 80, 443, and 443/udp and proxies to the relay on port 8080 through a four-line Caddyfile. The health check hits /api/health every thirty seconds, and Caddy waits for the relay to report healthy before it starts.

+
+ +
+

Pointing the CLI at your relay

+

You do not reinstall or rebuild the CLI. The CLI reference lists SHELL_ONLINE_SERVER as the default relay URL and a per-invocation --server flag that overrides it:

+
SHELL_ONLINE_SERVER=https://relay.example.com shell claude
+shell --server https://relay.example.com claude
+

Everything else behaves as it does against the hosted service. The share URL now points at your domain, the browser page is served by your relay (the Docker build bundles the web client into the image), and read-only mode, --auto-close, --persistent, and the rest all work because the standalone server enforces the same protocol rules. The docs list them: authentication, same-origin browser policy, read-only mode, the input lease that stops two people typing over each other, viewer and frame and traffic limits, slow-client protection, the stable terminal grid, and task-bound expiry. One small tell that this was done properly rather than bolted on: the --no-e2ee output now refers to whatever relay you configured instead of assuming it is Cloudflare.

+

The access rule is unchanged too, and it needs saying every time. The URL plus the password is a bearer credential. Anyone who has both can watch the session, and unless you started it with --read-only, can type with the permissions of the wrapped process. Running your own relay does not soften that. What it does give you is a domain you control, so a leaked link at least points at infrastructure you can shut off, and shell password rotate followed by the session ID swaps the password and salt on a running session without killing the process.

+
+ +
+

What survives a relay restart, and what the state volume holds

+

The relay keeps a state file, by default at /var/lib/shell-online/relay.json inside the container, on a named volume called relay-state. According to the self-hosting docs it stores session metadata and host-token hashes so that a client started with --persistent can recover the same identity after the relay restarts. It does not store terminal contents, end-to-end encryption keys, or browser passwords. Back the volume up if you care about persistent links surviving a rebuild, and run exactly one relay replica per volume; this is a single-node deployment by design. Live sockets reconnect after a restart, which matches the behavior documented on our reliability page: the CLI and browser both reconnect with bounded backoff, and a temporary failure does not stop the wrapped process.

+

Two things the standalone relay does not include: the optional accounts app (organizations, session vault, browser-started sessions) and the hosted analytics dashboard. Accounts are a separate service that never participates in terminal transport, so a self-hosted relay works without them. If you want the org features against your own relay you deploy the app in the repo's app directory separately; it uses Firebase Authentication and PostgreSQL and is documented in its own README.

+
+ +
+

When this is the right call

+

Most people should keep using the hosted relay. It is already up, it needs no setup, and the encryption boundary is the same. Self-hosting makes sense in a few specific situations. Your compliance regime wants the relay's metadata (IPs, timing, command labels) inside your own perimeter. Your team already runs a Docker host with a domain and you would rather own one more small service than depend on someone else's status page. Or you were reminded this week that a tunnel provider can have an eleven-hour incident, and you want the remote path to a training box or an unattended coding agent to fail on your terms rather than theirs. In each of those cases the deployment is a clone, two environment variables, and a compose up, and the CLI you already have on every laptop and server keeps working with one variable changed.

+

For the background on how the browser link itself works, the earlier post on shell.online as a live terminal browser link covers the session model. We build shell.online at Pilot Protocol, it is MIT licensed, and the standalone server is one readable TypeScript file, so you can check every claim in this post against the source before you run it.

+
+ +

Run the relay where you want it

The hosted service works out of the box. When you need the relay on your own domain, the standalone Docker deployment is in the repo, and the CLI switches with one environment variable.

Try shell.online
diff --git a/blog/posts/self-hosted-terminal-sharing-run-the-shell-online-relay-on-one-docker-host-inste.html b/blog/posts/self-hosted-terminal-sharing-run-the-shell-online-relay-on-one-docker-host-inste.html new file mode 100644 index 0000000..1bdd5a9 --- /dev/null +++ b/blog/posts/self-hosted-terminal-sharing-run-the-shell-online-relay-on-one-docker-host-inste.html @@ -0,0 +1,55 @@ +

Cloudflare's status page opened an incident titled "Issues with Durable Objects" at 00:03 UTC on 25 September 2026 and resolved it at 14:49 UTC the same day. The hosted shell.online relay is a Cloudflare Worker, and its live sessions are Durable Objects. That is a dependency you can read in the source, and since v0.12.2 it is also one you can remove: the repo ships a standalone relay for self-hosted terminal sharing on a single Docker host, with no Cloudflare account involved.

+ +
+

What the hosted relay is built on

+

Cloudflare describes a Durable Object as "a special kind of Cloudflare Worker which uniquely combines compute with storage", with a globally unique name that lets it coordinate multiple clients (Durable Objects documentation).

+

In the shell.online repository, wrangler.example.jsonc binds a SESSIONS namespace to a class named TerminalSession, and worker/index.ts exports TerminalSession as a subclass of DurableObject. A comment beside the screen-snapshot code shows one consequence: Durable Object values stop at 128 KiB, so the last screen a host sent is stored in 64 KiB chunks and deleted with the rest of the session when it expires. The self-hosting guide lists the platform requirements for that path: Durable Objects, Rate Limiting, Analytics Engine, and static assets.

+

On the incident, the first update on the Cloudflare status entry described new Workflows instances stuck in a Queued state; the 02:32 UTC update said Cloudflare was "investigating issues causing an increase of errors for Durable Objects". Cloudflare rated the impact minor. We are not claiming the hosted relay failed during that window; we did not measure it for this post and will not guess. The point is narrower. If your sessions run through a relay whose session state lives in someone else's Durable Objects, their incident page is on your dependency list.

+
+ +
+

Self-hosted terminal sharing on one Docker host

+

The standalone relay arrived in v0.12.2 on 12 September 2026. The changelog describes it as a single-node relay for ordinary Docker hosts using Node.js, WebSockets, local metadata state and Caddy-managed TLS, requiring no Cloudflare account or credentials. It lives under standalone/ in the repo: server.ts, a Dockerfile, compose.yaml and a four-line Caddyfile. A published image, ghcr.io/teoslayer/shell.online-relay, is built for amd64 and arm64.

+

Requirements, per the self-hosting guide: Docker Engine with Compose, a public domain, and ports 80 and 443. Point the domain's A and AAAA records at the host first, then:

+
git clone https://github.com/TeoSlayer/shell.online.git
+cd shell.online/standalone
+
+SHELL_ONLINE_PUBLIC_URL=https://relay.example.com \\
+SHELL_ONLINE_SITE=relay.example.com \\
+docker compose up -d --build
+

Caddy obtains the certificate on its own. Its documentation gives the conditions: the A/AAAA records point at your server, ports 80 and 443 are open externally, and the data directory is writable and persistent, after which "sites will be served over HTTPS automatically" and Caddy keeps the certificates renewed (Caddy automatic HTTPS). The Caddyfile in the repo only enables compression and reverse-proxies to the relay container on port 8080. For a local test with no domain, the defaults work at http://localhost:

+
docker compose up -d --build
+curl http://localhost/api/health
+

The relay container in compose.yaml runs with a read-only root filesystem, drops all capabilities, sets no-new-privileges, runs as an unprivileged user created in the Dockerfile, and has a health check that fetches /api/health every 30 seconds. Caddy starts only once that check passes. State goes to a named volume, relay-state, mounted at /var/lib/shell-online.

+
+ +
+

Pointing the CLI at your relay

+

Nothing about the installed CLI changes. The built-in reference documents --server as "Override the relay URL. Defaults to $SHELL_ONLINE_SERVER, then https://shell.online." Either of these starts Claude Code through your own relay:

+
SHELL_ONLINE_SERVER=https://relay.example.com shell claude
+shell --server https://relay.example.com claude
+

The CLI prints a link on your domain, a random ten-character password and a QR code, as it does against shell.online. The session commands are unchanged: shell --read-only for a view-only link, shell --auto-close 5m for a hard deadline, shell --persistent with a state file for a link that survives a host restart, shell list and shell attach on the host. The relay you run enforces the same protocol rules as the hosted one. The guide lists them: authentication, same-origin browser policy, read-only mode, the input lease, viewer, frame and traffic limits, slow-client protection, a stable terminal grid, and task-bound expiry. The numbers are in standalone/server.ts, for example a 64 KiB cap on a live frame and a 1.8 second typing lease that keeps two viewers from interleaving input.

+

The credential model is unchanged too, and moving the relay in-house does not soften it. The URL and the password together are a bearer credential. Anyone holding both can view the session and, unless it was started read-only, type with the permissions of the wrapped process. Your relay adds no login in front of that; if you want one, put it in front of Caddy yourself.

+
+ +
+

What your relay stores, and what it still cannot read

+

End-to-end encryption does not depend on who runs the relay. The host and browser derive an AES-256-GCM frame key locally using PBKDF2-HMAC-SHA256 with 600,000 iterations, from the password and the random salt in the link's #salt= fragment (encryption details). Terminal input, output, snapshots and latency probes reach the relay as authenticated ciphertext. The self-hosting guide repeats this for the standalone build: terminal frames remain opaque to the relay when the CLI's default E2EE is used.

+

What the relay does see is timing, addresses, encrypted sizes, labels and lifecycle metadata, and it can delay or drop traffic. That holds for a relay on your hardware, with one practical difference: the operator who sees that metadata is now you. The relay-state volume holds session metadata and host-token hashes so a persistent client can recover its identity after a relay restart. It does not hold terminal contents, encryption keys, or browser passwords. Back it up and run one replica per volume.

+

Passing --no-e2ee lets the relay read terminal contents. On a relay you control that is a smaller exposure than on a shared one, but it is still an exposure, and the docs call it a compatibility and debugging choice rather than a mode. The security model page covers what the flag gives up.

+
+ +
+

Restarts, dropped networks, and expiry on your own relay

+

The lifetime rules are shared code, not a hosted-only policy. shared/session-lifetime.ts sets a 12 hour TTL for an ordinary session and 30 days for a persistent one, and both relays import the same disconnectedSessionExpiry function. The reliability page states the user-facing version: an ordinary disconnected share can recover its link for 12 hours, and a temporary browser or network failure does not intentionally stop the session, because the process runs on the host machine, not on the relay.

+

Relay restarts matter most when you own the box. The guide says live sockets reconnect after a restart, and the resume endpoint in standalone/server.ts rejects a resumed session with a 403 unless the host-token hash matches and the persistent, read-only and encrypted flags are identical to what was recorded. That check is what stops a restarted host from quietly turning a read-only link into an interactive one. With the persistent Docker client against your relay, the same URL and password come back after both sides restart: the client keeps its identity in its state file, and the relay keeps the matching metadata in relay-state.

+

When the wrapped process exits, the session closes on its own, and auto-close deadlines apply as usual. If your relay is down, viewers see a disconnected session but nothing happens to the process; check the host first, and use shell attach on the machine to confirm the process is alive without going through the browser at all.

+
+ +
+

What you give up, and the middle option

+

The standalone relay does not include the optional accounts app or the hosted analytics dashboard, and standalone/server.ts has no MCP routes, so scoped MCP grants stay a Worker feature. That means no organization session list, no sealed password handoff between teammates, and no starting sessions from the web app. Those features are described in the overview of how a browser link to a terminal works; the accounts app can be self-hosted separately from app/ with PostgreSQL, but that is a second deployment, not part of the relay.

+

If you want your own relay on Cloudflare rather than off it, copy wrangler.example.jsonc to wrangler.local.jsonc, run npm ci and npm run build, and deploy with wrangler. That changes who pays and who holds the data, not which platform incidents you are exposed to. The Docker path is the one that removes the dependency. We built shell.online at Pilot Protocol with the hosted relay as the default because most people just want the link, but the standalone server speaks the same protocol with a smaller trust surface, and after a day like 25 September it is worth knowing it is there.

+
+ +

Run the relay where your sessions already are

One compose file, one domain, and the same CLI. Terminal contents stay encrypted end to end whichever relay carries them.

Try shell.online
diff --git a/blog/posts/share-a-terminal-session-in-the-browser-without-handing-over-the-filesystem-shel.html b/blog/posts/share-a-terminal-session-in-the-browser-without-handing-over-the-filesystem-shel.html new file mode 100644 index 0000000..763a0e9 --- /dev/null +++ b/blog/posts/share-a-terminal-session-in-the-browser-without-handing-over-the-filesystem-shel.html @@ -0,0 +1,41 @@ +

This summer's coding-agent security research kept landing on the same shape of bug: the agent stays inside its sandbox, writes a file, and something trusted on the host runs that file later. Today we shipped shell.online v0.14, which adds file access to a shared terminal, so the timing calls for a plain description of the design. When you share a terminal session in the browser with shell.online, a viewer gets the live process and nothing else. As of v0.14 you can additionally opt in one directory. This post covers the exact flags, what the browser can and cannot ask for, and why the file layer is read-only and kept apart from the terminal itself. The basics of the live link are in our earlier post on the browser link for any terminal.

+
+

The pattern behind this summer's sandbox escapes

+

Pillar Security published its Week of Sandbox Escapes series on July 20, covering seven vulnerabilities across Cursor, Codex, Gemini CLI, and Antigravity. Their one-sentence summary of the mechanism: the agent only had to write something that a trusted component outside the sandbox would later run, load, scan, or treat as safe. They sorted the findings into four failure modes, and two of them are about files: workspace configurations that are really executable code, and command allowlists that trust a command name instead of the full invocation.

+

On September 2, The Hacker News reported Manifold Security's GitSpawn findings. A repository's own .git/config can set core.fsmonitor to an attacker's command, and the command executes as the user, outside the agent's sandbox and without an approval prompt, the moment an agent refreshes the Git index. Seven agents were affected, Claude Code, Codex, and Cursor among them.

+

Then on September 10, Upstarts Media covered Accomplish, a Tel Aviv startup that reported further sandbox escapes to Anthropic, Cursor, and OpenAI. One of the fixes took about 50 days. Co-founder Or Hiltch's line in that piece was that there is a lot of talk about security now and it does not really show in how products get built.

+

The thread running through all three is a file in the workspace that was treated as inert when it was not. That is the framing we used for the file layer in v0.14.

+
+
+

What --files adds when you share a terminal session in the browser

+
shell --files claude
+shell --files-root ./artifacts python train.py
+

File access is off unless one of those flags is present. The first scopes it to the command's working directory. The second picks a different root and implies the first. The CLI opens the root with Go's os.Root, so parent traversal and symlink escapes fail at the filesystem boundary instead of in a string-prefix check. The implementation is short and worth reading: shared_files.go in the shell.online source. Directory listings skip symlinks and anything that is neither a regular file nor a directory, and a read of a device file or other non-regular file is refused.

+

The file service understands exactly three request types: capabilities, list, and read. There is no write, no rename, no delete, and no execute. A viewer cannot put a file into the shared root through the browser, which is the whole class of problem in the research above. If the wrapped process writes a file, that is the process acting with its own permissions, exactly as it would with no share running.

+

Nothing is pushed. The browser receives no listing and no contents until it asks, and the Files control only appears in the viewer when the host opted in. Reads are pulled by the viewer in 16 KiB chunks, so a slow phone connection cannot apply back-pressure to the PTY. Previews are capped at 16 MiB and downloads at 128 MiB. A file over the cap is refused rather than truncated. The CLI keeps at most eight file requests in flight and answers a ninth with a BUSY error. The full flag list for this release is in the v0.14.0 changelog entry of the shell.online repository.

+
+
+

Encrypted end to end, or not at all

+

Files ride the same end-to-end encrypted WebSocket as the terminal frames. The CLI encrypts the listing and each chunk before they reach the relay, and the browser decrypts with the key derived from the ten-character password the CLI printed. The URL fragment carries only a random salt. What the relay routes is a request ID and an encrypted frame size; it does not see paths, names, or contents. That is the same boundary described on the shell.online end-to-end encryption page for terminal output.

+

Because of that, the file flags refuse to combine with --no-e2ee. The CLI exits and says that --files and --files-root require end-to-end encryption. There is no transport-only mode for files.

+

The credential model has not changed. Anyone holding both the URL and the password can open the share, and if the share is interactive they can also type with the permissions of the wrapped process. Adding --files widens what a holder of that pair can read, so the decision to opt in a directory deserves the same care as the decision to send someone the password.

+

The URL and the password together are a bearer credential. With --files, that credential also reads every regular file under the chosen root. Pick a narrow root such as ./artifacts rather than a home directory, and use shell password rotate to revoke a pair without restarting the process.

+
+
+

Read-only viewers, Refstream previews, and a scoped agent

+

A link created with shell --read-only --files can open the opted-in files but still cannot type into the process; browser input is blocked by the server as before. That combination fits a training run. A viewer watches the log and opens the latest metrics file from a phone, and there is no path by which they could send a keystroke.

+

v0.14 also adds a renderer dropdown. xterm.js remains the default. The alpha Refstream renderer turns filename-like terminal output into previews backed by the file service, which is handy when a build prints the path of a report. The rule that matters: terminal text is never treated as filesystem authority. A path printed by the process is only a hint the viewer can click. Every open still goes through the same rooted, read-only request path, so a process that prints ../../etc/passwd produces a denial from the CLI, not a preview.

+

Refstream also offers a Connect agent invitation, and it is separate from file access. You copy one scoped invitation into an agent chat and choose either read, which lets the agent read, search, and wait for terminal output, or control, which also lets it type, run commands, and send key combinations such as Ctrl-C. The invitation is revocable and bound to one live session. Disconnecting the agent does not stop the terminal, and files stay unavailable unless the host separately started the share with a file flag. The agent skill published at shell.online/skill instructs an agent to enable file sharing only when the operator explicitly asks, and to say which root it shared.

+
+
+

Edge cases worth knowing before you rely on it

+ +

All of this is in the CLI reference under the files topic, and shell help files prints the same text offline. shell.online is MIT licensed and built by us at Pilot Protocol; the file service, the relay worker, and the browser panel are all in the public repository, so every claim in this post can be checked against the code.

+
+

Share the terminal, and only the files you chose

One command prints a URL and a password. Add --files or --files-root to let viewers open a single directory over the same encrypted channel, read-only, with nothing sent until they ask.

Try shell.online
diff --git a/blog/posts/share-a-terminal-session-password-with-a-teammate-without-the-service-seeing-it.html b/blog/posts/share-a-terminal-session-password-with-a-teammate-without-the-service-seeing-it.html new file mode 100644 index 0000000..a5c16bf --- /dev/null +++ b/blog/posts/share-a-terminal-session-password-with-a-teammate-without-the-service-seeing-it.html @@ -0,0 +1,49 @@ +

GitHub shipped local sandboxing for the Copilot app today. The changelog puts the point in one sentence: it "helps reduce the potential impact of unintended commands by limiting access to files, network resources, and credentials on your machine." The day before, OpenAI rolled GPT-6 Sol and Luna into Codex and the API at half the price of the 5.6 series. Both circle the same problem. Coding agents now run long enough, and cheaply enough, that other people need to look at them, and the moment a second person opens a session the question becomes who holds the credential. shell.online v0.23.1, released today, adds a way to share a terminal session password with a teammate where the service in the middle never gets a copy it can open.

+ +
+

Where the password lives before anyone asks for it

+

Start a session with the CLI and it prints a link, a password and a QR code:

+
shell --name "GPT-6 migration" codex
+

The process stays on your machine. The link and the password together are the access credential: anyone holding both can open the terminal and, unless the session was started with --read-only, type into it with the permissions of the wrapped process. The security model page says to treat the QR code the same way, since it carries the password for one-scan access. The relay in between forwards ciphertext. The browser derives the key from the password and the salt in the URL fragment and decrypts locally, which is why the password is a real secret and not a convenience.

+

That is the whole model without an account. Nothing in this post changes it. What v0.23.1 changes is what happens when you have linked the machine with shell auth, joined an organization, and a colleague opens the session from the app without having been handed the password.

+
+ +
+

What a teammate sees, and what the owner does

+

Inside an organization, everyone sees every member's sessions in the app. A session this browser has no password for shows a small lock beside its name. Open it and the unlock prompt offers the usual choices, vault or session password, plus a button labelled "Ask owner for the password". The asker's pane then reads "Asked owner for the password. This opens by itself when they accept."

+

The owner gets a blue count on the Share button, a "Password requests" item in the Share menu, and a Password requests section on the session page with Accept and Decline for each pending request and the last ten answers. Accepting takes one click. On the asker's side, the next poll brings the sealed copy and the terminal opens without them typing anything. A declined request shows "Ask again". The whole exchange is recorded in the team's audit trail as a handoff entry.

+

That description comes from the UI components in the shell.online repository, specifically the terminal pane's gate and the PasswordRequests component under app/src. The changelog lists the feature under v0.23.1.

+
+ +
+

Why the service cannot read the password it relays

+

The interesting part is the accept path. The accounts service holds no copy of any session password it can open. Every password it stores is sealed to an account's vault public key using ephemeral ECDH P-256, HKDF-SHA256 and AES-256-GCM, bound to the session ID and the recipient. The matching private key sits encrypted under a random vault key, and that vault key is wrapped only by a recovery key the service never receives. So when the owner clicks Accept, their own browser does the work: it unlocks their vault, reads the session password, seals a fresh copy to the asker's vault key, and sends that sealed blob along with the approval. The server writes the sealed copy and the answer in one transaction, so a request can never read as approved without the password having gone with it. The migration comment in the repo says it plainly: "The service never holds a password it can open, so it keeps only the ask."

+

A few rules fall out of the server route directly:

+ +

There is one trust decision the browser makes rather than the math. If a teammate's vault key has changed since the owner last sealed something to them, the owner is asked to confirm before anything is sealed to the new key. That is either a colleague who reset their vault or a key that is not theirs, and the app treats the first key it sees for a person as trust-on-first-use. The security policy in the repo is explicit that the hosted web app is trusted while it is open, since it performs the unlock and the sealing; the vault protects stored data and database copies, not against malicious JavaScript served during that unlock.

+
+ +
+

Share a terminal session password without the service seeing it: the edge cases

+

A password request is a way to grant terminal access, so the same consequences apply as when you paste the password into a chat. The teammate who receives it can type into the process. If they only need to watch, the owner should have started the session read-only in the first place, since --read-only rejects both browser and MCP input and a request-granted password does not change that. If access needs to end, the owner runs:

+
shell password rotate <ID>
+

Rotation changes the password, salt and cipher generation, disconnects current viewers, revokes MCP grants and replaces the sealed copies from the previous generation in the account registry. It cannot erase output a viewer already received. The CLI reference on shell.online lists the exact behaviour of each subcommand.

+

Network loss on the host does not affect any of this, because the request and answer live in the accounts service, not the relay. The host reconnecting after a dropped connection keeps the same session and the same password, and a copy already sealed to the teammate still opens it. The requests table is keyed to the session row and deleted with it, so nothing about a request outlives the session record.

+

Signing out locks the vault in that browser, which means an approved copy stays sealed until the teammate unlocks again. That is why the approved-state message says to unlock the vault if the pane does not open in a moment. And v0.23.1 also tightened the local side of the same story: looking up sessions no longer deletes a live host's control socket or saved credentials after a denied or slow probe, and a persistent launch reserves local control before it resumes the relay so a second launch cannot overwrite the first one's credentials. Those fixes matter most for long-running sessions, which are the ones people ask each other for.

+
+ +

Linking a machine is optional and the CLI works identically without it. What the account learns is the share URL, command name, host name and timing. It never receives terminal contents or the browser password. A password request, like the existing share action, moves a password between colleagues only as a copy sealed in the owner's browser to the recipient's vault key.

+ +
+

Why this is the right week for it

+

GitHub's sandboxing entry is careful to note that the local policy does not apply to cloud sandbox sessions or sessions running on a remote host, and that if the operating system cannot enforce the requested policy, "the sandboxed shell fails with an error rather than running without a sandbox." That is the right default, and it is the same instinct behind refusing a password request when there is no vault to seal to. Tools are converging on the position that a middle layer which cannot enforce a boundary should say so rather than pretend.

+

The GPT-6 rollout makes the volume side concrete. OpenAI says GPT-6 Sol is aimed at complex tasks like coding and makes about half as many mistakes as its predecessor, and TechCrunch reports the new versions are available in Codex for most paid accounts. Cheaper, more reliable agents mean more sessions running unattended, which is the case the original browser-link write-up was built around. A phone view of a running Codex session is only useful to a teammate if they can get in, and getting in should not require the owner to paste a secret into Slack.

+

This is also the pattern we care about at Pilot Protocol, where the overlay network exists so agents can reach each other over encrypted tunnels without a central party reading the traffic. A password request is the small, human-scale version of that: the coordination goes through the service, the secret does not.

+
+ +

Give a teammate the session, not the machine

Start any agent or command with one line, link the machine if you want the app, and let colleagues ask for access instead of chasing you for a password.

Try shell.online
diff --git a/blog/posts/shell-online-live-terminal-browser-link.html b/blog/posts/shell-online-live-terminal-browser-link.html new file mode 100644 index 0000000..df49c4c --- /dev/null +++ b/blog/posts/shell-online-live-terminal-browser-link.html @@ -0,0 +1,68 @@ +

A terminal has always been stuck on one screen, on one machine, in front of one person. Start a build, a training run, or a coding agent, walk away, and whatever it is doing becomes invisible until you come back to that desk.

+ +

shell.online removes that constraint. Prefix any command with shell and it prints a URL:

+ +
$ shell npm run build
+https://shell.online/s/<share-id>
+ +

Open that URL on a phone, a tablet, or someone else's laptop, and you are looking at the live terminal. Not a video of it, not a screenshot, but the real session, rendering as it runs. The process itself never moves. It keeps running on the machine that started it, with the same files and the same permissions it always had.

+ +
+

Watch, or type

+ +

Links are interactive by default: whoever opens one can send keystrokes to the session. Add --read-only and the link becomes view-only, and that decision is fixed when the session is created:

+ +
$ shell --read-only python train.py
+ +

Read-only is enforced by the server, not by hiding a text box in the page. Browser developer tools or handcrafted WebSocket frames cannot promote a view-only link into a typing one.

+ +

Sessions run in the background, so your terminal stays free. shell list shows what is live, including whether the public link is still online independently of whether the local process is. shell attach takes a session over locally without cutting off browser viewers, and shell kill ends it. A share also closes on its own when its task exits.

+
+ +
+

Why this matters for agents

+ +

Coding agents changed the shape of this problem. An agent can now work unattended for hours, and the only way to know what it is doing is to be sitting at the machine it runs on.

+ +

Inside a Claude Code session, shell claude starts a shareable fork of that conversation and its workspace, so you can follow the work from a phone and step in when it needs a decision. Any command-line tool behaves the same way, whether that is a Claude agent team or an OpenClaw fleet. For agents driving the CLI themselves, shell --json emits a single structured line containing the share URL, session ID, and access mode, which the agent can hand to its operator through whatever channel it already uses. There is a ready-made skill at shell.online/skill for exactly that, in the same spirit as the skills Pilot ships for agent tooling.

+ +

This is the same problem Pilot Protocol works on from the other side. Pilot gives agents addresses, encrypted tunnels, and a trust model so they can reach each other without a platform in the middle, which we cover in detail in the overlay network architecture post and in the documentation. shell.online covers the moment a person needs to see what one of them is actually doing.

+
+ +
+

The link is the key

+ +
+

Worth being blunt about, because it is the first question anyone sensible asks: the share URL is a bearer credential. Anyone holding an interactive one can type into that session with the same operating-system permissions as the process it wraps. That is why view-only links exist, why sessions can be killed on command, and why the documentation says so in plain language rather than burying it. The same posture runs through our own trust model.

+
+ +

For work where the relay should not be able to read the screen at all, --e2ee encrypts terminal frames with AES-256-GCM before they leave your machine. The key travels in the URL fragment, which browsers never send to a server, or is derived in the browser from a password you share separately. What encryption does not hide is equally explicit: connection IPs, timing, frame sizes, and session lifecycle metadata stay visible to the relay.

+ +

Terminal contents are never stored server-side in either mode. The machine keeps a bounded 512 KiB replay buffer in memory so a viewer arriving late sees the current screen. When the task exits, the session state is deleted and the link stops working.

+
+ +
+

What is underneath

+ +

A Go binary owns the local terminal and mirrors its input and output. A Cloudflare Worker handles session creation, rate limiting, and static assets, and one coordinator object per terminal connects the machine to its viewers and enforces that session's access mode. In the browser, xterm.js renders the real thing: ANSI colour, alternate screens, mouse sequences, and a terminal continuously refitted to the actual viewport rather than a CSS box.

+ +

That last detail matters more than it sounds. A phone is not a small desktop. The browser tracks the visual viewport through keyboard, rotation, and browser-chrome changes and resizes the actual PTY, one viewer owns sizing at a time so the display does not jump when several people watch, and paste is chunked into 16 KiB frames with backpressure so one large paste cannot flood the session.

+ +

Network failures are treated as display failures, never as permission to kill local work. The CLI and browser reconnect on their own, and a disconnected session keeps a 15-minute grace window.

+
+ +
+

Install

+ +
curl -fsSL https://shell.online/install | sh
+ +

macOS and Linux, on arm64 and amd64. The installer verifies the binary's checksum before writing it, never invokes sudo, and never silently edits your shell configuration. There is also a Homebrew tap and a Docker image that keeps one stable encrypted link and workspace across container restarts. No accounts, and nothing for the person opening your link to install.

+ +

shell.online is MIT licensed and open source at github.com/TeoSlayer/shell.online. If you want your own tool reachable by agents the same way, see the agent app store and how to turn an API into an agent app.

+
+ +
+

Share your first terminal

+

One command turns a running process into a link you can open anywhere. Interactive or read-only, encrypted if you want it.

+ Try shell.online +
diff --git a/blog/posts/start-a-terminal-session-from-the-browser-on-a-mac-that-sat-idle-for-days-the-la.html b/blog/posts/start-a-terminal-session-from-the-browser-on-a-mac-that-sat-idle-for-days-the-la.html new file mode 100644 index 0000000..fbaaec7 --- /dev/null +++ b/blog/posts/start-a-terminal-session-from-the-browser-on-a-mac-that-sat-idle-for-days-the-la.html @@ -0,0 +1,45 @@ +

Two of the most used coding agents now assume a machine that stays up and answers when you come back to it. OpenAI's Codex 0.157.0, released today, enables "automatic background-server startup for eligible interactive sessions". Anthropic's Remote Control documentation says Claude "keeps running locally the entire time", and that when a laptop sleeps or the network drops it "reconnects automatically when your machine comes back online". shell.online takes the same position for any terminal program: link a machine once, and you can start a terminal session from the browser on it later, from wherever you are. Today's v0.23.3 tag fixes a problem that only shows up once a Mac has been doing exactly that for a few days.

+ +
+

How you start a terminal session from the browser on a linked machine

+

Account linking is optional and the CLI works identically without it. When you do link, the machine is what gets linked, never a terminal. Remote start is a separate, explicit choice on top of that:

+
curl -fsSL https://shell.online/install | sh
+shell auth --allow-remote-start
+shell service install
+

The app guide describes the second line as "Allow your signed-in browser to start sessions on this machine" and adds a warning worth repeating: "The process runs on that machine, not on an app server. Enabling this grants remote execution authority; choose it deliberately." Withdraw it at any time with the same command and --no-remote-start, pause the listener with shell daemon stop, and check it with shell daemon status.

+

The third line is what makes a machine useful days later. Without it, the small listener that accepts browser-start requests runs only after you have typed a shell command, and stops at the next reboot. With it, the CLI writes a user launch agent under ~/Library/LaunchAgents on macOS, which is where Apple's launchd documentation says per-user agents belong, and launchd keeps the listener alive across restarts. The install command refuses to run if remote start is off, because a service for a machine that does not accept browser starts would keep a process alive to do nothing.

+

From then on, a signed-in browser at app.shell.online can start Claude Code, Codex, or a plain shell on that Mac. The password for a browser-started session is picked in the browser and sealed to that one machine with an ephemeral P-256 key the machine published, so the accounts service relays an envelope it cannot open. That detail matters for the fix below.

+
+ +
+

Five seconds per keystroke, and the network was fine

+

The report that led to v0.23.3 is written up in pull request #268. Some sessions hosted on a Mac took around five seconds on average to echo a keystroke, and at times over thirty. The TCP connect time to the relay was about ten milliseconds, so the relay and the link were not the problem. The host was.

+

The launch agent that shell service install wrote declared the job with ProcessType set to Background. The launchd.plist manual is direct about what that means: "The resource limits applied to Background jobs are intended to prevent them from disrupting the user experience." Apple's quality-of-service guide describes that class as work that "focuses on energy efficiency" and "takes significant time, such as minutes or hours". Not a terminal waiting for a keypress.

+

Every session the daemon starts from the browser is a child of that launchd job, and it inherits the clamp. The pull request measured it: the lowest CPU priority, 4 where a normal process runs at 31, plus throttled disk I/O. The I/O part is what turned a scheduling nuisance into a thirty-second stall. When the machine ran short of memory, macOS swapped the idle sessions out. On the affected Mac, Claude Code sessions two to three days old had 90 to 96 percent of their memory swapped according to vmmap. Each keystroke then had to page the heap back in at background I/O priority, queued behind Spotlight and whatever build was running. The one session someone had started by hand from a terminal ran at normal priority and had only 37 percent swapped.

+

The clamp cannot be lifted after the fact. The pull request notes that taskpolicy -B does not raise a running process out of the background class; only launchd can, at launch. So the fix has to be in the plist, and existing sessions keep the priority they were born with until they are restarted.

+
+ +
+

What v0.23.3 changes, and what it deliberately does not

+

The plist now says Interactive. The manual describes that class as jobs that "run with the same resource limitations as apps, that is to say, none", and tells you to use it only "if an app's ability to be responsive depends on it, and cannot be made Adaptive". Adaptive moves a job between classes "based on activity over XPC connections", which is not how a Go process relaying pseudo-terminal frames over WebSockets talks to anything, so Interactive is the honest choice. The comment next to the key in service_darwin.go records the swap behaviour in the file itself.

+

The part that took a decision rather than a one-line edit is upgrading. Installing v0.23.3 does not rewrite an existing launch agent. Reloading the agent restarts the daemon, and a restart re-keys it, which drops the keys browsers have sealed passwords to. Instead, shell service status now checks the installed plist for the old Background value and prints a plain instruction. From the source:

+
Installed at <path>
+It runs sessions at background priority, which makes idle ones slow to respond.
+Run 'shell service install' to refresh it. Running sessions keep the old priority until restarted.
+

So the upgrade path on an existing Mac is one command, run when you choose: shell service install. Sessions started after that run at normal priority. The changelog entry for 0.23.3 records the same thing. Linux is untouched, because the systemd unit never lowered the daemon's priority in the first place.

+
+ +
+

What a browser-started session does and does not expose

+

Since the fix lives in the daemon layer, here is exactly what that layer sees. Linking a machine publishes the share URL, the command name, the host name, and timing to the account. It never publishes terminal contents and never publishes the browser password. Terminal traffic between the CLI and a viewer is end-to-end encrypted by default; the encryption notes cover the frame cipher and what an explicit --no-e2ee gives up.

+

The credential model is the same as for any share. The URL and the eight-character password together are a bearer credential: anyone holding both can view and, unless the session was started read-only, type with the permissions of the wrapped process. A browser-started session runs on that machine as your user, through sh on Unix. That is the "remote execution authority" the docs warn about, and it is why remote start is asked for rather than assumed. If you only want to watch a long job, start it with shell --read-only and give out that link instead.

+

Edge cases behave as they do for a session started at the keyboard. The reliability page puts it this way: "The process runs on your computer. A temporary browser or network failure does not intentionally stop it." The CLI and browser retry on their own, the session ends when its process exits or earlier with --auto-close, and the README states the one rule no plist can change: a sleeping or offline host cannot be controlled. The launch agent keeps the listener alive across restarts. It does not keep a closed laptop awake.

+
+ +
+

Why this class of bug will keep appearing

+

Codex starting a background server on its own today, and Claude Code reconnecting after sleep, both point the same direction: a developer's machine is becoming a host that is expected to be responsive to a request that arrives days after the last keypress. Operating systems make reasonable assumptions about background jobs, and those assumptions are wrong for a terminal a person is about to type into from a phone. The shell.online daemon is one example; any always-on agent host that installs itself as a service will have to pick a process class, and the default is the slow one.

+

If you want the plain version of what a share link is before setting up a linked machine, the introduction to live terminal links covers the one-command case. The source is MIT licensed, and the service installer, the status check, and the tests that assert the plist never says Background are all in cmd/shell for anyone who wants to read the change rather than take this post's word for it.

+
+ +

Link a machine, then open it from anywhere

One install, one auth command, and a session you can start from a phone. The process stays on your hardware, the password stays sealed to it, and after v0.23.3 the keystroke comes back at the speed you expect.

Try shell.online
diff --git a/blog/posts/team-terminal-sharing-sealed-so-the-service-cant-open-it.html b/blog/posts/team-terminal-sharing-sealed-so-the-service-cant-open-it.html new file mode 100644 index 0000000..bcc8a96 --- /dev/null +++ b/blog/posts/team-terminal-sharing-sealed-so-the-service-cant-open-it.html @@ -0,0 +1,50 @@ +

The 0.11.1 release of shell.online, dated 2026-09-08 in the changelog, is the first version where you can share terminal sessions with your team through an account instead of pasting a URL and a password into chat. An organization, invite links, an owner and an assignee per session, handoffs, comments, and a browser that can start a coding agent on a colleague's linked machine. What the accounts service holds while all of that happens is the interesting part: never a session password it can read, and never terminal output.

+ +
+

Linking a machine publishes four things

+

Nothing here is required. The CLI works identically without an account, and a share started with a single shell command still prints its URL and eight-character password the way it always did.

+
shell login                 # opens a browser to approve this machine
+shell login --no-browser    # prints the URL; open it on this same machine
+shell whoami                # show the linked account
+shell logout                # unlink and revoke this machine's token
+

Login is the OAuth 2.0 authorization code flow with PKCE and a loopback redirect. RFC 8252, the native-app profile of OAuth, requires public native clients to implement PKCE and describes redirecting to a listener on 127.0.0.1 so the code never crosses the network. RFC 7636 is why that matters: an intercepted authorization code is useless without the code verifier, and the verifier is never transmitted. The token the CLI receives is scoped to publishing sessions and can be revoked per machine.

+

You link a machine, not a terminal. From then on every share in any window publishes as it starts and is marked closed when the process exits. What gets published is the share URL, the command name, the host name, and the timing. The URL keeps its salt fragment so the session can be opened from the web app; the salt is not a secret, and no key derives from it without the password. A fragment carrying a raw key is stripped before anything leaves the machine, which you can read in the SafeShareURL function in the CLI's account client.

+
+ +
+

How to share terminal sessions with your team day to day

+

Signing up creates an organization and invite links add people to it. Everyone inside sees every member's sessions, which in v0.11 means a board or a table, with the choice remembered, a search box that matches a session's name or its command, and one menu to copy the link, the password, or the attach command.

+

Each session has an owner and an assignee. The owner started it. The assignee is responsible for it now, and the owner can hand a running session to someone else. The server route that does this refuses anyone who is neither the session's owner nor an organization admin, with the error text "only the session's owner can hand it off", and the target must be a member. Typing follows responsibility: the web terminal lets the owner and the assignee type, and everyone else in the organization watches.

+

Around the terminal, each session has a page with comments and mentions, and mentions and assignments collect in an inbox. The list shows the relay's own view of each session as online, starting, reconnecting, expired, or unknown. The walkthrough is in the shell.online documentation.

+
+ +
+

The hard part: a password the service relays but cannot open

+

Every shell.online session is end-to-end encrypted, and the browser password is the only thing a key derives from. A team feature that copied that password through a central service would undo the encryption for whoever runs the service. Two separate seals prevent that, and both are in the open-source repository.

+

The first seal covers sessions started from the browser. The daemon on a linked machine generates an ephemeral key pair each run, using Go's crypto/ecdh package on the P-256 curve, and publishes only the public half. When you start a session from the web app, the browser picks the password itself and seals it to that key, so the accounts service relays an envelope it cannot open and the machine unwraps it to start the process. The Go side is internal/account/sealed.go, which derives a 32-byte key from the ECDH shared secret with HKDF-SHA256 and an info string that binds the key to this one purpose, then encrypts with AES-GCM under a fresh 12-byte nonce. P-256 is the curve NIST specifies in section 3.2.1.3 of SP 800-186, and ephemeral elliptic-curve Diffie-Hellman is the agreement pattern SP 800-56A Rev. 3 covers. The key lives only as long as the daemon process. Stop the daemon and nothing sealed to it can be opened again.

+

The second seal is what makes it a team feature. Only the owner of a session holds its password, and colleagues need it without the service reading it. So every browser publishes its own ECDH P-256 public key with its membership, the private key stays in that browser's storage, and the owner's browser seals the password once per organization member. The sealForMembers function in the web app generates one ephemeral key pair per sharing round, derives a distinct AES-GCM key against each member's public key, and produces one envelope per person. The server stores those envelopes on the session record and rejects any share addressed to someone outside the organization.

+

Consequences to plan around: the accounts service cannot recover or reset a session password, because it never had one. A new member gets a sealed copy of an existing session only when the owner's browser is open to produce it. Signing in on a second browser creates a different key pair, so sharing is re-done rather than assumed. And a machine whose daemon has stopped cannot receive a browser-started session until any shell command starts the daemon again.

+
+ +
+

What the account does see, stated plainly

+

The metadata above, plus an audit log. When a signed-in member types into a session through the web app, the app assembles keystrokes into the line that was actually submitted, a command at a shell prompt or a prompt to an agent, and records that line with the member's email, along with interrupts and handoffs. The page describes itself as "everything entered in this team's sessions: commands in a terminal, prompts to an agent, and who entered them", and it exports as CSV. The sink watches only the input stream the browser sends. Terminal output is never transmitted to the accounts service, and neither is the password. If the service must not learn which commands your team ran, read that page before you invite anyone. The security model page is where the sealing guarantee sits alongside the other trust boundaries.

+
+ +
+

Starting a session on a colleague's machine is asked, not assumed

+

A signed-in browser can start Claude Code, GPT Codex, Hermes Agent, OpenClaw, or a plain terminal on a linked machine, but only after the person at that machine says yes. The login prompt spells out the consequence: anyone signed in to that account could start processes on the machine, as that user, without touching the terminal. Saying no leaves the machine publish-only, and the question is put again at the next sign-in. The flags do the same thing non-interactively.

+
shell login --allow-remote-start   # let your signed-in browser start sessions here
+shell login --no-remote-start      # keep this machine publish-only
+shell daemon status                # is my browser able to start sessions here?
+shell daemon stop                  # stop until the next shell command
+shell service install              # LaunchAgent on macOS, systemd user unit on Linux
+

Saying yes runs a small daemon for as long as the machine stays signed in. Any shell command restarts it after a reboot; a machine that sits idle and still has to be reachable installs it as a user service. Commands entered in the web app run through sh on Unix and PowerShell on Windows, so quoting and escaping behave as they do locally. Exactly one poller runs per machine, held by a lock the kernel releases even if the process dies, because two would each publish their own key. Logging out stops the daemon, unlinks the machine, and revokes its token.

+
+ +
+

The rules that did not change

+

The URL and password together are still a bearer credential. Anyone holding both can view a session and, unless it was started read-only, type with the permissions of the wrapped process. An organization does not change that; it changes how the password reaches the people who should have it. The relay still sees connection and lifecycle metadata and nothing inside the frames, as described in the introduction to shell.online on this blog. shell.online is developed by Pilot Protocol and released under the MIT license.

+
+ +

Share a terminal with your team, not with the service

Link a machine with one command, invite your organization, and hand off running sessions while the accounts service holds only envelopes it cannot open.

Try shell.online
diff --git a/blog/posts/track-terminal-sessions-across-machines-by-name-shell-online-v0-16-adds-name-and.html b/blog/posts/track-terminal-sessions-across-machines-by-name-shell-online-v0-16-adds-name-and.html new file mode 100644 index 0000000..7bcc8ad --- /dev/null +++ b/blog/posts/track-terminal-sessions-across-machines-by-name-shell-online-v0-16-adds-name-and.html @@ -0,0 +1,53 @@ +

Two announcements this month move coding agents further from the keyboard you sit at. Coder now runs Claude Code inside its Agent Relay workspaces, and OpenAI's Agents API puts the Codex harness behind a managed service with hosted, self-hosted, and partner sandboxes. Both mean more sessions on more machines you never log into directly. shell.online v0.16.0, tagged this week, adds the two pieces that make terminal sessions across machines manageable from one place: a name you give a session when it starts, and a single command that lists every session your account owns on every linked machine.

+ +
+

Why sessions are spreading across machines this month

+

Coder's September 15 announcement describes Claude Code running in Coder workspaces on the customer's cloud, VPC, or on-premises environment, with workspaces that are "sandboxed, ephemeral, and scoped to a single Claude Code session" and an audit record for every run. The Agent Relay launch post from earlier in the month spells out the split: the agent's reasoning loop still runs at the vendor, execution happens in a workspace the enterprise controls, and the workspace is destroyed the moment the session ends.

+

OpenAI's Agents API public beta, announced September 10, does the same thing from the other direction. OpenAI runs orchestration, long-running sessions, and context management. You pick where the code executes: an OpenAI-hosted sandbox, your own, or one of nine named partners including Cloudflare Dev, Daytona, E2B, Modal, and Vercel.

+

The common thread is that the process doing the work has a terminal on a box somewhere else. With five of those, the question stops being "can I see it" and becomes "which one is this."

+
+ +
+

Give the session a name before it starts

+

The v0.16.0 changelog in the shell.online repository adds a start option that labels a session as it is created. It takes one line of up to 120 characters and shows up on the start card, in the local session list, and in the web app:

+
shell --name "issue-4821 claude" claude
+shell --name "web app" npm run dev
+shell --name "nightly eval" --read-only python train.py
+

Everything else about the share is unchanged. The command keeps running on your machine, the CLI owns the PTY, and it prints a URL plus a generated ten-character password that together open the same terminal from a phone or desktop browser.

+

Three details from the help text and changelog matter in practice. A persistent session that restarts without a name keeps the one it had, including a name someone gave it in the web app. A session can be renamed from its own page in the app by the owner, an assignee, or a team admin, and a blank name falls back to the command. And the name is one of the few things published to an account at all, alongside the share URL, command, host name, and timing; the CLI reference lists every start option and what each one changes.

+
+ +
+

List terminal sessions across machines with shell ls

+

Until this release, the CLI could only tell you about processes on the machine you were typing on. The existing local command still does that:

+
shell list
+

The new one asks your account instead:

+
shell ls
+shell ls --all
+shell ls --json
+

It lists the sessions you started on any machine linked to your account, newest first, with each one's name, status, uptime, machine, and command. Sessions that have ended are hidden and counted rather than shown; the footer reads something like "1 ended session hidden · shell ls --all", and the flag includes them. The JSON form prints the complete records, including the share URL and the raw relay status, for scripts that decide which session to open.

+

One thing it never does is print a password. The help text says so directly: passwords stay on the machine that started the session and, if you opted into the personal vault, in your vault. The listing gives you the URL and the name; the password still has to come from the owner. A list that included passwords would be a list of bearer credentials sitting in scrollback.

+

The command requires a linked machine. Linking is optional and uses the OAuth 2.0 authorization code flow with PKCE and a loopback redirect, so the CLI ends up with a scoped token that can only publish sessions and is revocable per machine. On an unlinked machine it tells you to sign in or use the local list. For teams with their own identity provider, v0.16.0 also adds optional OpenID Connect sign-in for the accounts app, using authorization code with PKCE and provider discovery.

+
+ +
+

What the account sees, and what it cannot

+

What the account layer can read has not changed in this release. Terminal frames are encrypted by the CLI before they reach the relay and decrypted in the browser; the end-to-end encryption page describes the key derivation from the password and what the relay still observes, which is connection metadata, encrypted frame sizes, timing, labels, and lifecycle events. The account layer gets the share URL, the command name, the host name, timing, and now the name you chose. It does not get terminal contents and it does not get the password.

+

The URL and the password together are a bearer credential. Anyone holding both can open the session and, unless it was started with --read-only, type with the permissions of the wrapped process. A named session is easier to find in a list, so treat the name as public within your organization and the password as the thing you actually guard.

+

For agent sessions, a read-only share is usually the right default. Watching a run from a phone does not require typing into it, and a read-only link cannot be upgraded to an interactive one after creation.

+
+ +
+

When the machine behind a session goes away

+

A related change merged to main on September 16 fixes what a viewer sees when the session exists but its machine does not. The pull request describes the bug plainly: a machine rebooted, the relay reported the session as existing but disconnected, and the browser showed a grey "Offline" dot above a blank white rectangle with no explanation. With workspaces that are destroyed when a session ends, this state will be common.

+

The fix does two things. Both the standalone share page and the app now say whether they are waiting for a machine that has never connected or looking at one that is temporarily offline, with the time it was last seen. And the relay keeps the last full screen the host sent and replays it, dimmed so it cannot be mistaken for live output, to a viewer who arrives while the machine is away. For an encrypted session those stored bytes are ciphertext the relay cannot read; the frame is stored and replayed untouched. The relay asks the host for a fresh screen every five minutes so the kept copy is never older than that, and it is deleted with the session.

+

The retention consequence is worth stating. The relay now holds one encrypted screen per session for as long as that session exists, which the reliability page puts at 12 hours after a host disconnects for an ordinary share and 30 days for a persistent identity. If you started the session with --no-e2ee, that kept screen is plaintext, which is the same tradeoff the flag already makes for frames in flight. The notice clears itself when the machine comes back, and a restarted persistent session goes live again on the same URL.

+
+ +
+

Handing one of them to a colleague

+

Once sessions have names and one list, handoff is a filter and a click. In the web app, sessions from every linked machine appear in one searchable list or board, reconciled with the relay's current online, starting, offline, finished, or unavailable state. Each session has an owner and can have several assignees, and the owner can transfer it without changing the public bearer link. The app documentation is explicit that offline and unavailable never imply the process is reachable, and that finished records a known task exit; the web app guide covers the states and the cleanup rules.

+

The earlier post on this blog about a live browser link for any terminal covered the single-session case: one command, one URL, one password. This release is for the moment that stops being enough. If your agents run in a Coder workspace, an OpenAI-hosted sandbox, and a laptop under your desk, a session called "issue-4821 claude" on a machine called "build-3" is easier to find than a bare session ID, and the command that finds it is the same everywhere. The install is one line from shell.online on macOS, Linux, BSD, and Windows, and everything described here is in the MIT-licensed source.

+
+ +

Name it, list it, hand it over

Start a session with a name, see every session your account owns with one command, and give a teammate the link without ever sending the terminal contents through the service.

Try shell.online
diff --git a/blog/posts/vs-code-1-137-runs-agent-sessions-in-their-own-process-terminal-sharing-without.html b/blog/posts/vs-code-1-137-runs-agent-sessions-in-their-own-process-terminal-sharing-without.html new file mode 100644 index 0000000..bd95d52 --- /dev/null +++ b/blog/posts/vs-code-1-137-runs-agent-sessions-in-their-own-process-terminal-sharing-without.html @@ -0,0 +1,43 @@ +

VS Code 1.137 shipped on September 9, 2026, and its release notes describe the Agent Host in one sentence: "The agent host lets you connect to the same agent session from multiple VS Code windows." Agent harnesses run in a dedicated process, and every window is a client of that process rather than its owner. The idea underneath is old and good. The thing doing the work should not die when the window that started it closes, and every screen looking at it should be a viewer, not the owner. shell.online applies that same split to any terminal process, and it is why terminal sharing without SSH or VPN works for a build that is failing on one machine while the person who can fix it sits at another.

+ +
+

What the Agent Host actually changes

+

Under the hood VS Code now talks to the harness over the Agent Host Protocol (AHP), which the concept documentation describes as state-first: "The host is the source of truth," and each client subscribes to channels for sessions, chats, terminals, and changesets. Two properties fall out of that. Multiple clients can observe and control the same session and stay in sync, and a session can keep running when no editor or other client is connected at all.

+

Reaching that host from another machine is the part worth reading closely. For remote sessions the Agent Host runs as a standalone process and exposes AHP as JSON-RPC over WebSocket. You start one with the code agent host command, it is protected with a connection token, and you reach it over SSH or a dev tunnel. The August architecture post frames the payoff as running the host next to your workspace on another machine "while you connect from the desktop or web to check progress, review changes, and manage sessions."

+

That is a good design for agent sessions. It also only covers agent sessions inside VS Code, and its remote path assumes the other person can be given SSH access or a tunnel. A failing build is often neither of those things. It is a plain process, and the colleague you want looking at it may have no account on the box and no reason to get one.

+
+ +
+

Terminal sharing without SSH or VPN, for one broken build

+

The shell.online CLI makes the same split, but smaller and more literal. It owns a pseudo-terminal and the process inside it on your machine. Browsers are viewers of that PTY and nothing more. One command wraps the build:

+
curl -fsSL https://shell.online/install | sh
+shell npm run build
+

The CLI prints a URL and an eight-character password only after the relay exists and the process has survived a short startup handshake. A build that dies in its first second reports the exit status and prints no link, so nobody is handed a dead URL. The session runs in the background by default and your own terminal comes back. Use shell --foreground when you want the output mirrored locally too, or shell attach with the session ID to take over from your own terminal later; Ctrl-X and then D detaches without stopping the build.

+

Send the URL and the password to the colleague. They open it on a laptop or a phone, see the terminal the build is drawing, and can type into it if you left the link interactive. The relay is Cloudflare, but the CLI encrypts each frame with AES-256-GCM before it leaves the machine and the browser decrypts on arrival. The URL carries only a random salt fragment, and both ends derive the key from the password with 600,000 rounds of PBKDF2-HMAC-SHA256, as the E2EE guide spells out. Cloudflare still sees connection IPs, timing, and encrypted frame sizes, and the security page lists that metadata rather than glossing over it. Terminal contents are not persisted server-side.

+

The access model is blunt and worth saying out loud: the URL and the password together are a bearer credential. Anyone holding both can view the terminal and, unless the link is read-only, type into it with the permissions of the wrapped process. That is the whole trust model. It is why an interactive build link goes to one named person, not to a channel.

+
+ +
+

Read-only when the other person should only look

+

Often the second person does not need the keyboard. They need to see the last two hundred lines of a linker error, or watch the test runner reach the case that hangs. For that:

+
shell --read-only npm run build
+

Read-only is enforced at the relay Worker, not by the page in the browser. Ordinary browser input and a confirmed EOF are rejected server-side, so opening DevTools or hand-crafting WebSocket frames does not turn the link into an interactive one. Access mode is fixed when the share is created and cannot be weakened afterwards. If you later decide the viewer should type, you start a new share.

+

Read-only also narrows what the bearer credential grants. With both values a viewer can see everything the build prints, and nothing else.

+
+ +
+

What happens when the network drops mid-build

+

This is where "the host is the source of truth" earns its keep. The reliability page states the rule directly: relay and browser failures are treated as recoverable display failures, never as permission to terminate local work. The CLI and the browser both reconnect with bounded backoff, and an ordinary share link stays recoverable for 12 hours after its host disconnects. The build keeps running through all of it, because it never depended on the viewer.

+

Build output is bursty, which creates a second problem: a viewer can fall behind. The CLI keeps a bounded local ring buffer so a viewer who arrives late or reconnects gets the screen restored, and if live output outruns the network or the browser's rendering, stale display work is dropped and one authoritative screen snapshot is sent instead. PTY reads never block waiting on Cloudflare. Queues are bounded and writes time out, so a process that writes faster than the relay can carry keeps running at full speed locally. The 0.11.0 entry in the changelog of the shell.online repository records a fix in exactly this area: authenticated E2EE recovery snapshots are now preserved during relay backpressure, so large output can no longer eject viewers to the password screen. Builds are the workload that finds that kind of bug.

+

Two more lifecycle details matter for a pairing session. When the build exits, the share closes on its own; the link does not outlive the process. If you want a harder deadline, shell --auto-close 2h npm run build closes the share after two hours even if the process is somehow still going. And if the colleague opens the link from a phone, the session grid switches from 120 by 36 to 80 by 24 for everyone until the last phone leaves, because viewers never own PTY sizing. Every viewer sees the same line wrapping in the compiler output.

+
+ +
+

Where the two designs differ, honestly

+

The Agent Host carries structured state: chats, changesets, tool approvals, terminals as addressable resources. shell.online carries one PTY and nothing else. It does not know what a build is, cannot show a diff, and has no notion of approving a tool call. What it gives up in structure it gets back in reach. Any process on any machine that can run the static binary qualifies, including the OpenWrt routers and Raspberry Pi boards in the platform matrix, and any viewer with a modern browser, with no account, SSH key, tunnel, or VPN on either side.

+

The remote path is the sharpest contrast. A remote Agent Host is reached over SSH or a dev tunnel with a connection token. A shell.online link is reached over HTTPS with a URL and a password, and the relay only ever handles ciphertext. Both are bearer credentials in practice. The difference is what the holder can reach: an SSH session is the whole machine, while a share link is one process, at that process's permissions, and optionally with no keyboard at all.

+

Rule of thumb for a failing build: an interactive link to the one person who will type the fix, a read-only link to everyone who just wants to watch it go green, and let the share close itself when the build exits.

+

The full flag list, including the auto-close grammar and exit codes, is in the CLI reference. The broader case for a live terminal link is in the post that introduced shell.online on this blog.

+
+ +

Share the build, not the box

One command, one link, one password. The process stays on your machine and the relay never sees plaintext.

Try shell.online
diff --git a/blog/posts/watch-a-coding-agent-remotely-without-shrinking-its-terminal-shell-online-v0-23.html b/blog/posts/watch-a-coding-agent-remotely-without-shrinking-its-terminal-shell-online-v0-23.html new file mode 100644 index 0000000..07d1946 --- /dev/null +++ b/blog/posts/watch-a-coding-agent-remotely-without-shrinking-its-terminal-shell-online-v0-23.html @@ -0,0 +1,42 @@ +

Claude Code 2.1.282 shipped on September 24 with a setting called maxProseWidth. It caps how wide Claude's replies run in a wide terminal, while tables and code blocks keep the full width. The minimum is 40 columns and the default is unset, so prose wraps at the terminal edge. That line is about a problem anyone who has tried to watch a coding agent remotely already knows: a program breaks its lines wherever the terminal told it the edge was, and every other screen inherits those breaks. shell.online v0.23.2, released September 25, takes the other side of the same problem. The program keeps the grid of the terminal it actually runs in, and each browser viewer lays the session out for its own screen.

+ +
+

Where a terminal's width actually comes from

+

A terminal program never measures a window. It asks the kernel. The TIOCGWINSZ ioctl returns a struct winsize with ws_row and ws_col, and when the size changes, the kernel sends SIGWINCH to the foreground process group so the program can ask again and redraw. Whoever sets the PTY size decides where every line wraps.

+

When you share a terminal through a relay, someone has to pick that size, and until this release shell.online picked it centrally. A desktop-only session ran at 120 by 36. The moment a phone connected, the session dropped to 80 by 40 for everyone, including a read-only phone, and it went back when the last phone left. The pull request behind v0.23.2 lists the consequences: a 200 by 60 terminal running the CLI showed a 120 by 36 program in its corner, a phone got 80-plus columns scaled to a few pixels per character, and a phone joining reshaped the program for everyone.

+
+ +
+

Watch a coding agent remotely at the size it was started at

+

The fix has two halves, and the first one is about the host. As of v0.23.2, the CLI owns its grid. Running the CLI in the foreground or attaching locally opens the program at your terminal's size and follows it on SIGWINCH as you resize. A session started in the background, which is the default, opens at the size of the terminal that launched it. One controller resizes the PTY and the snapshot emulator together, and a repeated size is a no-op.

+

The workflow is the same command it has always been:

+
shell claude
+

The CLI prints a URL and an eight-character password. Open the link on your phone, enter the password, and you are looking at the same Claude Code process running on your machine. In v0.23.2 your phone joining changes nothing about the terminal Claude Code sees. The CLI reference covers the flags around this: the foreground flag keeps local interaction while sharing, the attach command reconnects a local terminal to a background session, and Ctrl-X followed by D detaches without ending the process.

+

The second half is about viewers, and it never touches the network. The new default renderer is called Adaptive. It keeps an exact hidden copy of the session grid and a visible canvas. Where the pane can draw the grid legibly, which the implementation defines as at least 11 pixels, it draws the grid whole, exactly as before. Where it cannot, which is most phones, it lays the content out again at the pane's width at 13 pixels. Terminal wraps are undone, and so are the line breaks a coding agent inserts into its own prose; the PR notes that Claude Code breaks replies at the session width using cursor moves and runs in the alternate screen, and the layout handles both.

+

Layout is a pure function of the screen contents and the pane width, computed in the browser. Nothing a viewer does reaches the relay or another viewer. A viewer who prefers the old behaviour can switch to the Fixed grid renderer, which is the previous one unchanged.

+
+ +
+

Fit to my screen, and who is allowed to press it

+

One viewer should be able to change the program's size: the person who started it. The session owner gets a Fit to my screen button. It sends a grid request for the owner's pane, capped at the size of any attached local terminal so a phone can never shrink a terminal someone is typing into locally. The button hides when the grid already matches. A session started from the browser through a linked account opens at the size of the pane that started it, and asks once.

+

At the protocol level, grid requests are accepted from writers only and only for hosts that advertise a dynamic grid. That means a link created with the read-only flag cannot resize anything, which is the right default when you hand someone a link to watch an agent work:

+
shell --read-only claude
+

The URL and the password together are a bearer credential. Anyone holding both can view the session, and unless the link is read-only, can type with the permissions of the wrapped process. The sizing machinery adds no new secret and no new capability for a viewer beyond asking for a grid, and that is refused for read-only links. Terminal frames stay end-to-end encrypted by default. The security model page spells out what an interactive link grants.

+
+ +
+

Why the relay had to learn a new word first

+

The hard part of this release is compatibility, because the CLI, the relay and the web app ship separately and an update never restarts a running host. The host advertises what it can do in a header, X-Shell-Terminal-Grid, which now lists dynamic alongside the fixed grids it already supported. A relay that understands this sends a relay_features message first, before any snapshot request. Only after that does the host send its terminal_grid message, then again on every change, and it resets on every reconnect.

+

The ordering matters because the previous relay closes a host over any unknown control message. So a new CLI against an old relay never sends one. The old relay's own grid choice arrives as a terminal_size message and is applied inside the local terminal, which the release notes verified end to end. In the other direction, an old CLI that does not list dynamic gets exactly the fixed-grid behaviour it had before, byte for byte. The source repository has the controller in cmd/shell/terminal_grid.go and the renderer under app/src/terminal/adaptive/, and the layout tests replay real captures of Claude Code, bash and vim at every width from 10 to 130 columns, asserting that no row is wider than the viewer and no text is lost.

+

Installing v0.23.2 does not restart sessions that are already running, and the host-owned grid only takes effect once both the CLI and the relay are updated. Check the installed version with shell --version and start a new session to get the new sizing.

+
+ +
+

What this looks like on a phone during a long agent run

+

Start the agent on your workstation in a normal 200-column terminal. Leave the desk. Open the link on your phone in portrait. Before v0.23.2, that action would have forced the agent into 80 columns and rewrapped every reply for your colleague still at the desk. Now the agent keeps its 200 columns, your phone gets 13-pixel text reflowed to its own width with the agent's own line breaks rejoined, and your colleague's screen does not move. Rotate the phone and the layout recomputes locally. On the phone, dragging with one finger scrolls history, and inside full-screen programs that take mouse input the gesture is sent as wheel input instead, as the mobile guide describes.

+

Network loss behaves the same as before. The process runs on your computer, a temporary browser or network failure does not stop it, and the reliability notes say an ordinary disconnected share can recover its link for 12 hours while the CLI and browser retry temporary failures on their own. Add an auto-close deadline if the agent should not stay reachable past a certain time:

+
shell --auto-close 2h --read-only claude
+

Claude Code's maxProseWidth and this release solve the same annoyance from opposite ends. The setting tells the agent to wrap prose at a chosen column in a wide terminal. The Adaptive renderer accepts whatever width the agent wrapped at and reflows it for the screen in front of you, without the agent or anyone else knowing. Set maxProseWidth to 80 on the host and the phone view still rejoins those 80-column lines to fit its own width. The earlier overview of what a live terminal browser link is still applies; what changed is who decides the shape of the terminal. shell.online is developed by Pilot Protocol and released under the MIT license, so the grid controller and the renderer are both there to read.

+
+ +

Watch your agent from any screen, at its own size

Start a session with one command on the machine doing the work, open the link on your phone, and let each screen lay out the terminal for itself. Read-only links cannot resize anything.

Try shell.online
diff --git a/blog/posts/watch-claude-code-from-your-phone-as-a-conversation-not-an-80-column-grid.html b/blog/posts/watch-claude-code-from-your-phone-as-a-conversation-not-an-80-column-grid.html new file mode 100644 index 0000000..3c995e3 --- /dev/null +++ b/blog/posts/watch-claude-code-from-your-phone-as-a-conversation-not-an-80-column-grid.html @@ -0,0 +1,45 @@ +

Two of the most used coding agents shipped terminal-rendering work this week. Codex 0.156.0 added an optional fullscreen interface, six themes, and inline Mermaid diagrams and display equations. Claude Code 2.1.282, released today, added a setting that caps how wide the model's prose may run in a wide terminal. Both point the same way: an agent's output is prose, and a terminal is a grid. The gap is widest when you try to watch Claude Code from your phone. The shell.online app now has a renderer that reads the session as the conversation it is.

+ +
+

An agent session is a conversation drawn on an 80-column grid

+

The Codex changelog for 0.156.0 describes an optional fullscreen UI behind a /tui command, with transcript search and mouse selection, plus the ability to "view supported Mermaid diagrams and display equations directly in responses." The Claude Code changelog for 2.1.282 adds a maxProseWidth setting "that caps the width of Claude's prose in wide terminals while tables and code blocks keep the full width", next to fixes for garbled rows in the non-fullscreen renderer.

+

The agent writes headings, lists, fenced code, and paragraphs. The terminal turns them into rows of cells, and the agent's interface works hard to make those rows look like a document again. On a big monitor that mostly works. On a phone an 80-column grid is either unreadably small or wrapped into pieces that no longer line up. A terminal share does not change this by itself: the default xterm.js view shows the real screen byte for byte, which is right for anything interactive and poor for reading three paragraphs and a code block in a queue.

+
+ +
+

How to watch Claude Code from your phone as a conversation

+

The chat renderer lives in the shell.online app, the optional accounts layer, so the one-time setup is linking the machine. Everything else is the normal command.

+
curl -fsSL https://shell.online/install | sh
+shell auth
+shell --name "Refactor auth" claude
+

Linking publishes the share URL, the command name, the host name, and timing. It never publishes terminal contents or the browser password. On the phone, open app.shell.online, pick the session, and enter the eight-character password the CLI printed, or use the saved copy in your vault. In the terminal tab, change the Renderer selector from xterm.js to Chat. The choice is remembered per browser, and switching does not restart the process or touch the encryption.

+

What you get is a thread. Your prompts appear as sent messages. Claude Code's replies appear as received messages, with the Markdown it wrote rendered as headings, lists, inline code, quotes, links, and code blocks that carry their language. While the agent is working there is a Thinking line under the last message, driven by the spinner the program itself draws. Before this week a busy session, a finished session, and a broken session all looked identical on a phone: the thread simply stopped.

+

As the mobile guide says, closing the browser does not stop the host; the session ends when its process exits.

+
+ +
+

What the renderer reads, and what it declines to guess

+

The design is written up in the chat renderer README in the repository. It never parses the byte stream directly. It builds an xterm.js Terminal that is never opened, so it has no canvas and no DOM, and reads finished rows out of that emulator's buffer. Stripping escape codes and splitting on newlines fails within a minute of real use: a progress bar becomes two hundred lines, a wrapped line is cut in half, and the shell prompt becomes output. An emulator already solves all of that.

+

Two rules decide what is finished. The cursor row is never released, because whatever is on it is still being written. A wrapped line is released whole, because the emulator knows which rows continue the row above. Where a shell publishes OSC 133 command markers, the boundary between answers is exact.

+

Claude Code draws its own interface, so it needs an adapter on top, and that adapter is written against captured frames from the real program rather than an assumed shape. Three merges today tightened it. The title match now includes the rotating glyphs Claude Code puts in front of the window title once it has a task, so a session is still recognised after the header has scrolled away. And the repaint reader anchors on the tail of what it has already emitted rather than the head of the screen. In the replay test added with that change, three real exchanges went from 71 emitted utterances to 9.

+

The Markdown rendering is hand-written on purpose. The input is output from somebody else's machine, so every node is built with textContent and there is no path from the input to innerHTML. A link only becomes a link when its scheme is http or https. Raw HTML, images, reference links, and tables are left exactly as typed. The whole thing is in markdown.ts.

+

When a program takes the whole screen and no adapter can read it, such as vim, top, or a pager, the renderer prints one line instead of squeezing a grid into a bubble: "vim notes.md is running. Switch this session to the terminal renderer to see it."

+
+ +
+

Where the transcript lives after you reload the page

+

Reload the page and the renderer starts from whatever the relay replays, which is the current screen, not the conversation. The change that merged today keeps the conversation on the device that watched it, and the reasoning matters more than the mechanism.

+

The relay cannot read a session. The encryption page spells out what it does see: timing, addresses, encrypted sizes, labels, and lifecycle metadata. A server-side history would mean shipping the conversation somewhere it is not supposed to exist. The device that watched already has the plaintext on its screen, so it is the honest place to keep it.

+

The store is IndexedDB rather than localStorage. localStorage is synchronous, so every write would land on the thread that draws the conversation, and per MDN's storage quota documentation browsers allow about 5 MiB of local storage per origin. The cache keeps the most recent 400 messages and writes once a burst has settled, so an answer arriving a line at a time is one write rather than forty.

+

At rest, the cache is encrypted whenever the session is. The key is derived from the secret in the share link with HKDF-SHA256, per RFC 5869, under its own salt and info strings, so the cache key is never the session key. A cached conversation is therefore readable by exactly the people who could read the session it came from, and by nobody else with access to the disk. Reload with a different link and the cache cannot be decrypted, so it is treated as no history. A session started with --no-e2ee has nothing to derive from and is stored as plaintext, which is no worse than the session itself. The implementation is chat-history.ts.

+

A device that was not there says so. The first time a browser opens a session with an empty cache, the thread starts with "Nothing from this session is kept on this device. What follows starts here." That notice is not saved, because it is true of that moment and no moment after it. There is no syncing between devices: the relay broadcasts to every viewer, so each cache reflects every interaction that device was present for.

+
+ +
+

What access looks like once the session reads as chat

+

The URL and the password together are a bearer credential. Anyone holding both can read the session and, unless it was started with --read-only, type into it with the permissions of the process you wrapped. A conversation view does not narrow that in any way. It is the same session with a different renderer.

+

If you want a teammate to read the agent's reasoning from their phone without steering it, start the session read-only. A colleague who opens a session you handed over, through the organisation features covered in the earlier post on live terminal links, starts their own cache from that moment, and their thread will say so.

+

One note on versions. The CLI release is v0.23.1, published September 23, and none of the above needs a CLI update, because the chat renderer runs entirely in the browser. The adapter, Markdown, and history changes merged to main on September 24. The repository README is explicit that a merged change is not proof that every hosted component has been updated, so if the Renderer selector does not yet behave as described, the source linked above is the authoritative account of what shipped.

+
+ +

Read your agent, not its screen

Start Claude Code with one command, open the link on your phone, and switch the renderer to Chat. The process stays on your machine, the relay sees ciphertext, and the transcript stays on the device that watched it.

Try shell.online
diff --git a/blog/posts/which-agent-session-needs-you-shell-online-v0-22-adds-a-passive-pulse-to-monitor.html b/blog/posts/which-agent-session-needs-you-shell-online-v0-22-adds-a-passive-pulse-to-monitor.html new file mode 100644 index 0000000..ff25607 --- /dev/null +++ b/blog/posts/which-agent-session-needs-you-shell-online-v0-22-adds-a-passive-pulse-to-monitor.html @@ -0,0 +1,50 @@ +

On September 17 the Anthropic Institute published its R&D Automation Index, which says Claude now leads 26% of Anthropic's own model R&D work, up from under 1% in February, with roughly 30,000 agents doing research and engineering work at any one time in August. The word doing the work in that sentence is "leads". The index defines its AL4 level as "AI leads: it can complete most of the task end-to-end from a high-level prompt", and a human still owns the result. Someone has to know which of those sessions needs a person right now. That is the problem the passive session pulse in shell.online v0.22.0, released yesterday, is aimed at: a way to monitor a coding agent in the browser across several open sessions without spending a model call or a keystroke to find out whether one of them has gone quiet.

+ +
+

What the pulse actually shows

+

The pulse lives in the shell.online web app. Every open terminal pane gets a small badge, and background tabs get a compact version of the same badge on the tab strip, so you can have five agent sessions open and scan the tabs instead of clicking through each one. The badge is built from three things, all computed in the browser from output the pane is already receiving.

+ +

The hint patterns are narrow on purpose. The tracker in the repo matches lines like "Error: context window exceeded", "Compaction failed: context too long", "Approval required", "Allow this command? [y/n]", and the summary lines that vitest, jest, pytest and cargo print at the end of a run, such as "Tests: 2 failed, 3 passed, 5 total" or "test result: ok. 4 passed; 0 failed; 0 ignored". A line has to match from its start, and a line over 1,024 characters is discarded rather than scanned. The test file beside it lists the exact strings expected to match.

+
+ +
+

Why it says "observation", not "idle"

+

The README is blunt about what this is: "These are observations, not proof that a process is idle, finished, or successful." The badge's own tooltip repeats it: output timing does not establish whether an agent is idle or finished, and a pattern match does not confirm agent state.

+

That restraint is deliberate, and the agent tooling itself shows why. The Claude Code changelog for 2.1.269 records a fix for remote and headless sessions "reporting 'waiting for your input' while background agents were still running", with an environment variable to restore the old behaviour. Five versions later, 2.1.274 fixed background agent notifications "claiming the agent had no live background work when it was still waiting on its own background task and would resume". If the harness running the agent can misreport whether it is waiting, a byte-level observer outside the process has no business claiming to know better. So the pulse does not try. It reports when bytes last arrived, whether you have seen them, and whether a recognisable line went past. You decide what that means.

+

One related detail: replayed snapshots do not count as new activity. When a viewer reconnects, the host sends a screen snapshot so the terminal repaints correctly. That snapshot can replace a stale hint, but it does not set Output active or bump the New output counter, because nothing new happened on the machine. The changelog entries for v0.21.2 through v0.22.0 cover the snapshot ordering work that makes this distinction reliable.

+
+ +
+

What it costs, and what it can see

+

The pulse makes no model calls, sends no prompts, opens no additional terminal connections, and creates no subscriptions for sessions you have not opened. It runs on the bytes an authorised viewer already decrypts. That last point is a consequence of the architecture rather than a policy choice: terminal traffic is end-to-end encrypted by default, the CLI encrypts frames before they reach the relay, and the browser decrypts them. The relay cannot compute a pulse because it never sees plaintext. The only place this signal can exist is inside the pane that holds the key.

+

Its state lives in browser memory and is cleared on disconnect, on loss of access, and when the pane closes. Nothing is persisted, nothing is uploaded, and the pulse does not appear in the account service's session list. The parser is bounded in the way you would want for something that reads untrusted output: it decodes in four-kilobyte pieces including split UTF-8 code points, discards escape sequences and control strings as a stream even if an attacker never terminates one, and stops scanning any line that exceeds the length cap. The session content and pulse doc in the repo is the short reference for these limits.

+

The pulse is not a daily briefing and not a summary. v0.22.0 also ships owner-encrypted titles and response excerpts for explicitly resumed OpenCode sessions, but that is a separate, opt-in feature gated on daily-briefing consent, and it makes no model calls either. Enabling it does not change what the pulse does.

+
+ +
+

The workflow, end to end

+

Start the agent the way you already would, wrapped in shell.online. If the person watching should never be able to type, make the link read-only from the start:

+
shell claude
+shell --read-only codex
+shell --name "migration branch" claude
+

Each command prints a browser link, an eight-character password and a QR code. The URL and password together are a bearer credential: anyone holding both can view the session and, unless it is read-only, type into it with the permissions of the wrapped process. Read-only is enforced by the CLI rather than hidden in the viewer, so a monitoring link cannot be promoted to a control link from the browser side.

+

To get the pulse across several sessions at once, link the machine with shell auth, which is optional and uses OAuth 2.0 with PKCE and a loopback redirect. Linking publishes only the share URL, command name, host name and timing, never terminal contents and never the password. Then open the sessions as tabs in the web app. The compact badge on each tab is the whole point: three agents running, one tab says Quiet with Input may be needed, and that is the one you open. On a phone the same tabs work, and the recent mobile layout fixes in the repo's unreleased changelog are aimed at exactly that screen.

+

Edge cases worth knowing:

+ +
+ +
+

The supervision problem is an attention problem

+

The Automation Index also reports that over a billion decisions from Anthropic's research and engineering agents in August, about one in 47,000 were blocked by automated checks. That is a very large number of decisions and a very small number of interventions, and it describes the shape of supervising agents at scale: nearly everything runs, and the human's job is to notice the few moments that need them. A passive signal that says "this one has been quiet for a while and printed something that looks like a question" is cheap, honest about its limits, and computed where the data already is.

+
+ +

Watch every agent from one set of tabs

Wrap the agent in one command, open the sessions in the web app, and let the pulse tell you which tab to look at. Encrypted end to end, with no model calls and nothing stored.

Try shell.online
diff --git a/public/blog/banners/a-browser-terminal-on-a-ros-2-robot-without-a-vpn-and-why-it-should-be-read-only.svg b/public/blog/banners/a-browser-terminal-on-a-ros-2-robot-without-a-vpn-and-why-it-should-be-read-only.svg new file mode 100644 index 0000000..9394496 --- /dev/null +++ b/public/blog/banners/a-browser-terminal-on-a-ros-2-robot-without-a-vpn-and-why-it-should-be-read-only.svg @@ -0,0 +1,8 @@ + + + + GUIDE / SEP 15 2026 + A Browser Terminal on a ROS 2Robot Without a VPN, and Why ItShould Be Read-Only by Default + How to get a browser terminal on a ROS 2 robot with... + pilotprotocol.network + diff --git a/public/blog/banners/a-read-only-terminal-link-for-a-training-run-you-check-from-your-phone.svg b/public/blog/banners/a-read-only-terminal-link-for-a-training-run-you-check-from-your-phone.svg new file mode 100644 index 0000000..ec741d9 --- /dev/null +++ b/public/blog/banners/a-read-only-terminal-link-for-a-training-run-you-check-from-your-phone.svg @@ -0,0 +1,8 @@ + + + + GUIDE / SEP 11 2026 + A Read-Only Terminal Linkfor a Training Run You CheckFrom Your Phone + Share a training run with a read-only terminal... + pilotprotocol.network + diff --git a/public/blog/banners/a-read-only-terminal-link-for-your-coding-agent-after-plugin4shell.svg b/public/blog/banners/a-read-only-terminal-link-for-your-coding-agent-after-plugin4shell.svg new file mode 100644 index 0000000..9a68d56 --- /dev/null +++ b/public/blog/banners/a-read-only-terminal-link-for-your-coding-agent-after-plugin4shell.svg @@ -0,0 +1,8 @@ + + + + SECURITY / SEP 20 2026 + A Read-Only Terminal Linkfor Your Coding Agent, AfterPlugin4Shell + Plugin4Shell showed how much a coding agent can... + pilotprotocol.network + diff --git a/public/blog/banners/connect-an-ai-agent-to-a-live-terminal-without-giving-it-the-machine-shell-onlin.svg b/public/blog/banners/connect-an-ai-agent-to-a-live-terminal-without-giving-it-the-machine-shell-onlin.svg new file mode 100644 index 0000000..b28d9df --- /dev/null +++ b/public/blog/banners/connect-an-ai-agent-to-a-live-terminal-without-giving-it-the-machine-shell-onlin.svg @@ -0,0 +1,8 @@ + + + + ENGINEERING / SEP 14 2026 + Connect an AI Agent to a LiveTerminal Without Giving It theMachine: shell.online v0.15 AgentHandoffs + How to connect an AI agent to a live terminal with... + pilotprotocol.network + diff --git a/public/blog/banners/end-to-end-encrypted-terminal-sharing-has-a-second-secret-on-the-host-shell-onli.svg b/public/blog/banners/end-to-end-encrypted-terminal-sharing-has-a-second-secret-on-the-host-shell-onli.svg new file mode 100644 index 0000000..37bac36 --- /dev/null +++ b/public/blog/banners/end-to-end-encrypted-terminal-sharing-has-a-second-secret-on-the-host-shell-onli.svg @@ -0,0 +1,8 @@ + + + + SECURITY / SEP 30 2026 + End-to-End Encrypted Terminal SharingHas a Second Secret on the Host:shell.online v0.24.1 Takes the SessionPassword Out of Process Environments + What end-to-end encrypted terminal sharing hides... + pilotprotocol.network + diff --git a/public/blog/banners/end-to-end-encrypted-terminal-sharing-what-the-relay-can-still-do-after-shell-on.svg b/public/blog/banners/end-to-end-encrypted-terminal-sharing-what-the-relay-can-still-do-after-shell-on.svg new file mode 100644 index 0000000..7a63566 --- /dev/null +++ b/public/blog/banners/end-to-end-encrypted-terminal-sharing-what-the-relay-can-still-do-after-shell-on.svg @@ -0,0 +1,8 @@ + + + + SECURITY / SEP 17 2026 + End-to-End Encrypted TerminalSharing: What the Relay Can StillDo After shell.online v0.16.1 + What end-to-end encrypted terminal sharing hides... + pilotprotocol.network + diff --git a/public/blog/banners/end-to-end-encrypted-terminal-sharing-what-the-relay-sees-and-what-it-can-no-lon.svg b/public/blog/banners/end-to-end-encrypted-terminal-sharing-what-the-relay-sees-and-what-it-can-no-lon.svg new file mode 100644 index 0000000..0455e2c --- /dev/null +++ b/public/blog/banners/end-to-end-encrypted-terminal-sharing-what-the-relay-sees-and-what-it-can-no-lon.svg @@ -0,0 +1,8 @@ + + + + SECURITY / SEP 18 2026 + End-to-End Encrypted TerminalSharing: What the Relay Sees, andWhat It Can No Longer Replay + What end-to-end encrypted terminal sharing in... + pilotprotocol.network + diff --git a/public/blog/banners/how-to-revoke-a-shared-terminal-link-without-killing-the-process-behind-it.svg b/public/blog/banners/how-to-revoke-a-shared-terminal-link-without-killing-the-process-behind-it.svg new file mode 100644 index 0000000..0d786f1 --- /dev/null +++ b/public/blog/banners/how-to-revoke-a-shared-terminal-link-without-killing-the-process-behind-it.svg @@ -0,0 +1,8 @@ + + + + SECURITY / SEP 19 2026 + How to Revoke a Shared TerminalLink Without Killing the ProcessBehind It + Revoke a shared terminal link with one command... + pilotprotocol.network + diff --git a/public/blog/banners/how-to-watch-a-coding-agent-remotely-after-you-turn-off-its-permission-prompts.svg b/public/blog/banners/how-to-watch-a-coding-agent-remotely-after-you-turn-off-its-permission-prompts.svg new file mode 100644 index 0000000..96a4edc --- /dev/null +++ b/public/blog/banners/how-to-watch-a-coding-agent-remotely-after-you-turn-off-its-permission-prompts.svg @@ -0,0 +1,8 @@ + + + + GUIDE / SEP 08 2026 + How to Watch a Coding AgentRemotely After You Turn OffIts Permission Prompts + Claude Code 2.1.259 runs headless with no prompts... + pilotprotocol.network + diff --git a/public/blog/banners/let-a-coding-agent-share-its-own-terminal-session-in-the-browser-the-shell-onlin.svg b/public/blog/banners/let-a-coding-agent-share-its-own-terminal-session-in-the-browser-the-shell-onlin.svg new file mode 100644 index 0000000..e59b5ae --- /dev/null +++ b/public/blog/banners/let-a-coding-agent-share-its-own-terminal-session-in-the-browser-the-shell-onlin.svg @@ -0,0 +1,8 @@ + + + + GUIDE / SEP 29 2026 + Let a Coding Agent Share Its OwnTerminal Session in the Browser:the shell.online Skill and shell--json + How the shell.online agent skill tells Claude Code... + pilotprotocol.network + diff --git a/public/blog/banners/mcp-terminal-access-for-an-ai-agent-scoped-grants-that-expire-and-revoke.svg b/public/blog/banners/mcp-terminal-access-for-an-ai-agent-scoped-grants-that-expire-and-revoke.svg new file mode 100644 index 0000000..10f4228 --- /dev/null +++ b/public/blog/banners/mcp-terminal-access-for-an-ai-agent-scoped-grants-that-expire-and-revoke.svg @@ -0,0 +1,8 @@ + + + + SECURITY / SEP 21 2026 + MCP Terminal Access for anAI Agent: Scoped Grants ThatExpire and Revoke + How shell.online v0.21 gives an AI agent MCP... + pilotprotocol.network + diff --git a/public/blog/banners/self-hosted-terminal-sharing-on-one-docker-box-the-shell-online-relay-without-a.svg b/public/blog/banners/self-hosted-terminal-sharing-on-one-docker-box-the-shell-online-relay-without-a.svg new file mode 100644 index 0000000..deb9b9f --- /dev/null +++ b/public/blog/banners/self-hosted-terminal-sharing-on-one-docker-box-the-shell-online-relay-without-a.svg @@ -0,0 +1,8 @@ + + + + OPERATIONS / SEP 12 2026 + Self-Hosted Terminal Sharing onOne Docker Box: the shell.onlineRelay Without a CloudflareAccount + shell.online v0.12.2 adds self-hosted terminal... + pilotprotocol.network + diff --git a/public/blog/banners/self-hosted-terminal-sharing-run-the-shell-online-relay-on-one-docker-host-inste.svg b/public/blog/banners/self-hosted-terminal-sharing-run-the-shell-online-relay-on-one-docker-host-inste.svg new file mode 100644 index 0000000..de29b64 --- /dev/null +++ b/public/blog/banners/self-hosted-terminal-sharing-run-the-shell-online-relay-on-one-docker-host-inste.svg @@ -0,0 +1,8 @@ + + + + OPERATIONS / SEP 26 2026 + Self-Hosted Terminal Sharing: Runthe shell.online Relay on OneDocker Host Instead of Cloudflare + Cloudflare had a Durable Objects incident on 25... + pilotprotocol.network + diff --git a/public/blog/banners/share-a-terminal-session-in-the-browser-without-handing-over-the-filesystem-shel.svg b/public/blog/banners/share-a-terminal-session-in-the-browser-without-handing-over-the-filesystem-shel.svg new file mode 100644 index 0000000..914203b --- /dev/null +++ b/public/blog/banners/share-a-terminal-session-in-the-browser-without-handing-over-the-filesystem-shel.svg @@ -0,0 +1,8 @@ + + + + SECURITY / SEP 13 2026 + Share a Terminal Session in theBrowser Without Handing Over theFilesystem: shell.online v0.14File Access + shell.online v0.14 adds opt-in, rooted file access... + pilotprotocol.network + diff --git a/public/blog/banners/share-a-terminal-session-password-with-a-teammate-without-the-service-seeing-it.svg b/public/blog/banners/share-a-terminal-session-password-with-a-teammate-without-the-service-seeing-it.svg new file mode 100644 index 0000000..cad6b69 --- /dev/null +++ b/public/blog/banners/share-a-terminal-session-password-with-a-teammate-without-the-service-seeing-it.svg @@ -0,0 +1,8 @@ + + + + SECURITY / SEP 23 2026 + Share a Terminal Session PasswordWith a Teammate Without theService Seeing It: shell.onlinev0.23.1 Password Requests + shell.online v0.23.1 lets a teammate ask a session... + pilotprotocol.network + diff --git a/public/blog/banners/shell-online-live-terminal-browser-link.svg b/public/blog/banners/shell-online-live-terminal-browser-link.svg new file mode 100644 index 0000000..73e1df9 --- /dev/null +++ b/public/blog/banners/shell-online-live-terminal-browser-link.svg @@ -0,0 +1,17 @@ + + + + + + + PILOT / PROTOCOL + + shell.online + A live browser link + for any terminal. + + shell.online + + terminals · agents · open-source + + diff --git a/public/blog/banners/start-a-terminal-session-from-the-browser-on-a-mac-that-sat-idle-for-days-the-la.svg b/public/blog/banners/start-a-terminal-session-from-the-browser-on-a-mac-that-sat-idle-for-days-the-la.svg new file mode 100644 index 0000000..a11ab66 --- /dev/null +++ b/public/blog/banners/start-a-terminal-session-from-the-browser-on-a-mac-that-sat-idle-for-days-the-la.svg @@ -0,0 +1,8 @@ + + + + ENGINEERING / SEP 25 2026 + Start a Terminal Session From theBrowser on a Mac That Sat Idlefor Days: the launchd PriorityFix in shell.online v0.23.3 + Why browser-started sessions on an idle Mac lagged... + pilotprotocol.network + diff --git a/public/blog/banners/team-terminal-sharing-sealed-so-the-service-cant-open-it.svg b/public/blog/banners/team-terminal-sharing-sealed-so-the-service-cant-open-it.svg new file mode 100644 index 0000000..34ea942 --- /dev/null +++ b/public/blog/banners/team-terminal-sharing-sealed-so-the-service-cant-open-it.svg @@ -0,0 +1,8 @@ + + + + SECURITY / SEP 09 2026 + Team Terminal Sharing,Sealed So the Service Can'tOpen It + Share terminal sessions with your team on... + pilotprotocol.network + diff --git a/public/blog/banners/track-terminal-sessions-across-machines-by-name-shell-online-v0-16-adds-name-and.svg b/public/blog/banners/track-terminal-sessions-across-machines-by-name-shell-online-v0-16-adds-name-and.svg new file mode 100644 index 0000000..fc0bb03 --- /dev/null +++ b/public/blog/banners/track-terminal-sessions-across-machines-by-name-shell-online-v0-16-adds-name-and.svg @@ -0,0 +1,8 @@ + + + + ENGINEERING / SEP 16 2026 + Track Terminal Sessions AcrossMachines by Name: shell.onlinev0.16 Adds --name and shell ls + shell.online v0.16.0 lets you name a session at... + pilotprotocol.network + diff --git a/public/blog/banners/vs-code-1-137-runs-agent-sessions-in-their-own-process-terminal-sharing-without.svg b/public/blog/banners/vs-code-1-137-runs-agent-sessions-in-their-own-process-terminal-sharing-without.svg new file mode 100644 index 0000000..c19fa88 --- /dev/null +++ b/public/blog/banners/vs-code-1-137-runs-agent-sessions-in-their-own-process-terminal-sharing-without.svg @@ -0,0 +1,8 @@ + + + + GUIDE / SEP 10 2026 + VS Code 1.137 Runs Agent Sessionsin Their Own Process. TerminalSharing Without SSH or VPN DoesThat for a Failing Build + How to pair-debug a failing build with terminal... + pilotprotocol.network + diff --git a/public/blog/banners/watch-a-coding-agent-remotely-without-shrinking-its-terminal-shell-online-v0-23.svg b/public/blog/banners/watch-a-coding-agent-remotely-without-shrinking-its-terminal-shell-online-v0-23.svg new file mode 100644 index 0000000..4671285 --- /dev/null +++ b/public/blog/banners/watch-a-coding-agent-remotely-without-shrinking-its-terminal-shell-online-v0-23.svg @@ -0,0 +1,8 @@ + + + + ENGINEERING / SEP 27 2026 + Watch a Coding Agent Remotely WithoutShrinking Its Terminal: shell.onlinev0.23.2 Gives the Host Its Grid andEach Viewer Its Own Layout + How to watch a coding agent remotely from a phone... + pilotprotocol.network + diff --git a/public/blog/banners/watch-claude-code-from-your-phone-as-a-conversation-not-an-80-column-grid.svg b/public/blog/banners/watch-claude-code-from-your-phone-as-a-conversation-not-an-80-column-grid.svg new file mode 100644 index 0000000..ede1da4 --- /dev/null +++ b/public/blog/banners/watch-claude-code-from-your-phone-as-a-conversation-not-an-80-column-grid.svg @@ -0,0 +1,8 @@ + + + + ENGINEERING / SEP 24 2026 + Watch Claude Code From YourPhone as a Conversation, Notan 80-Column Grid + How to watch Claude Code from your phone with the... + pilotprotocol.network + diff --git a/public/blog/banners/which-agent-session-needs-you-shell-online-v0-22-adds-a-passive-pulse-to-monitor.svg b/public/blog/banners/which-agent-session-needs-you-shell-online-v0-22-adds-a-passive-pulse-to-monitor.svg new file mode 100644 index 0000000..f4035cd --- /dev/null +++ b/public/blog/banners/which-agent-session-needs-you-shell-online-v0-22-adds-a-passive-pulse-to-monitor.svg @@ -0,0 +1,8 @@ + + + + ENGINEERING / SEP 22 2026 + Which Agent Session Needs You?shell.online v0.22 Adds a PassivePulse to Monitor a Coding Agentin the Browser + shell.online v0.22.0 adds a passive session pulse... + pilotprotocol.network + diff --git a/scripts/test-landing-seo.mjs b/scripts/test-landing-seo.mjs index d53fd17..b92fed7 100644 --- a/scripts/test-landing-seo.mjs +++ b/scripts/test-landing-seo.mjs @@ -183,6 +183,7 @@ for (const post of JSON.parse(blogRegistrySource)) { } check(landingSource.includes('Blog'), "The landing page must link to the blog"); check(exampleConfig.includes('"/blog",') && exampleConfig.includes('"/blog/*",'), "Blog routes must run through the Worker to be counted and to 404 properly"); +check(exampleConfig.includes('"/blogs",') && exampleConfig.includes('"/blogs/*",') && workerSource.includes("blogAliasRedirect(url.pathname)"), "/blogs must run through the Worker and redirect to /blog/"); check(workerSource.includes("blogAssetIsSpaFallback(url.pathname"), "A missing blog post must not answer with the landing page"); check(robots.includes("Sitemap: https://shell.online/blog/sitemap.xml"), "robots.txt does not advertise the blog sitemap"); check(robots.includes("User-agent: *\nAllow: /"), "robots.txt does not allow the canonical landing page"); diff --git a/shared/blog.ts b/shared/blog.ts index 9bbc649..3b5fd30 100644 --- a/shared/blog.ts +++ b/shared/blog.ts @@ -49,6 +49,17 @@ export function resolveBlogRoute( : null; } +/** + * Where a request under /blogs belongs: the same path under /blog/. People + * type the plural; it should land on the index or the post, not a 404. + */ +export function blogAliasRedirect(pathname: string): string | null { + if (pathname === "/blogs" || pathname === "/blogs/") return "/blog/"; + if (!pathname.startsWith("/blogs/")) return null; + const rest = pathname.slice("/blogs/".length); + return `/blog/${rest}`; +} + // The assets binding answers an unknown path with the landing page. Under // /blog/ that would make every mistyped post URL a 200 copy of the homepage. export function blogAssetIsSpaFallback( diff --git a/tests/blog.test.ts b/tests/blog.test.ts index 77ded36..6c9aa1b 100644 --- a/tests/blog.test.ts +++ b/tests/blog.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import registry from "../blog/posts.json"; import { + blogAliasRedirect, blogAssetIsSpaFallback, blogCategories, blogPosts, @@ -108,6 +109,16 @@ describe("blog routing", () => { expect(resolveBlogRoute(`/blog/${slug}/`)).toEqual({ kind: "post", slug }); }); + it("sends /blogs to the same place under /blog/", () => { + expect(blogAliasRedirect("/blogs")).toBe("/blog/"); + expect(blogAliasRedirect("/blogs/")).toBe("/blog/"); + expect(blogAliasRedirect("/blogs/a-post/")).toBe("/blog/a-post/"); + expect(blogAliasRedirect("/blogs/feed.xml")).toBe("/blog/feed.xml"); + expect(blogAliasRedirect("/blog/")).toBeNull(); + expect(blogAliasRedirect("/blogsx")).toBeNull(); + expect(blogAliasRedirect("/")).toBeNull(); + }); + it("detects the landing page returned for a missing post", () => { const html = "text/html; charset=utf-8"; expect(blogAssetIsSpaFallback("/blog/not-a-post/", html)).toBe(true); diff --git a/worker/index.ts b/worker/index.ts index 2d3208c..3f404eb 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -42,7 +42,7 @@ import { fetchAccountList, fetchAccountStats } from "./account-stats"; import { checkTeamAuthorization, type TeamAuthorizationResult } from "./team-authorization"; import { RELEASE_VERSION } from "../shared/release"; import { downloadAssetIsSpaFallback } from "../shared/download-assets"; -import { blogAssetIsSpaFallback, resolveBlogRoute } from "../shared/blog"; +import { blogAliasRedirect, blogAssetIsSpaFallback, resolveBlogRoute } from "../shared/blog"; import { viewerFrameAction } from "../shared/session-access"; import { MAX_SESSION_VIEWERS, @@ -455,6 +455,14 @@ const relayHandler = { return json({ error: "not found" }, 404); } + const blogAlias = blogAliasRedirect(url.pathname); + if (blogAlias !== null && (request.method === "GET" || request.method === "HEAD")) { + return new Response(null, { + status: 301, + headers: { Location: `${blogAlias}${url.search}`, "Cache-Control": "public, max-age=3600" }, + }); + } + const assetRequest = (request.method === "GET" || request.method === "HEAD") && isVersionedDocumentationPath(url.pathname) ? new Request(new URL(documentationAssetPath(url.pathname, RELEASE_VERSION), url), request) diff --git a/wrangler.example.jsonc b/wrangler.example.jsonc index 4da3bd7..45f4450 100644 --- a/wrangler.example.jsonc +++ b/wrangler.example.jsonc @@ -35,6 +35,8 @@ "/self-hosting/*", "/blog", "/blog/*", + "/blogs", + "/blogs/*", "/" ] },