fix(dashboard): route OSC 52 copy through an insecure-context fallback - #222
Conversation
Claude Code emits OSC 52 (ESC ]52;c;<base64> BEL) to auto-copy the
terminal selection. The dashboard handler decoded the payload and called
`navigator.clipboard.writeText` unconditionally. `navigator.clipboard`
only exists in a secure context — HTTPS, or the localhost/127.0.0.1
exception browsers grant over plain HTTP. On a remote-served deployment
(plain HTTP on a LAN IP/hostname) the page is NOT a secure context, so
`navigator.clipboard` is `undefined` and the bare `.writeText` property
access threw a synchronous TypeError. The throw was swallowed by the
handler's outer try/catch and mislogged as "OSC 52 decode failed", so
remote auto-copy failed silently even though Claude Code reported
"sent N chars via OSC 52". Local (localhost/HTTPS) worked because those
origins are secure-context exceptions.
Fix: extract the clipboard write into `copyTextToClipboard`, which probes
for the Clipboard API and falls back to a transient off-screen
`<textarea>` + `document.execCommand("copy")` when it is unavailable or
rejects. execCommand is not secure-context-gated and works over plain
HTTP; it relies on transient user activation, which is typically still
live because OSC 52 is emitted milliseconds after the user's selection.
A `console.warn` breadcrumb is logged only when BOTH mechanisms fail, so
a residual failure (e.g. activation expired) is greppable rather than
invisible. The OSC 52 read-drop ("?") anti-exfiltration guard and the
base64 decode-error handling are preserved; decode errors are now scoped
to the decode step, not the copy.
Local auto-copy is unchanged: the Clipboard API path runs exactly as
before on localhost/HTTPS.
Adds jsdom tests covering the secure path, the insecure execCommand
fallback, the writeText-rejection fallback, the both-mechanisms-failed
warn, focus restoration, the read-drop guard, multi-byte UTF-8
round-tripping, and malformed base64.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwFzy8JiM6H2NENce8Hmpx
35dcda6 to
59cc534
Compare
nox-0x
left a comment
There was a problem hiding this comment.
Approving — clean, well-targeted fix for a real silent failure, and the test suite covers every branch of the new logic.
The capability detection (navigator.clipboard?.writeText) is the right shape — it handles both the insecure-context-no-API case and the secure-context-rejection case, with execCommandCopy as the unified fallback. Separating the decode try from the clipboard write also fixes the original mislogging bug (TypeError → "OSC 52 decode failed") as a side effect. Read-drop (payload === "?") preserved. 14 new tests including focus restoration, multi-byte UTF-8 round-trip, both-mechanisms-failed warn, and malformed base64.
The remote-QA caveat in the PR body is the right call — localhost is always secure-context, so the new branch genuinely can't be exercised locally. Unit tests simulate it; real validation is the deploy step.
Non-blocking follow-ups:
PreviewPane.tsx:128has the samenavigator.clipboard.writeTextunconditional call and would fail identically on plain HTTP. ExtractingcopyTextToClipboardinto a shared util in a follow-up would close that gap.- In the writeText-rejection path, the
.catchmicrotask runs after the OSC 52 emit, so transient activation may already be near its 5s edge by the timeexecCommandCopyruns. Acknowledged in the code comment; theconsole.warnbreadcrumb will surface it if it bites.
Four append-only ADRs documenting decisions shipped across PRs #220, #219, #221, and #222: - ADR-039 — Terminal-only view, xterm.js-only renderer (#220, Cleanup@autonomOS) - ADR-040 — selectUsageOrg() picks chat/claude_max capability (#219, ClaudeUsage) - ADR-041 — Zero-touch Claude Usage via in-memory cookie harvest (#221, ClaudeUsage) - ADR-042 — Insecure-context clipboard fallback for OSC 52 (#222, RemoteCopy) Bundled to keep the docs cadence dense — each ADR is owned by the agent who shipped the underlying code. ADR-038 and prior entries untouched. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Problem
Claude Code auto-copies your terminal selection by emitting an OSC 52 escape sequence (
ESC ]52;c;<base64> BEL) and printssent N chars via OSC 52. This works on a local dashboard but silently fails on a remote-served deployment (http://<ip>:3100): CC reports the copy, but nothing reaches the clipboard.Root cause: the dashboard's OSC 52 handler called
navigator.clipboard.writeText()unconditionally. That API only exists in a secure context — HTTPS, or thelocalhost/127.0.0.1exception browsers grant even over plain HTTP. On a remote LAN IP/hostname over plain HTTP the page is not a secure context, sonavigator.clipboardisundefined. The bare.writeTextproperty access threw a synchronousTypeError, which was swallowed by the handler's outertry/catchand mislogged as"OSC 52 decode failed"— invisible.flowchart LR CC["Claude Code<br/>emits OSC 52<br/>'sent N chars'"] -->|PTY| S[node-pty server] S -->|WebSocket<br/>raw bytes| X["xterm.js<br/>registerOscHandler(52)"] X -->|atob + decode| C{"navigator.clipboard<br/>.writeText"} C -->|"localhost / HTTPS<br/>(secure context)"| OK["✅ clipboard written"] C -->|"http://<ip> (insecure)"| OLD["❌ clipboard undefined →<br/>TypeError thrown →<br/>mislogged, silent"] OLD -.->|"this PR"| FB["execCommand('copy')<br/>via transient textarea<br/>✅ works on plain HTTP"] style OLD fill:#fdd style OK fill:#dfd style FB fill:#dfdThe byte pipeline (CC → PTY → WebSocket → xterm → handler) was intact in both cases — the only difference is the browser's secure-context verdict on the page origin.
Solution
Extract the clipboard write into
copyTextToClipboard(), which capability-detects rather than environment-detects:navigator.clipboard.writeTextexactly as before — local path unchanged.navigator.clipboardisundefined, so fall back to a transient off-screen<textarea>+document.execCommand("copy"), which is not secure-context-gated and works over plain HTTP. Previous focus is restored so the terminal keeps keyboard focus.writeTextrejection also routes to theexecCommandfallback.console.warnbreadcrumb fires only when both mechanisms fail, converting a residual silent no-op into a greppable signal.The same build self-selects the right path per origin, so it supports local and remote simultaneously (one of the deployment configs Terry runs). The OSC 52 read-drop (
payload === "?") anti-exfiltration guard from #160 is preserved; no clipboard read path is introduced.Testing
make checkgreen locally: biome ✓,tsc --build✓, server/app tests 470 pass, dashboard vitest 227 pass.xterm-backend.clipboard.dom.test.ts, 14 tests): secure path, insecureexecCommandfallback,writeText-rejection fallback, both-mechanisms-failed warn, focus restoration, OSC 52 read-drop guard, multi-byte UTF-8 round-trip, malformed base64./polish(code-reviewer + simplifier + silent-failure-hunter); all findings addressed.localhost is always a secure context, so the new fallback branch cannot be exercised locally — unit tests simulate the insecure context instead. The real end-to-end check must run on the remote box:
http://<ip>:3100from another device.sent N chars).If step 3 fails, check the browser console for
OSC 52 copy failed:— that's the signal we need the click-to-copy follow-up.Risks
document.execCommandis deprecated but still broadly supported; it's the only insecure-context clipboard write available, used as a fallback only.Alternatives considered
pbcopy/xclipover WebSocket — writes the server's clipboard, not the viewing device's; wrong machine for a true remote deploy.PreviewPane.tsx), recommended as a deployment option, but doesn't help plain-HTTP users and isn't a code change.@xterm/addon-clipboard— usesnavigator.clipboardexclusively; fails identically on insecure contexts and is a larger refactor.🤖 Generated with Claude Code