From 050027c1c23fd13755d59bdf6504b34a7b38ddc2 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Sun, 13 Sep 2026 12:04:27 +0300 Subject: [PATCH] Add scoped agent and file access --- CHANGELOG.md | 8 + README.md | 15 + app/src/styles/terminal.css | 19 +- app/src/terminal/TerminalPane.tsx | 23 +- app/src/terminal/connection.ts | 9 + app/src/terminal/protocol.ts | 4 + app/src/terminal/renderer.ts | 1 + cmd/shell/background.go | 1 + cmd/shell/background_unix.go | 3 + cmd/shell/background_windows.go | 3 + cmd/shell/help.go | 36 +- cmd/shell/main.go | 26 +- cmd/shell/session_output.go | 3 + cmd/shell/session_unix.go | 6 +- cmd/shell/session_unix_test.go | 1 + cmd/shell/shared_files.go | 344 ++++++++++++++++++ cmd/shell/shared_files_test.go | 91 +++++ docs/content.json | 32 +- internal/e2ee/e2ee.go | 26 +- internal/e2ee/e2ee_test.go | 33 ++ internal/protocol/protocol.go | 2 + shared/protocol.ts | 4 + shared/session-access.ts | 5 +- standalone/server.test.ts | 22 ++ standalone/server.ts | 15 + tests/relay-files.test.ts | 63 ++++ tests/session-access.test.ts | 1 + web/main.ts | 14 + web/refstream-tools.ts | 9 +- web/relay-files-ui.ts | 243 +++++++++++++ web/relay-files.css | 79 ++++ web/relay-files.ts | 278 ++++++++++++++ web/terminal-renderer.ts | 2 + .../refstream/v0.1.0-alpha.2/refstream.d.ts | 1 + worker/index.ts | 34 ++ 35 files changed, 1433 insertions(+), 23 deletions(-) create mode 100644 cmd/shell/shared_files.go create mode 100644 cmd/shell/shared_files_test.go create mode 100644 tests/relay-files.test.ts create mode 100644 web/relay-files-ui.ts create mode 100644 web/relay-files.css create mode 100644 web/relay-files.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fed6ae3..3e07def 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index b3f3680..377ae59 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,8 @@ shell --read-only # disable browser input shell --foreground # also show it locally shell --auto-close 5m # set an earlier deadline shell --persistent # reuse a URL and password +shell --files # opt in working-directory files +shell --files-root # opt in a different file root shell list # list local sessions (adapts to terminal width) shell password # retrieve an active password locally @@ -78,6 +80,19 @@ shell kill # 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 diff --git a/app/src/styles/terminal.css b/app/src/styles/terminal.css index 7685837..b3a5180 100644 --- a/app/src/styles/terminal.css +++ b/app/src/styles/terminal.css @@ -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 { @@ -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 { diff --git a/app/src/terminal/TerminalPane.tsx b/app/src/terminal/TerminalPane.tsx index ccafa16..ab32ddd 100644 --- a/app/src/terminal/TerminalPane.tsx +++ b/app/src/terminal/TerminalPane.tsx @@ -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; @@ -90,6 +93,7 @@ export function TerminalPane({ const pending = useRef([]); const mount = useRef(null); const toolsMount = useRef(null); + const filesMount = useRef(null); const terminal = useRef(null); const measure = useRef<((fontSize: number) => TerminalCell) | null>(null); const connection = useRef(null); @@ -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. */ @@ -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(".pane"); + const fileBrowser = pane ? mountRelayFileBrowser(fileClient, filesNode, pane) : null; let rendererTools: { dispose(): void } | null = null; let rendererToolsDisposed = false; if (pane) { @@ -240,7 +250,7 @@ export function TerminalPane({ }); } - const connected = new TerminalConnection({ + connected = new TerminalConnection({ url: target.url, fragment: encryptionFragment(shareUrl), events: { @@ -267,6 +277,7 @@ export function TerminalPane({ } } setStatus(next); + if (next === "connected") fileClient.probe(); setDetail(shown ?? ""); if (next === "needs-password") setUnlocking(false); }, @@ -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; @@ -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; @@ -473,7 +487,10 @@ export function TerminalPane({ return (
-
+
+
+
+
{locked && ( diff --git a/app/src/terminal/connection.ts b/app/src/terminal/connection.ts index 3ed574b..e54ca9d 100644 --- a/app/src/terminal/connection.ts +++ b/app/src/terminal/connection.ts @@ -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. @@ -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): void { + void this.transmit(frame); + } + close(): void { this.stopped = true; if (this.retryTimer !== null) clearTimeout(this.retryTimer); @@ -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); } } diff --git a/app/src/terminal/protocol.ts b/app/src/terminal/protocol.ts index b73cbb5..5dddaf0 100644 --- a/app/src/terminal/protocol.ts +++ b/app/src/terminal/protocol.ts @@ -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; diff --git a/app/src/terminal/renderer.ts b/app/src/terminal/renderer.ts index a4d455b..84b1cb7 100644 --- a/app/src/terminal/renderer.ts +++ b/app/src/terminal/renderer.ts @@ -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; diff --git a/cmd/shell/background.go b/cmd/shell/background.go index 645ddf0..309d778 100644 --- a/cmd/shell/background.go +++ b/cmd/shell/background.go @@ -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"` diff --git a/cmd/shell/background_unix.go b/cmd/shell/background_unix.go index bc0ee77..5144201 100644 --- a/cmd/shell/background_unix.go +++ b/cmd/shell/background_unix.go @@ -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 diff --git a/cmd/shell/background_windows.go b/cmd/shell/background_windows.go index d0c2601..2cf5f05 100644 --- a/cmd/shell/background_windows.go +++ b/cmd/shell/background_windows.go @@ -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 diff --git a/cmd/shell/help.go b/cmd/shell/help.go index 58a8ee3..873c608 100644 --- a/cmd/shell/help.go +++ b/cmd/shell/help.go @@ -11,6 +11,7 @@ func printShellHelp(writer io.Writer) { Start shell Share it in the background shell --read-only Share it while browser input is blocked + shell --files Add on-demand files from this directory shell Share a fresh shell shell claude Share a fork of this conversation @@ -41,9 +42,11 @@ Common options --read-only View only --foreground Stay attached locally --persistent Keep one encrypted URL across restarts + --files Opt in the working directory for file access + --files-root Opt in a different directory --auto-close
+
+ + +

