Skip to content

fix(dashboard): route OSC 52 copy through an insecure-context fallback - #222

Merged
aterrylu merged 1 commit into
mainfrom
terry/osc52-remote-clipboard
Jun 20, 2026
Merged

fix(dashboard): route OSC 52 copy through an insecure-context fallback#222
aterrylu merged 1 commit into
mainfrom
terry/osc52-remote-clipboard

Conversation

@aterrylu

Copy link
Copy Markdown
Owner

Problem

Claude Code auto-copies your terminal selection by emitting an OSC 52 escape sequence (ESC ]52;c;<base64> BEL) and prints sent 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 the localhost/127.0.0.1 exception browsers grant even over plain HTTP. On a remote LAN IP/hostname over plain HTTP the page is not a secure context, so navigator.clipboard is undefined. The bare .writeText property access threw a synchronous TypeError, which was swallowed by the handler's outer try/catch and 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://&lt;ip&gt; (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:#dfd
Loading

The 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:

  1. Secure context (localhost/HTTPS): use navigator.clipboard.writeText exactly as before — local path unchanged.
  2. Insecure context (plain HTTP remote): navigator.clipboard is undefined, 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.
  3. Secure-but-rejected (e.g. transient activation expired): the writeText rejection also routes to the execCommand fallback.
  4. A console.warn breadcrumb 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.

Note on the activation caveat: OSC 52 arrives server-pushed with no click in the JS call stack. execCommand relies on transient user activation, which is a ~5s window — and CC emits OSC 52 milliseconds after your mouse-up selection, so the window is typically still open. Whether silent auto-copy lands on plain HTTP without a click is the one thing only real-remote QA can confirm (see below). If it doesn't, the console.warn breadcrumb will tell us, and the follow-up is a one-click Copy affordance.

Testing

  • make check green locally: biome ✓, tsc --build ✓, server/app tests 470 pass, dashboard vitest 227 pass.
  • New jsdom suite (xterm-backend.clipboard.dom.test.ts, 14 tests): secure path, insecure execCommand fallback, writeText-rejection fallback, both-mechanisms-failed warn, focus restoration, OSC 52 read-drop guard, multi-byte UTF-8 round-trip, malformed base64.
  • Reviewed via /polish (code-reviewer + simplifier + silent-failure-hunter); all findings addressed.

⚠️ Remote QA required before merge (cannot be tested on localhost)

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:

  1. Deploy this branch to the remote, open http://<ip>:3100 from another device.
  2. In a Claude Code pane, select/scroll text so CC emits OSC 52 (sent N chars).
  3. Paste elsewhere on that device → text should now appear (pre-fix: nothing).
  4. Confirm local auto-copy still works (regression).

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.execCommand is deprecated but still broadly supported; it's the only insecure-context clipboard write available, used as a fallback only.
  • Silent auto-copy on plain HTTP depends on transient activation being live; worst case degrades to "needs a click" (future affordance), with a console breadcrumb.

Alternatives considered

  • Server-side pbcopy/xclip over WebSocket — writes the server's clipboard, not the viewing device's; wrong machine for a true remote deploy.
  • Require HTTPS (reverse proxy) — the cleanest systemic fix (also fixes copy-link in PreviewPane.tsx), recommended as a deployment option, but doesn't help plain-HTTP users and isn't a code change.
  • @xterm/addon-clipboard — uses navigator.clipboard exclusively; fails identically on insecure contexts and is a larger refactor.

🤖 Generated with Claude Code

@aterrylu
aterrylu enabled auto-merge (squash) June 20, 2026 01:26
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
@aterrylu
aterrylu force-pushed the terry/osc52-remote-clipboard branch from 35dcda6 to 59cc534 Compare June 20, 2026 01:27

@nox-0x nox-0x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:128 has the same navigator.clipboard.writeText unconditional call and would fail identically on plain HTTP. Extracting copyTextToClipboard into a shared util in a follow-up would close that gap.
  • In the writeText-rejection path, the .catch microtask runs after the OSC 52 emit, so transient activation may already be near its 5s edge by the time execCommandCopy runs. Acknowledged in the code comment; the console.warn breadcrumb will surface it if it bites.

@aterrylu
aterrylu merged commit f58eace into main Jun 20, 2026
5 checks passed
@aterrylu
aterrylu deleted the terry/osc52-remote-clipboard branch June 20, 2026 01:28
aterrylu added a commit that referenced this pull request Jun 20, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants