Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve

### Added

- Opt-in, rooted file access over the existing encrypted session WebSocket.
Browsers can open a scoped file tree on demand, and Refstream mode turns
filename-like terminal output into backed previews without granting access
outside the CLI-selected root.
- A Refstream agent-connect surface with separately selectable read or control
permission. Agents can read, search, wait, type, execute, and send terminal
key combinations through a revocable, one-session invitation.

- A persisted terminal renderer dropdown in public shares and signed-in
session tabs. xterm.js is the default; Refstream (alpha) can be selected without
changing the process, relay protocol, encryption, or session permissions.
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ shell --read-only <command> # disable browser input
shell --foreground <command> # also show it locally
shell --auto-close 5m <command> # set an earlier deadline
shell --persistent <file> <command> # reuse a URL and password
shell --files <command> # opt in working-directory files
shell --files-root <dir> <command> # opt in a different file root

shell list # list local sessions (adapts to terminal width)
shell password <id> # retrieve an active password locally
Expand All @@ -78,6 +80,19 @@ shell kill <id> # stop a session
Press `Ctrl-X`, then `D`, to detach from an attached session. See
[`shell help reference`](https://shell.online/cli/) for every command and option.

File sharing is disabled unless `--files` or `--files-root` is present. Once
enabled, browsers can browse that root and open referenced files on demand.
Paths and contents use the session's E2EE WebSocket; the CLI rejects traversal,
non-regular files, and symlink escapes. File flags cannot be combined with
`--no-e2ee`. Refstream (alpha) adds inline backed-file
previews, while the Files panel works with either renderer.

Refstream (alpha) also provides a scoped **Connect agent** invitation. An agent
can read, search, and wait for terminal output; with explicit control permission
it can type, run commands, and send key combinations such as Ctrl-C. Disconnecting
the agent does not stop the terminal. Files remain unavailable unless the host
separately started the share with `--files` or `--files-root`.

## Security

The CLI owns the PTY and encrypts terminal frames before sending them to the
Expand Down
19 changes: 16 additions & 3 deletions app/src/styles/terminal.css
Original file line number Diff line number Diff line change
Expand Up @@ -191,11 +191,15 @@ button.tab {
justify-content: center;
}

.pane-refstream-toolbar {
.pane-tools {
position: absolute;
z-index: 6;
z-index: 7;
top: 10px;
right: 12px;
display: flex;
max-width: calc(100% - 24px);
align-items: flex-start;
gap: 8px;
}

.pane-refstream-toolbar:empty {
Expand All @@ -211,10 +215,19 @@ button.tab {
backdrop-filter: blur(12px);
}

.pane-refstream-toolbar:not(:empty) ~ .pane-banner {
.pane-tools:has(.pane-refstream-toolbar:not(:empty), .pane-files-toolbar:not(:empty)) ~ .pane-banner {
top: 58px;
}

@media (max-width: 640px) {
.pane-tools {
top: 6px;
right: 6px;
max-width: calc(100% - 12px);
gap: 5px;
}
}

/* The emulator sizes itself; it must not be shrunk to the flex line. */
.pane-screen > .xterm,
.pane-screen > .shell-terminal {
Expand Down
23 changes: 20 additions & 3 deletions app/src/terminal/TerminalPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import { Button } from "../components/Button";
import { Alert } from "../components/Alert";
import { createTerminal, type TerminalRenderer, type TerminalSurface } from "./renderer";
import { attachRefstreamTools } from "../../../web/refstream-tools";
import { RelayFileClient } from "../../../web/relay-files";
import { mountRelayFileBrowser } from "../../../web/relay-files-ui";
import "../../../web/relay-files.css";

export interface TerminalPaneProps {
shareUrl: string;
Expand Down Expand Up @@ -90,6 +93,7 @@ export function TerminalPane({
const pending = useRef<Attempt[]>([]);
const mount = useRef<HTMLDivElement>(null);
const toolsMount = useRef<HTMLDivElement>(null);
const filesMount = useRef<HTMLDivElement>(null);
const terminal = useRef<TerminalSurface | null>(null);
const measure = useRef<((fontSize: number) => TerminalCell) | null>(null);
const connection = useRef<TerminalConnection | null>(null);
Expand Down Expand Up @@ -183,7 +187,8 @@ export function TerminalPane({
useEffect(() => {
const node = mount.current;
const toolsNode = toolsMount.current;
if (!node || !toolsNode) return;
const filesNode = filesMount.current;
if (!node || !toolsNode || !filesNode) return;
tried.current = new Set();

/* A pane reused for another session starts from the default again. */
Expand Down Expand Up @@ -219,7 +224,12 @@ export function TerminalPane({
terminal.current = term;
measure.current = cellMeasurer(FONT_FAMILY);

let connected: TerminalConnection;
const fileClient = new RelayFileClient((frame) => connected.sendFrame(frame));
if (renderer === "refstream") term.options.fileLinks = fileClient.fileLinks;

const pane = node.closest<HTMLElement>(".pane");
const fileBrowser = pane ? mountRelayFileBrowser(fileClient, filesNode, pane) : null;
let rendererTools: { dispose(): void } | null = null;
let rendererToolsDisposed = false;
if (pane) {
Expand All @@ -240,7 +250,7 @@ export function TerminalPane({
});
}

const connected = new TerminalConnection({
connected = new TerminalConnection({
url: target.url,
fragment: encryptionFragment(shareUrl),
events: {
Expand All @@ -267,6 +277,7 @@ export function TerminalPane({
}
}
setStatus(next);
if (next === "connected") fileClient.probe();
setDetail(shown ?? "");
if (next === "needs-password") setUnlocking(false);
},
Expand All @@ -292,6 +303,7 @@ export function TerminalPane({
if (reset) term.reset();
term.write(bytes);
},
onFileFrame: (frame) => { fileClient.handle(frame); },
onReadOnly: (value) => {
setReadOnly(value);
term.options.disableStdin = value || !canTypeRef.current;
Expand Down Expand Up @@ -404,6 +416,8 @@ export function TerminalPane({
typed.dispose();
sink?.close();
connected.close();
fileClient.dispose();
fileBrowser?.dispose();
term.dispose();
terminal.current = null;
measure.current = null;
Expand Down Expand Up @@ -473,7 +487,10 @@ export function TerminalPane({

return (
<div className="pane" data-active={active} aria-hidden={!active}>
<div ref={toolsMount} className="refstream-toolbar pane-refstream-toolbar" aria-label="Refstream terminal tools" />
<div className="pane-tools">
<div ref={toolsMount} className="refstream-toolbar pane-refstream-toolbar" aria-label="Refstream terminal tools" />
<div ref={filesMount} className="pane-files-toolbar" aria-label="Shared files" />
</div>
<div className="pane-screen" ref={mount} />

{locked && (
Expand Down
9 changes: 9 additions & 0 deletions app/src/terminal/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export interface ConnectionEvents {
* joins or leaves.
*/
onGrid(grid: TerminalGrid): void;
/** Decrypted optional file-service frames, kept out of terminal output. */
onFileFrame?(frame: Uint8Array): void;
/**
* The key opened its first frame. Until then a password is only a guess, so
* this is the moment it can be kept as the right one.
Expand Down Expand Up @@ -134,6 +136,11 @@ export class TerminalConnection {
void this.transmit(encodeFrame(Opcode.Input, bytes));
}

/** Send a non-terminal frame such as an opted-in file request. */
sendFrame(frame: Uint8Array<ArrayBuffer>): void {
void this.transmit(frame);
}

close(): void {
this.stopped = true;
if (this.retryTimer !== null) clearTimeout(this.retryTimer);
Expand Down Expand Up @@ -260,6 +267,8 @@ export class TerminalConnection {
this.options.events.onData(frame.subarray(1), true);
} else if (opcode === Opcode.Output) {
this.options.events.onData(frame.subarray(1), false);
} else if (opcode === Opcode.FileResponse) {
this.options.events.onFileFrame?.(frame);
}
}

Expand Down
4 changes: 4 additions & 0 deletions app/src/terminal/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export const enum Opcode {
Pong = 0x07,
BroadcastSnapshot = 0x08,
ConfirmedEOF = 0x09,
/** Encrypted, relay-targeted request from one viewer to the CLI file service. */
FileRequest = 0x0a,
/** Encrypted, relay-targeted response from the CLI file service to one viewer. */
FileResponse = 0x0b,
}

export const MAX_INPUT_CHUNK = 16 * 1024;
Expand Down
1 change: 1 addition & 0 deletions app/src/terminal/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface TerminalSurface {
fontSize?: number;
lineHeight?: number;
disableStdin?: boolean;
fileLinks?: unknown;
};
open(element: HTMLElement): void;
write(data: string | Uint8Array, callback?: () => void): void;
Expand Down
1 change: 1 addition & 0 deletions cmd/shell/background.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type backgroundLaunchResult struct {
Password string `json:"e2ee_password,omitempty"`
Vault string `json:"vault,omitempty"`
Persistent bool `json:"persistent,omitempty"`
Files string `json:"files,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
ClosesAt *time.Time `json:"closes_at,omitempty"`
Handoff string `json:"handoff,omitempty"`
Expand Down
3 changes: 3 additions & 0 deletions cmd/shell/background_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ func launchBackgroundProcess(arguments []string, jsonOutput bool, stdout, stderr
if result.Handoff != "" {
event["handoff"] = result.Handoff
}
if result.Files != "" {
event["files"] = result.Files
}
encoded, _ := json.Marshal(event)
fmt.Fprintf(stderr, "%s\n", encoded)
return 0
Expand Down
3 changes: 3 additions & 0 deletions cmd/shell/background_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ func launchBackgroundProcess(arguments []string, jsonOutput bool, stdout, stderr
if result.Handoff != "" {
event["handoff"] = result.Handoff
}
if result.Files != "" {
event["files"] = result.Files
}
encoded, _ := json.Marshal(event)
fmt.Fprintf(stderr, "%s\n", encoded)
return 0
Expand Down
36 changes: 34 additions & 2 deletions cmd/shell/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ func printShellHelp(writer io.Writer) {
Start
shell <command> Share it in the background
shell --read-only <command> Share it while browser input is blocked
shell --files <command> Add on-demand files from this directory
shell Share a fresh shell
shell claude Share a fork of this conversation

Expand Down Expand Up @@ -41,9 +42,11 @@ Common options
--read-only View only
--foreground Stay attached locally
--persistent <state-file> Keep one encrypted URL across restarts
--files Opt in the working directory for file access
--files-root <directory> Opt in a different directory
--auto-close <time> Add an earlier deadline, such as 5m

Use shell help <start|attach|list|password|kill|login|agent|daemon|service|e2ee|docker|platforms> for a
Use shell help <start|files|attach|list|password|kill|login|agent|daemon|service|e2ee|docker|platforms> for a
guided topic, or shell help reference for every command, flag, and environment variable.
`)
}
Expand All @@ -54,7 +57,7 @@ func runHelp(arguments []string, stdout, stderr io.Writer) int {
return 0
}
if len(arguments) != 1 {
fmt.Fprintln(stderr, "Usage: shell help [start|attach|list|kill|login|agent|daemon|service|e2ee|docker|platforms|reference]")
fmt.Fprintln(stderr, "Usage: shell help [start|files|attach|list|kill|login|agent|daemon|service|e2ee|docker|platforms|reference]")
return 2
}

Expand All @@ -79,6 +82,8 @@ Examples
shell npm run dev
shell --foreground htop
shell --auto-close 5m pytest -x
shell --files claude
shell --files-root ./artifacts python train.py

When Claude Code runs "shell claude" through its Bash tool, shell detects the current
conversation and starts a shareable fork with its history. The original Claude process
Expand All @@ -92,6 +97,28 @@ E2EE notes
shell password <ID> prints an active session's password from its owner-only
local record. An unlocked account vault can recover passwords that were sealed
to it. If neither copy exists, E2EE deliberately has no recovery backdoor.
`)
case "files":
fmt.Fprint(stdout, `Share files on demand

shell --files <command>
shell --files-root <directory> <command>

File access is off by default. --files scopes it to the command's working
directory; --files-root chooses another root. Only regular files beneath that
root can be opened. Parent traversal, device files, and symlink escapes are
rejected by the CLI.

The browser receives no directory listing or file contents until it asks. When
enabled, a Files control appears in both xterm.js and Refstream views. Refstream
also turns filename-like terminal output into backed previews; terminal text is
never treated as filesystem authority. Read-only terminal links may read files
that were deliberately opted in, but they still cannot type into the process.

Files use the session's existing authenticated, end-to-end encrypted WebSocket.
The relay routes request IDs and encrypted frame sizes, not paths or contents.
Transfers are bounded and pull-driven so a slow viewer cannot block the PTY.
File sharing therefore cannot be combined with --no-e2ee.
`)
case "attach":
fmt.Fprint(stdout, `Attach locally
Expand Down Expand Up @@ -344,6 +371,11 @@ START OPTIONS
Reuse a stable session identity, password, and URL. The owner-only state
file contains host credentials, the browser password, and decryption material.
Re-run with the same file after a process or machine restart to restore the link.
--files
Opt in regular files under the process working directory. The browser
discovers and reads them only on demand; nothing is shared by default.
--files-root <directory>
Opt in a different root. Parent traversal and symlink escapes are rejected.
--foreground
Mirror and control the process in the launching terminal instead of
returning immediately.
Expand Down
Loading
Loading