Loading…

+ `; + overlay.append(panel); + const title = panel.querySelector(".relay-files-title")!; + const close = panel.querySelector(".relay-files-close")!; + const path = panel.querySelector(".relay-files-path")!; + const body = panel.querySelector(".relay-files-body")!; + let currentPath = ""; + let generation = 0; + let previewAbort: AbortController | null = null; + + const closePanel = () => { + generation += 1; + previewAbort?.abort(); + previewAbort = null; + panel.hidden = true; + button.setAttribute("aria-expanded", "false"); + button.focus({ preventScroll: true }); + }; + + const showStatus = (message: string) => { + body.replaceChildren(); + const status = document.createElement("p"); + status.className = "relay-files-status"; + status.textContent = message; + body.append(status); + }; + + const renderPath = () => { + path.replaceChildren(); + const root = document.createElement("button"); + root.type = "button"; + root.textContent = client.root; + root.disabled = currentPath === ""; + root.addEventListener("click", () => void openDirectory(""), { signal }); + path.append(root); + let accumulated = ""; + for (const segment of currentPath.split("/").filter(Boolean)) { + const separator = document.createElement("span"); + separator.textContent = "/"; + accumulated = accumulated ? `${accumulated}/${segment}` : segment; + const destination = accumulated; + const item = document.createElement("button"); + item.type = "button"; + item.textContent = segment; + item.disabled = destination === currentPath; + item.addEventListener("click", () => void openDirectory(destination), { signal }); + path.append(separator, item); + } + }; + + const openDirectory = async (nextPath: string) => { + const current = ++generation; + previewAbort?.abort(); + previewAbort = null; + currentPath = nextPath; + renderPath(); + showStatus("Loading…"); + try { + const entries = await client.list(nextPath); + if (current !== generation) return; + renderEntries(entries); + } catch (error) { + if (current !== generation) return; + showStatus(error instanceof Error ? error.message : "Files unavailable."); + } + }; + + const renderEntries = (entries: RelayFileEntry[]) => { + body.replaceChildren(); + if (!entries.length) { + showStatus("This folder is empty."); + return; + } + const list = document.createElement("ul"); + list.className = "relay-files-list"; + for (const entry of entries) { + const row = document.createElement("li"); + const item = document.createElement("button"); + item.type = "button"; + item.innerHTML = ``; + const name = document.createElement("span"); + name.textContent = entry.name; + const detail = document.createElement("small"); + detail.textContent = entry.kind === "directory" ? "Folder" : formatBytes(entry.size ?? 0); + item.append(name, detail); + item.addEventListener("click", () => { + if (entry.kind === "directory") void openDirectory(entry.path); + else void openPreview(entry); + }, { signal }); + row.append(item); + list.append(row); + } + body.append(list); + }; + + const openPreview = async (entry: RelayFileEntry) => { + const current = ++generation; + previewAbort?.abort(); + previewAbort = new AbortController(); + showStatus("Opening…"); + try { + const resource = await client.resolve(entry.path, { purpose: "preview", signal: previewAbort.signal }); + if (!resource) throw new Error("File unavailable."); + const blob = await resourceBlob(resource); + if (current !== generation) return; + renderPreview(entry, resource, blob); + } catch (error) { + if (current !== generation || previewAbort.signal.aborted) return; + showStatus(error instanceof Error ? error.message : "File unavailable."); + } + }; + + const renderPreview = (entry: RelayFileEntry, resource: RelayFileResource, blob: Blob) => { + body.replaceChildren(); + const toolbar = document.createElement("div"); + toolbar.className = "relay-file-preview-heading"; + const back = document.createElement("button"); + back.type = "button"; + back.textContent = "← Folder"; + back.addEventListener("click", () => void openDirectory(currentPath), { signal }); + const label = document.createElement("strong"); + label.textContent = resource.name; + const download = document.createElement("button"); + download.type = "button"; + download.textContent = "Download"; + download.addEventListener("click", () => void downloadFile(entry), { signal }); + toolbar.append(back, label, download); + body.append(toolbar); + if (resource.mimeType.startsWith("image/")) { + const image = document.createElement("img"); + const url = URL.createObjectURL(blob); + image.src = url; + image.alt = resource.name; + image.addEventListener("load", () => URL.revokeObjectURL(url), { once: true }); + image.addEventListener("error", () => URL.revokeObjectURL(url), { once: true }); + body.append(image); + return; + } + if (isText(resource.mimeType, resource.name)) { + const pre = document.createElement("pre"); + void blob.text().then((text) => { pre.textContent = text; }); + body.append(pre); + return; + } + const unavailable = document.createElement("p"); + unavailable.className = "relay-files-status"; + unavailable.textContent = `${formatBytes(resource.size)} · Preview unavailable. Download to open it.`; + body.append(unavailable); + }; + + const downloadFile = async (entry: RelayFileEntry) => { + const controller = new AbortController(); + try { + const resource = await client.resolve(entry.path, { purpose: "download", signal: controller.signal }); + if (!resource) return; + const blob = await resourceBlob(resource); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = resource.name; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + globalThis.setTimeout(() => URL.revokeObjectURL(url), 1_000); + } catch { + showStatus("Download unavailable."); + } + }; + + button.addEventListener("click", () => { + panel.hidden = false; + button.setAttribute("aria-expanded", "true"); + title.textContent = client.root; + void openDirectory(""); + }, { signal }); + close.addEventListener("click", closePanel, { signal }); + panel.addEventListener("keydown", (event) => { + if (event.key === "Escape") closePanel(); + }, { signal }); + const availability = client.onAvailability((available) => { + button.hidden = !available; + if (!available && !panel.hidden) closePanel(); + }); + + return { + dispose() { + generation += 1; + previewAbort?.abort(); + lifetime.abort(); + availability.dispose(); + button.remove(); + panel.remove(); + }, + }; +} + +async function resourceBlob(resource: RelayFileResource): Promise { + return new Response(resource.body, { headers: { "Content-Type": resource.mimeType } }).blob(); +} + +function formatBytes(value: number): string { + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`; + return `${(value / 1024 / 1024).toFixed(1)} MB`; +} + +function isText(mimeType: string, name: string): boolean { + return mimeType.startsWith("text/") || /\.(?:c|cc|cpp|css|go|h|html|ini|java|js|json|jsx|log|md|py|rb|rs|sh|toml|ts|tsx|txt|xml|ya?ml)$/iu.test(name); +} diff --git a/web/relay-files.css b/web/relay-files.css new file mode 100644 index 0000000..4331465 --- /dev/null +++ b/web/relay-files.css @@ -0,0 +1,79 @@ +.relay-files-button { + display: inline-flex; + min-width: 44px; + min-height: 40px; + padding: 0 12px; + border: 1px solid var(--terminal-ui-line, #303847); + border-radius: 12px; + align-items: center; + justify-content: center; + gap: 7px; + background: var(--terminal-ui-surface, #171d27); + color: var(--terminal-ui-fg, #dce7fc); + cursor: pointer; + font: 560 11px/1 system-ui, sans-serif; + touch-action: manipulation; +} + +.relay-files-button[hidden] { display: none; } +.relay-files-button svg { width: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; } +.relay-files-button:hover { background: var(--terminal-ui-hover, #222a37); } +.relay-files-button:focus-visible, +.relay-files-panel button:focus-visible { outline: 2px solid var(--terminal-ui-accent, #84a8ee); outline-offset: 2px; } + +.relay-files-panel { + position: absolute; + z-index: 30; + top: 58px; + right: 12px; + display: grid; + width: min(420px, calc(100% - 24px)); + max-height: min(70%, 620px); + grid-template-rows: auto auto minmax(0, 1fr); + overflow: hidden; + border: 1px solid var(--terminal-ui-line, #303847); + border-radius: 16px; + background: var(--terminal-ui-surface, #111722); + color: var(--terminal-ui-fg, #e4eaf4); + box-shadow: 0 22px 60px rgb(0 0 0 / 38%); + font: 500 12px/1.35 system-ui, sans-serif; +} + +.relay-files-panel[hidden] { display: none; } +.relay-files-panel header { display: flex; min-height: 58px; padding: 10px 10px 10px 14px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--terminal-ui-line, #303847); } +.relay-files-panel header div { display: grid; min-width: 0; } +.relay-files-panel header small { color: var(--terminal-ui-muted, #8290a6); font-size: 9px; text-transform: uppercase; letter-spacing: .08em; } +.relay-files-panel header strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; } +.relay-files-close { width: 38px; height: 38px; border: 0; border-radius: 10px; background: transparent; color: inherit; cursor: pointer; font-size: 22px; } +.relay-files-path { display: flex; min-height: 38px; padding: 6px 12px; align-items: center; gap: 5px; overflow-x: auto; border-bottom: 1px solid var(--terminal-ui-line, #303847); color: var(--terminal-ui-muted, #8290a6); scrollbar-width: none; } +.relay-files-path button { padding: 4px; border: 0; background: none; color: inherit; cursor: pointer; font: inherit; white-space: nowrap; } +.relay-files-path button:disabled { color: var(--terminal-ui-fg, #e4eaf4); cursor: default; } +.relay-files-body { min-height: 180px; overflow: auto; overscroll-behavior: contain; } +.relay-files-status { margin: 0; padding: 24px 16px; color: var(--terminal-ui-muted, #8290a6); text-align: center; } +.relay-files-list { margin: 0; padding: 5px; list-style: none; } +.relay-files-list button { display: grid; width: 100%; min-height: 44px; padding: 7px 9px; border: 0; border-radius: 9px; grid-template-columns: 20px minmax(0, 1fr) auto; align-items: center; background: none; color: inherit; cursor: pointer; text-align: left; font: inherit; } +.relay-files-list button:hover { background: var(--terminal-ui-hover, #222a37); } +.relay-files-list button > span:nth-child(2) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.relay-files-list small { color: var(--terminal-ui-muted, #8290a6); font-size: 10px; } +.relay-file-icon { color: var(--terminal-ui-accent, #84a8ee); } +.relay-file-preview-heading { display: grid; position: sticky; top: 0; min-height: 48px; padding: 6px; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 8px; background: var(--terminal-ui-surface, #111722); border-bottom: 1px solid var(--terminal-ui-line, #303847); } +.relay-file-preview-heading button { min-height: 36px; padding: 0 9px; border: 1px solid var(--terminal-ui-line, #303847); border-radius: 8px; background: none; color: inherit; cursor: pointer; font: inherit; } +.relay-file-preview-heading strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center; } +.relay-files-body pre { margin: 0; padding: 14px; overflow: auto; white-space: pre-wrap; word-break: break-word; font: 11px/1.5 ui-monospace, monospace; } +.relay-files-body img { display: block; width: 100%; height: auto; max-height: 460px; object-fit: contain; background: #0a0d12; } + +.session-page.theme-light .relay-files-button, +.session-page.theme-light .relay-files-panel { + --terminal-ui-surface: #f8fafc; + --terminal-ui-fg: #233048; + --terminal-ui-line: #d3dbe7; + --terminal-ui-muted: #66748a; + --terminal-ui-hover: #edf2f8; + --terminal-ui-accent: #5479bc; +} + +@media (max-width: 640px) { + .relay-files-button { width: 44px; padding: 0; } + .relay-files-button span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } + .relay-files-panel { top: 52px; right: 6px; width: calc(100% - 12px); max-height: calc(100% - 64px); border-radius: 13px; } +} diff --git a/web/relay-files.ts b/web/relay-files.ts new file mode 100644 index 0000000..dca605e --- /dev/null +++ b/web/relay-files.ts @@ -0,0 +1,278 @@ +import { encodeFrame, Opcode } from "../shared/protocol"; + +export interface RelayFileEntry { + path: string; + name: string; + kind: "file" | "directory"; + size?: number; +} + +export interface RelayFileResource { + name: string; + mimeType: string; + size: number; + body: ReadableStream; +} + +interface ControlMessage { + id: number; + type: "capabilities" | "list" | "meta" | "error"; + root?: string; + path?: string; + entries?: RelayFileEntry[]; + name?: string; + mimeType?: string; + size?: number; + token?: string; + code?: string; + message?: string; +} + +interface Transfer { + id: number; + path: string; + purpose: "preview" | "download"; + offset: number; + token: string; + inFlight: boolean; + controller?: ReadableStreamDefaultController; + resolve(value: RelayFileResource | null): void; + reject(error: Error): void; + settled: boolean; + timeout?: ReturnType; + abort?: () => void; + signal: AbortSignal; +} + +type Sender = (frame: Uint8Array) => void; +type Disposable = { dispose(): void }; + +const CONTROL_KIND = 1; +const CHUNK_KIND = 2; +const REQUEST_TIMEOUT_MS = 15_000; + +/** + * Browser half of shell.online's optional relay-backed file protocol. + * + * Requests use the terminal's existing E2EE frame cipher. The relay sees an + * opcode and requester id for routing, but never a path or file byte. + */ +export class RelayFileClient { + private nextId = 0; + private sender: Sender; + private transfers = new Map(); + private lists = new Map }>(); + private listeners = new Set<() => void>(); + private availability = new Set<(available: boolean) => void>(); + private probing = new Set(); + private disposed = false; + root = "files"; + available = false; + + constructor(sender: Sender) { + this.sender = sender; + } + + setSender(sender: Sender): void { + this.sender = sender; + } + + probe(): void { + if (this.disposed) return; + const id = this.id(); + this.probing.add(id); + this.send({ id, type: "capabilities" }); + globalThis.setTimeout(() => this.probing.delete(id), 3_000); + } + + onAvailability(listener: (available: boolean) => void): Disposable { + this.availability.add(listener); + listener(this.available); + return { dispose: () => this.availability.delete(listener) }; + } + + onChange(listener: () => void): Disposable { + this.listeners.add(listener); + return { dispose: () => this.listeners.delete(listener) }; + } + + async list(path = ""): Promise { + if (!this.available || this.disposed) return []; + const id = this.id(); + return new Promise((resolve, reject) => { + const timeout = globalThis.setTimeout(() => { + this.lists.delete(id); + reject(new Error("File listing timed out.")); + }, REQUEST_TIMEOUT_MS); + this.lists.set(id, { resolve, reject, timeout }); + this.send({ id, type: "list", path }); + }); + } + + resolve(path: string, request: { purpose: "preview" | "download"; signal: AbortSignal }): Promise { + if (!this.available || this.disposed || request.signal.aborted) return Promise.resolve(null); + const id = this.id(); + return new Promise((resolve, reject) => { + const transfer: Transfer = { + id, path, purpose: request.purpose, offset: 0, token: "", inFlight: false, + resolve, reject, settled: false, signal: request.signal, + }; + const abort = () => this.failTransfer(transfer, new DOMException("File request cancelled.", "AbortError")); + transfer.abort = abort; + request.signal.addEventListener("abort", abort, { once: true }); + transfer.timeout = globalThis.setTimeout(() => this.failTransfer(transfer, new Error("File request timed out.")), REQUEST_TIMEOUT_MS); + this.transfers.set(id, transfer); + this.pull(transfer); + }); + } + + readonly fileLinks = { + // --files explicitly grants access to the scoped root. Detection remains + // local; the CLI is still authoritative and rejects escapes/non-files. + canResolve: (reference: { path: string }): boolean => this.available && Boolean(reference.path), + onChange: (listener: () => void): Disposable => this.onChange(listener), + resolve: (reference: { path: string }, request: { purpose: "preview" | "download"; signal: AbortSignal }) => + this.resolve(reference.path, request), + }; + + handle(frame: Uint8Array): boolean { + if (frame[0] !== Opcode.FileResponse || frame.byteLength < 3) return false; + if (frame[1] === CONTROL_KIND) { + this.handleControl(frame.subarray(2)); + return true; + } + if (frame[1] === CHUNK_KIND) { + this.handleChunk(frame); + return true; + } + return true; + } + + reset(): void { + if (this.available) { + this.available = false; + for (const listener of this.availability) listener(false); + for (const listener of this.listeners) listener(); + } + for (const transfer of [...this.transfers.values()]) this.failTransfer(transfer, new Error("File connection ended.")); + for (const [id, pending] of this.lists) { + clearTimeout(pending.timeout); + pending.reject(new Error("File connection ended.")); + this.lists.delete(id); + } + } + + dispose(): void { + this.disposed = true; + this.reset(); + this.listeners.clear(); + this.availability.clear(); + } + + private id(): number { + this.nextId = this.nextId >= 0xffff_ffff ? 1 : this.nextId + 1; + return this.nextId; + } + + private send(message: Record): void { + const bytes = new TextEncoder().encode(JSON.stringify(message)); + this.sender(encodeFrame(Opcode.FileRequest, bytes)); + } + + private pull(transfer: Transfer): void { + if (transfer.inFlight || !this.transfers.has(transfer.id)) return; + transfer.inFlight = true; + this.send({ + id: transfer.id, + type: "read", + path: transfer.path, + purpose: transfer.purpose, + offset: transfer.offset, + ...(transfer.token ? { token: transfer.token } : {}), + }); + } + + private handleControl(bytes: Uint8Array): void { + let message: ControlMessage; + try { + message = JSON.parse(new TextDecoder().decode(bytes)) as ControlMessage; + } catch { + return; + } + if (!Number.isInteger(message.id) || message.id <= 0) return; + if (message.type === "capabilities" && this.probing.delete(message.id)) { + this.root = typeof message.root === "string" && message.root ? message.root : "files"; + if (!this.available) { + this.available = true; + for (const listener of this.availability) listener(true); + for (const listener of this.listeners) listener(); + } + return; + } + if (message.type === "list") { + const pending = this.lists.get(message.id); + if (!pending) return; + this.lists.delete(message.id); + clearTimeout(pending.timeout); + const entries = Array.isArray(message.entries) ? message.entries.filter(validEntry) : []; + pending.resolve(entries); + return; + } + const transfer = this.transfers.get(message.id); + if (!transfer) return; + if (message.type === "error") { + this.failTransfer(transfer, new Error(message.message || "File unavailable.")); + return; + } + if (message.type !== "meta" || transfer.settled || typeof message.name !== "string" || + typeof message.mimeType !== "string" || typeof message.size !== "number" || typeof message.token !== "string") return; + transfer.token = message.token; + const body = new ReadableStream({ + start: (controller) => { transfer.controller = controller; }, + pull: () => this.pull(transfer), + cancel: () => this.finishTransfer(transfer), + }, { highWaterMark: 0 }); + transfer.settled = true; + transfer.resolve({ name: message.name, mimeType: message.mimeType, size: message.size, body }); + } + + private handleChunk(frame: Uint8Array): void { + if (frame.byteLength < 15) return; + const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength); + const id = view.getUint32(2); + const offset = Number(view.getBigUint64(6)); + const transfer = this.transfers.get(id); + if (!transfer || offset !== transfer.offset) return; + transfer.inFlight = false; + clearTimeout(transfer.timeout); + transfer.timeout = globalThis.setTimeout(() => this.failTransfer(transfer, new Error("File request timed out.")), REQUEST_TIMEOUT_MS); + const chunk = new Uint8Array(frame.subarray(15)); + transfer.offset += chunk.byteLength; + if (chunk.byteLength) transfer.controller?.enqueue(chunk); + if (frame[14] === 1) this.finishTransfer(transfer); + } + + private finishTransfer(transfer: Transfer): void { + if (!this.transfers.delete(transfer.id)) return; + clearTimeout(transfer.timeout); + if (transfer.abort) transfer.signal.removeEventListener("abort", transfer.abort); + transfer.controller?.close(); + if (!transfer.settled) transfer.resolve(null); + } + + private failTransfer(transfer: Transfer, error: Error): void { + if (!this.transfers.delete(transfer.id)) return; + clearTimeout(transfer.timeout); + if (transfer.abort) transfer.signal.removeEventListener("abort", transfer.abort); + if (transfer.settled) transfer.controller?.error(error); + else transfer.reject(error); + } +} + +function validEntry(value: unknown): value is RelayFileEntry { + if (!value || typeof value !== "object") return false; + const entry = value as Partial; + return typeof entry.path === "string" && typeof entry.name === "string" && + (entry.kind === "file" || entry.kind === "directory") && + (entry.size === undefined || Number.isSafeInteger(entry.size) && entry.size >= 0); +} diff --git a/web/terminal-renderer.ts b/web/terminal-renderer.ts index 2abf582..5a2bdda 100644 --- a/web/terminal-renderer.ts +++ b/web/terminal-renderer.ts @@ -18,6 +18,8 @@ export interface TerminalSurface { lineHeight?: number; theme?: ITerminalOptions["theme"]; disableStdin?: boolean; + /** Refstream's optional backed-file resolver. Ignored by xterm.js. */ + fileLinks?: unknown; }; readonly buffer: { readonly active: { readonly type: "normal" | "alternate" } }; readonly modes: { readonly mouseTrackingMode: string }; diff --git a/web/vendor/refstream/v0.1.0-alpha.2/refstream.d.ts b/web/vendor/refstream/v0.1.0-alpha.2/refstream.d.ts index c32be45..1412dc9 100644 --- a/web/vendor/refstream/v0.1.0-alpha.2/refstream.d.ts +++ b/web/vendor/refstream/v0.1.0-alpha.2/refstream.d.ts @@ -42,6 +42,7 @@ export interface TerminalOptions { cursorStyle?: "block" | "underline" | "bar"; cursorInactiveStyle?: "outline" | "block" | "bar" | "underline" | "none"; disableStdin?: boolean; + fileLinks?: unknown; drawBoldTextInBrightColors?: boolean; macOptionIsMeta?: boolean; scrollOnUserInput?: boolean; diff --git a/worker/index.ts b/worker/index.ts index 5e99bca..f614d98 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -1168,6 +1168,24 @@ export class TerminalSession extends DurableObject { this.broadcastBinary(frame, "viewer"); return; + case Opcode.FileResponse: { + if (frame.byteLength < 6 || frame.byteLength > MAX_LIVE_FRAME_BYTES + 5 + (this.isEncrypted() ? MAX_ENCRYPTION_OVERHEAD_BYTES : 0)) { + safeClose(socket, 4009, "file response frame too large"); + return; + } + const targetId = new DataView(frame.buffer, frame.byteOffset, frame.byteLength).getUint32(1); + const target = this.state + .getWebSockets("viewer") + .find((candidate) => readAttachment(candidate)?.id === targetId); + if (target) { + const outbound = new Uint8Array(frame.byteLength - 4); + outbound[0] = Opcode.FileResponse; + outbound.set(frame.subarray(5), 1); + safeSend(target, outbound); + } + return; + } + default: safeClose(socket, 4002, "host opcode not allowed"); } @@ -1209,6 +1227,22 @@ export class TerminalSession extends DurableObject { return; } + if (action === "file-request") { + if (frame.byteLength < 2 || frame.byteLength > MAX_LIVE_FRAME_BYTES + 1 + (this.isEncrypted() ? MAX_ENCRYPTION_OVERHEAD_BYTES : 0)) { + safeClose(socket, 4009, "file request frame too large"); + return; + } + // Attach the authenticated viewer id outside the encrypted body. The + // CLI echoes it in FileResponse so the relay can route file bytes only + // to the requester; paths and contents remain opaque to this worker. + const targeted = new Uint8Array(frame.byteLength + 4); + targeted[0] = Opcode.FileRequest; + new DataView(targeted.buffer).setUint32(1, attachment.id); + targeted.set(frame.subarray(1), 5); + this.broadcastBinary(targeted, "host"); + return; + } + if (action === "resize") { if (this.isEncrypted()) { if (frame.byteLength !== 34) {