Shift+Enter newline, OAuth URL truncation, and the auth bridge toggle

Three fixes that all land on the same journey: sign in, paste a prompt,
and have the terminal behave the way every other Claude Code host does.

Shift+Enter inserts a newline
-----------------------------
xterm.js does not consult `shiftKey` for Enter (`Keyboard.ts`, case 13),
so Shift+Enter was byte-identical to Enter and submitted the prompt.
Both terminals now send `\x1b\r` (ESC+CR) instead, which Claude Code
parses as return+meta — the same bytes its own `/terminal-setup` writes
into the VS Code, Cursor, Alacritty and Zed keymaps, so this is in-band
rather than a guess. Not `\n`: Claude Code accepts it, but a shell would
run the line, so the two session types would diverge. Bound in Claude
sessions only for that reason.

`entrypoint.sh` sets `shiftEnterKeyBindingInstalled` in `~/.claude.json`
so the CLI stops printing its "run /terminal-setup" tip. Purely
cosmetic — the decoding is unconditional either way.

Alt+Enter has always done the same thing (xterm ESC-prefixes on altKey)
and was simply never documented. It is now, along with the rest.

OAuth login URL truncation
--------------------------
Two producers wrote one toast slot, last-writer-wins. The OSC 7777 relay
delivers the URL base64-encoded and therefore exact; ~300 ms later the
screen-scraper's debounce fired and overwrote it with a truncated guess
at the same link — a URL that parses, points at the right host, and
authorises nothing. The user is the one who has to notice.

Why the scraper truncated: `ANSI_RE` strips OSC sequences wholesale,
including the OSC 8 hyperlink whose parameter carries the complete URL.
Claude Code slices the *visible* text of that hyperlink to the terminal
width while every emission carries the whole URL in its parameter. The
backend already knew this (`commands/auth_token_commands.rs`); the
frontend did not.

- `urlDetector` now reads OSC 8 targets out of the raw buffer before
  stripping, filtered by a port of `usable_sign_in_link`, and tags every
  candidate with its provenance.
- The prompt slot gained `supersedes`: better provenance always wins,
  worse never does, and between equals only a candidate that *extends*
  what is showing may replace it. That last rule is `extendsUrl`,
  factored out of `pickSignInUrl` rather than copied — same rule, same
  reason, one implementation.
- `flatten` splits on a bare `\r` as well as on `\r?\n`, so a
  `\r`-repainted TUI frame no longer inflates a line past the width and
  suppresses a join that should have happened; and the width is now
  sampled at `feed()` rather than read at `scan()`, so a resize inside
  the 300 ms debounce cannot reassemble 80-column text against a
  120-column rule.

Also corrects the comment claiming `acquire_claude_token` enables the
auth bridge. It deliberately does not, and the module comment in
`auth_token_commands.rs` explains at length why not.

The auth bridge toggle
----------------------
`setAuthBridgeEnabled` and `getAuthBridgeStatus` had zero call sites:
the Rust was complete, the IPC wrapper shipped, and there was nowhere to
click — so the docs told users to "enable the Auth Bridge" for a switch
that did not exist. `AuthBridgeRow` is that switch, in Config → Runtime.
It deliberately does not go through the tab's stopped-only save: the
dedicated command exists so the bridge can be flipped while a login is
hanging in a running container, which is the only moment anyone reaches
for it.

It also subscribes to `auth-bridge-changed`, which the poller has been
emitting to nobody — so a host port the bridge could not take was a
completely silent failure, indistinguishable from a login that hung.

`tunnel.rs` promotes the best-effort `::1` bind failure from debug to a
warning recorded on the port. Half-bound is the failure mode that looks
like success: the status says bridged, and a client that resolves
`localhost` to `::1` without falling back is still refused.

Finally, for a recognised Anthropic sign-in URL the toast now leads with
"In container" and demotes the host "Open". The callback listener is
inside the container, so the container-side browser closes the loop with
no host round trip and no auth bridge; the host button stays as the
fallback. Ordinary URLs are unchanged.

Tests: 402 frontend (was 359), 285 Rust (unchanged).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
2026-08-23 08:31:39 -07:00
co-authored by Claude Opus 5
parent 75cace7dde
commit 22d142c70d
21 changed files with 1529 additions and 98 deletions
+40 -4
View File
@@ -128,8 +128,11 @@ Anthropic-backend project uses that token without its own login. See
2. Claude prints an OAuth URL. Triple-C detects long URLs and shows a clickable toast at the top of the terminal — click **Open** to open it in your browser. 2. Claude prints an OAuth URL. Triple-C detects long URLs and shows a clickable toast at the top of the terminal — click **Open** to open it in your browser.
3. Complete the login in your browser. The token is saved and persists across container stops, starts and recreations. A **Reset** deletes it — see below. 3. Complete the login in your browser. The token is saved and persists across container stops, starts and recreations. A **Reset** deletes it — see below.
> If the login hangs after the browser step, the callback could not reach the container. Enable the > If the login hangs after the browser step, the callback could not reach the container. Either
> [Auth Bridge](#browser-logins-inside-the-container-auth-bridge) for that project. > click **In container** on the toast instead of **Open** — the callback then never has to leave the
> container at all — or turn on the
> [Auth Bridge](#browser-logins-inside-the-container-auth-bridge) in the project's
> **Config → Runtime** section.
**AWS Bedrock:** **AWS Bedrock:**
@@ -789,6 +792,19 @@ web server they started on `localhost`. `claude login`, `aws sso login` and Conc
The **Auth Bridge** fixes this. It is **opt-in per project** and **off by default**. The **Auth Bridge** fixes this. It is **opt-in per project** and **off by default**.
### Where the switch is
Project Home → **Config****Runtime****Auth bridge**.
Unlike the rest of that tab, it is **not** greyed out while the container is running — it is a
host-side feature that recreates nothing, and the moment you want it is usually the moment a login
is already hanging in a running container. Switch it on, then retry the login.
Beside the switch is its live state: **Off**, **Watching** (on, nothing to bridge yet — normal,
there is only something to bridge while a login is waiting), **Bridging *n* ports**, **IPv4 only**,
or **Port conflict** with the port and the reason. A conflict means the host port was already taken
and the callback will not arrive; free the port, or use **In container** instead.
### What it does ### What it does
- Every couple of seconds it looks inside the container for programs listening on the container's - Every couple of seconds it looks inside the container for programs listening on the container's
@@ -1293,9 +1309,19 @@ triple-c-scheduler add --name "test" --schedule "0 */6 * * *" --prompt "Run test
| **Ctrl+Shift+V** | Paste | | **Ctrl+Shift+V** | Paste |
| **Ctrl+V** | Paste an image from the clipboard into the container | | **Ctrl+V** | Paste an image from the clipboard into the container |
| **Ctrl+Shift+M** | Toggle speech-to-text recording (when enabled) | | **Ctrl+Shift+M** | Toggle speech-to-text recording (when enabled) |
| **Shift+Enter** | Insert a newline in Claude Code's prompt instead of submitting it |
| **Alt+Enter** | The same thing, and it has always worked — it was simply never written down |
Everything else goes straight through to the program running in the container. Everything else goes straight through to the program running in the container.
> **Shift+Enter** sends `ESC` + `CR`, the same bytes Claude Code's own `/terminal-setup` installs
> for VS Code, Cursor, Alacritty and Zed — so there is nothing to run and no tip to follow. It is
> bound in **Claude** tabs only: in a **bash** tab that sequence means nothing to readline, and
> Shift+Enter there submits the line as it always has.
>
> In the [Web Terminal](#web-terminal-remote-access) the same chord works, and there is an **↵+**
> key beside **Enter** on the mobile key row for devices with no Shift.
--- ---
## What's Inside the Container ## What's Inside the Container
@@ -1402,8 +1428,18 @@ your machine (anything that isn't `http`/`https`).
You opened the URL, signed in successfully, and the CLI in the terminal is still waiting. The You opened the URL, signed in successfully, and the CLI in the terminal is still waiting. The
callback from your browser is landing on your host's `localhost` while the CLI is listening on the callback from your browser is landing on your host's `localhost` while the CLI is listening on the
*container's*. Enable the *container's*.
[Auth Bridge](#browser-logins-inside-the-container-auth-bridge) for that project and try again.
Two ways out, in order of least effort:
1. Dismiss and re-trigger the login, then click **In container** on the toast rather than **Open**.
The page opens in a browser *inside* the container, so the callback never has to cross to the
host. This needs no auth bridge — only a running container with Playwright installed (Project
Home → **Browser**). For a recognised Anthropic sign-in link this is already the default button.
2. Turn on the [Auth Bridge](#browser-logins-inside-the-container-auth-bridge) — Project Home →
**Config****Runtime****Auth bridge** — and try again. It can be switched on while the
container is running. Check the indicator beside it: **Port conflict** means the host port was
already taken and the callback still will not arrive.
For Claude specifically, the simpler answer is usually For Claude specifically, the simpler answer is usually
[Shared Claude Authentication](#shared-claude-authentication), which finishes on an Anthropic-hosted [Shared Claude Authentication](#shared-claude-authentication), which finishes on an Anthropic-hosted
+15 -2
View File
@@ -76,8 +76,21 @@ Implemented in `hooks/useKeyboardShortcuts.ts` (document-level, capture phase):
`Ctrl+W` is deliberately **not** bound: it is readline's `kill-word`, used constantly in the `Ctrl+W` is deliberately **not** bound: it is readline's `kill-word`, used constantly in the
terminal this app is built around. Plain `Ctrl+←/→` is readline's word-wise cursor motion, which is terminal this app is built around. Plain `Ctrl+←/→` is readline's word-wise cursor motion, which is
why moving a tab takes Shift as well. Terminal-scoped keys (`Ctrl+Shift+C`, `Ctrl+Shift+Alt+C`, why moving a tab takes Shift as well.
`Ctrl+Shift+M`) are handled in `TerminalView.tsx`.
Terminal-scoped keys are handled in `TerminalView.tsx`:
| Shortcut | Action |
|---|---|
| `Ctrl+Shift+C` / `Ctrl+Shift+Alt+C` | Copy the selection, trimmed / exactly as-is |
| `Ctrl+Shift+M` | Toggle speech-to-text recording |
| `Shift+Enter` | Insert a newline in Claude Code's prompt instead of submitting |
| `Alt+Enter` | The same thing — xterm.js already ESC-prefixes on Alt, so this has always worked |
`Shift+Enter` sends `ESC` + `CR`, which is what Claude Code's own `/terminal-setup` installs for
VS Code, Cursor, Alacritty and Zed. It is bound in Claude sessions only: in a bash tab those bytes
are unbound in readline. The web terminal does the same, and adds an `↵+` key beside Enter for
devices with no Shift.
### Project Home ### Project Home
+5
View File
@@ -82,6 +82,10 @@ pub struct BridgedPort {
pub family: PortFamily, pub family: PortFamily,
/// RFC 3339 timestamp of when the host listener was bound. /// RFC 3339 timestamp of when the host listener was bound.
pub bridged_at: String, pub bridged_at: String,
/// Set when only the IPv4 half of the host listener could be bound. The
/// port still works, but not for a client that insists on `::1` — see
/// [`tunnel::PortForward::ipv6_warning`].
pub ipv6_warning: Option<String>,
} }
/// A loopback listener that was discovered but could not be bridged. /// A loopback listener that was discovered but could not be bridged.
@@ -132,6 +136,7 @@ impl BridgeState {
port: f.port, port: f.port,
family: f.family, family: f.family,
bridged_at: f.bridged_at.clone(), bridged_at: f.bridged_at.clone(),
ipv6_warning: f.ipv6_warning.clone(),
}) })
.collect(), .collect(),
conflicts: self conflicts: self
+30 -5
View File
@@ -52,6 +52,15 @@ pub struct PortForward {
pub port: u16, pub port: u16,
pub family: PortFamily, pub family: PortFamily,
pub bridged_at: String, pub bridged_at: String,
/// Why `[::1]` could not be taken alongside `127.0.0.1`, if it could not.
///
/// A half-bound forward is the one failure mode that looks like a success:
/// the status says the port is bridged, and a browser that resolves
/// `localhost` to `::1` and does not fall back still gets a refused
/// connection. It is not a conflict — the IPv4 half really is carrying
/// traffic — so it rides along with the port it belongs to and the UI says
/// so, rather than being logged at debug where nobody sees it.
pub ipv6_warning: Option<String>,
task: JoinHandle<()>, task: JoinHandle<()>,
} }
@@ -86,16 +95,31 @@ impl PortForward {
// first, so a v4-only host listener would miss those callbacks. This is // first, so a v4-only host listener would miss those callbacks. This is
// best-effort: if ::1 is unavailable (no IPv6, or that half is taken) // best-effort: if ::1 is unavailable (no IPv6, or that half is taken)
// the v4 listener alone still works, so it is not treated as a conflict. // the v4 listener alone still works, so it is not treated as a conflict.
let v6 = match TcpListener::bind(SocketAddr::from((Ipv6Addr::LOCALHOST, port))).await { let (v6, ipv6_warning) =
Ok(l) => Some(l), match TcpListener::bind(SocketAddr::from((Ipv6Addr::LOCALHOST, port))).await {
Ok(l) => (Some(l), None),
Err(e) => { Err(e) => {
log::debug!( // Warn, not debug. Best-effort is about whether to *fail*,
"Auth bridge: bound 127.0.0.1:{} but not [::1]:{} ({}) — continuing with IPv4 only", // not about whether to say anything: on a host where
// `localhost` resolves to `::1` and the client does not
// fall back to IPv4, the callback is refused while the
// bridge reports itself healthy — a silent failure with no
// thread back to this line.
log::warn!(
"Auth bridge: bound 127.0.0.1:{} but not [::1]:{} ({}) — continuing with IPv4 only; \
a client that resolves localhost to ::1 without falling back will not reach it",
port, port,
port, port,
e e
); );
None (
None,
Some(format!(
"IPv4 only — [::1]:{} could not be bound ({}). A browser that resolves \
localhost to ::1 without falling back will not reach this port.",
port, e
)),
)
} }
}; };
@@ -106,6 +130,7 @@ impl PortForward {
port, port,
family, family,
bridged_at: chrono::Utc::now().to_rfc3339(), bridged_at: chrono::Utc::now().to_rfc3339(),
ipv6_warning,
task, task,
}) })
} }
+32 -1
View File
@@ -334,6 +334,9 @@
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
enterkeyhint="send" inputmode="text"> enterkeyhint="send" inputmode="text">
<button class="key-btn" id="btnEnter">Enter</button> <button class="key-btn" id="btnEnter">Enter</button>
<!-- A newline *without* submitting. There is no Shift on a phone keyboard,
so the chord the desktop app binds needs a key of its own here. -->
<button class="key-btn" id="btnNewline" title="Insert a newline without submitting (Shift+Enter)">&#8629;+</button>
<button class="key-btn" id="btnTab">Tab</button> <button class="key-btn" id="btnTab">Tab</button>
<button class="key-btn" id="btnCtrlC">^C</button> <button class="key-btn" id="btnCtrlC">^C</button>
</div> </div>
@@ -360,6 +363,7 @@
const emptyState = document.getElementById('emptyState'); const emptyState = document.getElementById('emptyState');
const mobileInput = document.getElementById('mobileInput'); const mobileInput = document.getElementById('mobileInput');
const btnEnter = document.getElementById('btnEnter'); const btnEnter = document.getElementById('btnEnter');
const btnNewline = document.getElementById('btnNewline');
const btnTab = document.getElementById('btnTab'); const btnTab = document.getElementById('btnTab');
const btnCtrlC = document.getElementById('btnCtrlC'); const btnCtrlC = document.getElementById('btnCtrlC');
const scrollBottomBtn = document.getElementById('scrollBottomBtn'); const scrollBottomBtn = document.getElementById('scrollBottomBtn');
@@ -647,6 +651,28 @@
}); });
}); });
// Shift+Enter inserts a newline in Claude Code's prompt instead of
// submitting it. xterm.js does not consult `shiftKey` for Enter, so
// without this Shift+Enter is byte-identical to Enter.
//
// `\x1b\r` — ESC then CR — is what Claude Code parses as `return` with
// meta, and it is the same sequence its own `/terminal-setup` installs for
// VS Code, Cursor, Alacritty and Zed. Do not "simplify" it to `\n`: that
// also works in Claude Code, but a shell would *run* the line, so the two
// session types would diverge. Claude sessions only, for that reason —
// `bash -l` has no readline binding for `\e\r`.
term.attachCustomKeyEventHandler(e => {
if (
e.type === 'keydown' && e.key === 'Enter' && e.shiftKey &&
!e.ctrlKey && !e.altKey && !e.metaKey && !e.isComposing &&
sessionType === 'claude'
) {
sendTerminalInput('\x1b\r');
return false; // xterm must not also send a bare CR, which submits
}
return true;
});
// Track scroll position for scroll-to-bottom button // Track scroll position for scroll-to-bottom button
term.onScroll(() => updateScrollButton()); term.onScroll(() => updateScrollButton());
@@ -799,7 +825,11 @@
sendTerminalInput(val); sendTerminalInput(val);
mobileInput.value = ''; mobileInput.value = '';
} }
sendTerminalInput('\r'); // Shift+Enter is a newline, not a submit — same bytes, and the same
// reasoning, as the terminal's own key handler above. A hardware
// keyboard on a tablet is the only way to reach this; the phone case is
// the dedicated newline button beside Enter.
sendTerminalInput(e.shiftKey ? '\x1b\r' : '\r');
} else if (e.key === 'Tab') { } else if (e.key === 'Tab') {
e.preventDefault(); e.preventDefault();
sendTerminalInput('\t'); sendTerminalInput('\t');
@@ -807,6 +837,7 @@
}); });
btnEnter.onclick = () => { sendTerminalInput('\r'); mobileInput.focus(); }; btnEnter.onclick = () => { sendTerminalInput('\r'); mobileInput.focus(); };
btnNewline.onclick = () => { sendTerminalInput('\x1b\r'); mobileInput.focus(); };
btnTab.onclick = () => { sendTerminalInput('\t'); mobileInput.focus(); }; btnTab.onclick = () => { sendTerminalInput('\t'); mobileInput.focus(); };
btnCtrlC.onclick = () => { sendTerminalInput('\x03'); mobileInput.focus(); }; btnCtrlC.onclick = () => { sendTerminalInput('\x03'); mobileInput.focus(); };
+13
View File
@@ -23,6 +23,11 @@ export default function StatusBar({ stt }: Props) {
})) }))
); );
const running = projects.filter((p) => p.status === "running").length; const running = projects.filter((p) => p.status === "running").length;
// Only in a Claude tab: the chord is bound there and nowhere else, and a hint
// for a key that does nothing is worse than no hint.
const inClaudeSession = sessions.some(
(s) => s.id === activeSessionId && s.sessionType === "claude",
);
return ( return (
<div className="flex items-center h-6 px-4 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs text-[var(--text-secondary)]"> <div className="flex items-center h-6 px-4 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs text-[var(--text-secondary)]">
@@ -45,6 +50,14 @@ export default function StatusBar({ stt }: Props) {
</span> </span>
</> </>
)} )}
{!terminalHasSelection && inClaudeSession && (
<>
<span className="mx-2">|</span>
<span title="Sends ESC+CR — the sequence Claude Code's own /terminal-setup installs. Alt+Enter does the same.">
Shift+Enter: newline
</span>
</>
)}
{/* Right-aligned controls: Jump to Current + STT mic */} {/* Right-aligned controls: Jump to Current + STT mic */}
<div className="ml-auto flex items-center gap-3 pl-2"> <div className="ml-auto flex items-center gap-3 pl-2">
{activeSessionId && !terminalAtBottom && ( {activeSessionId && !terminalAtBottom && (
@@ -0,0 +1,178 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import AuthBridgeRow, { bridgeIndicator } from "./AuthBridgeRow";
import type { AuthBridgeStatus, Project } from "../../../../lib/types";
/**
* The bridge shipped with a working backend, a typed IPC wrapper, and no way to
* reach either: `setAuthBridgeEnabled` had zero call sites, and the
* `auth-bridge-changed` event had no listener — so a host port the bridge could
* not take was a silent failure that presented as a login that simply hung.
* These tests hold both halves down.
*/
const getAuthBridgeStatus = vi.fn<() => Promise<AuthBridgeStatus>>();
const setAuthBridgeEnabled = vi.fn<(id: string, on: boolean) => Promise<AuthBridgeStatus>>();
vi.mock("../../../../lib/tauri-commands", () => ({
getAuthBridgeStatus: () => getAuthBridgeStatus(),
setAuthBridgeEnabled: (id: string, on: boolean) => setAuthBridgeEnabled(id, on),
}));
/** Captured so a test can push an `auth-bridge-changed` payload by hand. */
let emit: ((payload: unknown) => void) | null = null;
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (_name: string, handler: (e: { payload: unknown }) => void) => {
emit = (payload) => handler({ payload });
return () => {
emit = null;
};
}),
}));
const OFF: AuthBridgeStatus = { enabled: false, active_ports: [], conflicts: [] };
const project = {
id: "p1",
name: "api",
status: "running",
auth_bridge_enabled: false,
} as unknown as Project;
beforeEach(() => {
vi.clearAllMocks();
getAuthBridgeStatus.mockResolvedValue(OFF);
setAuthBridgeEnabled.mockResolvedValue({ ...OFF, enabled: true });
});
describe("AuthBridgeRow", () => {
it("turns the bridge on through its own command, not the project save", async () => {
// The dedicated command exists so this can be flipped while the container
// runs — which is exactly when a user discovers they need it. Routing it
// through the Config tab's stopped-only save would make it unreachable at
// the only moment it matters.
render(<AuthBridgeRow project={project} />);
await waitFor(() => expect(getAuthBridgeStatus).toHaveBeenCalled());
fireEvent.click(screen.getByRole("switch", { name: "Auth bridge" }));
await waitFor(() =>
expect(setAuthBridgeEnabled).toHaveBeenCalledWith("p1", true),
);
});
it("stays usable while the container is running", async () => {
render(<AuthBridgeRow project={project} />);
await waitFor(() => expect(getAuthBridgeStatus).toHaveBeenCalled());
expect(screen.getByRole("switch", { name: "Auth bridge" })).not.toBeDisabled();
});
it("reports a port conflict the poller emitted", async () => {
getAuthBridgeStatus.mockResolvedValue({ ...OFF, enabled: true });
render(<AuthBridgeRow project={project} />);
await waitFor(() => expect(emit).not.toBeNull());
emit!({
project_id: "p1",
status: {
enabled: true,
active_ports: [],
conflicts: [
{ port: 54545, reason: "Host port 54545 is already in use (…); not bridged." },
],
},
});
expect(await screen.findByText(/Port 54545/)).toBeInTheDocument();
expect(screen.getByText("Port conflict")).toBeInTheDocument();
});
it("ignores an event for a different project", async () => {
getAuthBridgeStatus.mockResolvedValue({ ...OFF, enabled: true });
render(<AuthBridgeRow project={project} />);
await waitFor(() => expect(emit).not.toBeNull());
emit!({
project_id: "other",
status: { enabled: true, active_ports: [], conflicts: [{ port: 1, reason: "nope" }] },
});
expect(screen.queryByText(/Port 1:/)).not.toBeInTheDocument();
});
it("puts the switch back if the command rejects", async () => {
setAuthBridgeEnabled.mockRejectedValue("Project p1 not found");
render(<AuthBridgeRow project={project} />);
await waitFor(() => expect(getAuthBridgeStatus).toHaveBeenCalled());
fireEvent.click(screen.getByRole("switch", { name: "Auth bridge" }));
expect(await screen.findByText(/not found/)).toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Auth bridge" })).not.toBeChecked();
});
});
describe("bridgeIndicator", () => {
// Every branch is a glyph plus a word — status is never colour alone.
it("says nothing is on when it is off", () => {
expect(bridgeIndicator(OFF, true)).toEqual({ tone: "off", label: "Off" });
});
it("puts a conflict ahead of everything else", () => {
expect(
bridgeIndicator(
{
enabled: true,
active_ports: [
{ port: 1, family: "v4", bridged_at: "", ipv6_warning: null },
],
conflicts: [{ port: 2, reason: "taken" }],
},
true,
).tone,
).toBe("error");
});
it("flags a port that only took the IPv4 half", () => {
// Node resolves `localhost` to IPv6 first on Linux, so a v4-only listener
// is a callback that never arrives in front of a bridge reporting healthy.
expect(
bridgeIndicator(
{
enabled: true,
active_ports: [
{ port: 1, family: "v6", bridged_at: "", ipv6_warning: "no ::1" },
],
conflicts: [],
},
true,
).label,
).toBe("IPv4 only");
});
it("counts the ports it is holding", () => {
expect(
bridgeIndicator(
{
enabled: true,
active_ports: [
{ port: 1, family: "v4", bridged_at: "", ipv6_warning: null },
{ port: 2, family: "v4", bridged_at: "", ipv6_warning: null },
],
conflicts: [],
},
true,
).label,
).toBe("Bridging 2 ports");
});
it("says it is waiting when the container is not running", () => {
// Enabled and holding nothing is normal; enabled with no container is a
// different thing, and saying so stops it reading as a failure.
expect(bridgeIndicator({ ...OFF, enabled: true }, false).label).toBe(
"Waiting for the container",
);
expect(bridgeIndicator({ ...OFF, enabled: true }, true).label).toBe("Watching");
});
});
@@ -0,0 +1,198 @@
import { useCallback, useEffect, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import {
getAuthBridgeStatus,
setAuthBridgeEnabled,
} from "../../../../lib/tauri-commands";
import type {
AuthBridgeChangedEvent,
AuthBridgeStatus,
Project,
} from "../../../../lib/types";
import { SwitchRow } from "../../../ui/Field";
import StatusIndicator, { type StatusTone } from "../../../ui/StatusIndicator";
import Toggle from "../../../ui/Toggle";
/** Emitted by `auth_bridge/mod.rs` whenever the port or conflict set changes. */
const AUTH_BRIDGE_EVENT = "auth-bridge-changed";
const LABEL = "Auth bridge";
/**
* What the indicator beside the switch says.
*
* Split out so the interesting part that a conflict is a *visible* failure
* can be tested without a container. Every branch pairs a glyph with a word;
* none of them are distinguished by colour alone.
*/
export function bridgeIndicator(
status: AuthBridgeStatus | null,
containerRunning: boolean,
): { tone: StatusTone; label: string } {
if (!status) return { tone: "unknown", label: "Checking" };
if (!status.enabled) return { tone: "off", label: "Off" };
// A conflict means a login is in progress and its port could not be taken —
// the one state where doing nothing is the wrong answer, and until now the
// one state nothing in the app reported at all.
if (status.conflicts.length > 0) {
return { tone: "error", label: "Port conflict" };
}
if (status.active_ports.some((p) => p.ipv6_warning)) {
return { tone: "busy", label: "IPv4 only" };
}
if (status.active_ports.length > 0) {
const n = status.active_ports.length;
return { tone: "running", label: `Bridging ${n} port${n === 1 ? "" : "s"}` };
}
// Enabled but holding nothing. Normal: there is only something to bridge
// while a login is actually waiting for a callback.
if (!containerRunning) {
return { tone: "stopped", label: "Waiting for the container" };
}
return { tone: "ok", label: "Watching" };
}
/**
* The switch for `auth_bridge_enabled`, and the only place it can be changed.
*
* Two things here are deliberate and easy to undo by accident:
*
* - **It does not go through the Config tab's `save`.** That path is gated on
* a stopped container, because almost everything else in the tab is baked
* into the container at creation. This is not: the bridge is entirely
* host-side, and `set_auth_bridge_enabled` exists precisely so it can be
* flipped *while a login is hanging*, which is when the user finds out they
* need it. Routing it through the generic save would make it unreachable at
* the only moment it matters.
* - **It subscribes to `auth-bridge-changed`.** The poller already emits the
* bridged-port and conflict sets on every change and, before this, nothing
* listened so a host port the bridge could not take was a completely
* silent failure, indistinguishable from a login that simply hung.
*/
export default function AuthBridgeRow({ project }: { project: Project }) {
const projectId = project.id;
const containerRunning = project.status === "running";
const [status, setStatus] = useState<AuthBridgeStatus | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setStatus(null);
setError(null);
getAuthBridgeStatus(projectId)
.then((s) => {
if (!cancelled) setStatus(s);
})
.catch((e) => {
if (!cancelled) setError(String(e));
});
return () => {
cancelled = true;
};
}, [projectId]);
useEffect(() => {
let cancelled = false;
let unlisten: (() => void) | undefined;
listen<AuthBridgeChangedEvent>(AUTH_BRIDGE_EVENT, (event) => {
if (event.payload.project_id !== projectId) return;
setStatus(event.payload.status);
})
.then((un) => {
if (cancelled) un();
else unlisten = un;
})
.catch((e) => console.error("Auth bridge event subscription failed:", e));
return () => {
cancelled = true;
unlisten?.();
};
}, [projectId]);
const toggle = useCallback(
async (next: boolean) => {
setBusy(true);
setError(null);
// Optimistic, so the switch responds even though enabling has to await a
// container probe. The command's return value replaces it either way.
setStatus((s) => (s ? { ...s, enabled: next } : s));
try {
setStatus(await setAuthBridgeEnabled(projectId, next));
} catch (e) {
setStatus((s) => (s ? { ...s, enabled: !next } : s));
setError(String(e));
} finally {
setBusy(false);
}
},
[projectId],
);
// Fall back to the persisted flag until the first status arrives, so the
// switch never renders in the wrong position.
const enabled = status?.enabled ?? project.auth_bridge_enabled;
const indicator = bridgeIndicator(status, containerRunning);
return (
<SwitchRow
label={LABEL}
hint={
<>
Mirrors a port a program inside the container is listening on onto the
host's <code>127.0.0.1</code>, so a browser OAuth callback can reach
the listener waiting inside the container {" "}
<code>claude login</code>, <code>aws sso login</code> and{" "}
<code>gh auth login</code> all work this way, and without it the
browser calls back into nothing and the login hangs. Host-side only:
it never recreates the container, and it can be switched on while one
is running. A bridged port is unauthenticated and reachable by any
local process for as long as the in-container listener exists, so
leave it off unless you need it.
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
<StatusIndicator tone={indicator.tone} label={indicator.label} />
{status?.active_ports.map((p) => (
<span
key={p.port}
className="font-mono text-[var(--text-secondary)]"
title={p.ipv6_warning ?? `Bound on host 127.0.0.1:${p.port}`}
>
127.0.0.1:{p.port}
{p.ipv6_warning ? " (IPv4 only)" : ""}
</span>
))}
</span>
{status?.conflicts.map((c) => (
<span
key={c.port}
className="mt-1 block text-[var(--error)]"
role="status"
>
Port {c.port}: {c.reason}
</span>
))}
{status?.active_ports
.filter((p) => p.ipv6_warning)
.map((p) => (
<span key={p.port} className="mt-1 block text-[var(--warning)]">
Port {p.port}: {p.ipv6_warning}
</span>
))}
{error && (
<span className="mt-1 block text-[var(--error)]">{error}</span>
)}
</>
}
control={
<Toggle
label={LABEL}
checked={enabled}
// Never gated on the container being stopped — see the note above.
disabled={busy}
onChange={toggle}
/>
}
/>
);
}
@@ -1,7 +1,21 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import RuntimeSection from "./RuntimeSection"; import RuntimeSection from "./RuntimeSection";
import type { Project } from "../../../../lib/types"; import type { AuthBridgeStatus, Project } from "../../../../lib/types";
// The auth-bridge row owns its own IPC — see `AuthBridgeRow.tsx` for why it
// does not go through `save`.
const OFF_BRIDGE: AuthBridgeStatus = { enabled: false, active_ports: [], conflicts: [] };
const setAuthBridgeEnabled = vi.fn(async () => ({ ...OFF_BRIDGE, enabled: true }));
vi.mock("../../../../lib/tauri-commands", () => ({
getAuthBridgeStatus: vi.fn(async () => OFF_BRIDGE),
setAuthBridgeEnabled: (id: string, on: boolean) => setAuthBridgeEnabled(id, on),
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async () => () => {}),
}));
const baseProject: Project = { const baseProject: Project = {
id: "p1", id: "p1",
@@ -92,3 +106,24 @@ describe("RuntimeSection — VPN support toggle", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
}); });
describe("RuntimeSection — auth bridge toggle", () => {
beforeEach(() => vi.clearAllMocks());
it("is reachable while the container is running", async () => {
// The rest of the tab is gated on a stopped container because those
// settings are baked in at creation. This one is host-side and has its own
// command, and the moment a user needs it is the moment a login is hanging
// in a *running* container — so the tab's `disabled` must not reach it.
renderSection({ status: "running" }, true);
const toggle = screen.getByRole("switch", { name: "Auth bridge" });
await waitFor(() => expect(toggle).not.toBeDisabled());
fireEvent.click(toggle);
await waitFor(() => expect(setAuthBridgeEnabled).toHaveBeenCalledWith("p1", true));
// And never through the generic project save, which would drop it on the
// floor while the container runs.
expect(save).not.toHaveBeenCalled();
});
});
@@ -4,6 +4,7 @@ import { ConfigGroup, SwitchRow } from "../../../ui/Field";
import PermissionModeControl, { permissionModePatch } from "../../PermissionModeControl"; import PermissionModeControl, { permissionModePatch } from "../../PermissionModeControl";
import ClaudeInstructionsEditor from "../../ClaudeInstructionsEditor"; import ClaudeInstructionsEditor from "../../ClaudeInstructionsEditor";
import ClaudeCodeSettingsEditor from "../../ClaudeCodeSettingsEditor"; import ClaudeCodeSettingsEditor from "../../ClaudeCodeSettingsEditor";
import AuthBridgeRow from "./AuthBridgeRow";
interface Props { interface Props {
project: Project; project: Project;
@@ -70,6 +71,12 @@ export default function RuntimeSection({
} }
/> />
{/* Not gated on `disabled`: the bridge is host-side and has its own
command, so it can be switched on while a login is hanging which
is the only moment anyone reaches for it. It owns its state rather
than going through `save`. */}
<AuthBridgeRow project={project} />
<SwitchRow <SwitchRow
label="Mission Control" label="Mission Control"
hint="A web dashboard for monitoring and managing Claude sessions remotely." hint="A web dashboard for monitoring and managing Claude sessions remotely."
@@ -0,0 +1,224 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, fireEvent, cleanup } from "@testing-library/react";
import TerminalView, { supersedes } from "./TerminalView";
import { useAppState } from "../../store/appState";
/**
* Shift+Enter has to reach the container as ESC+CR.
*
* xterm.js does not consult `shiftKey` for Enter, so Shift+Enter is
* byte-identical to Enter unless `attachCustomKeyEventHandler` intervenes
* which means the interesting assertion is not just "ESC+CR was sent" but
* "and a bare CR was not", i.e. that the handler returned false and xterm
* stopped. A test that only checked the first half would pass on a version
* that submits the prompt *and* inserts a newline.
*/
const terminalInput = vi.fn(async () => {});
vi.mock("../../lib/tauri-commands", () => ({
terminalInput: (sessionId: string, bytes: number[]) =>
terminalInput(sessionId, bytes),
terminalResize: vi.fn(async () => {}),
pasteImageToTerminal: vi.fn(async () => ""),
openTerminalSession: vi.fn(async () => {}),
closeTerminalSession: vi.fn(async () => {}),
updateProject: vi.fn(async () => ({})),
awsSsoRefresh: vi.fn(async () => {}),
openPageInContainerBrowser: vi.fn(async () => ({ error: null })),
uploadHostFileToTerminal: vi.fn(async () => ""),
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async () => () => {}),
}));
vi.mock("@tauri-apps/plugin-opener", () => ({
openUrl: vi.fn(async () => {}),
}));
vi.mock("@tauri-apps/api/webview", () => ({
getCurrentWebview: () => ({ onDragDropEvent: vi.fn(async () => () => {}) }),
}));
/** jsdom has no ResizeObserver, and the mount effect installs one. */
class NoopResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
/** What `sendInput` put on the wire, decoded back to a string. */
function sent(): string[] {
return terminalInput.mock.calls.map((call) =>
new TextDecoder().decode(new Uint8Array((call as unknown as [string, number[]])[1])),
);
}
function mountSession(sessionType: "claude" | "bash") {
useAppState.setState({
sessions: [
{
id: "s1",
projectId: "p1",
projectName: "api",
sessionType,
sessionName: null,
},
],
});
return render(<TerminalView sessionId="s1" active />);
}
/** The hidden textarea xterm binds its keyboard handling to. */
function helperTextarea(container: HTMLElement): HTMLTextAreaElement {
const el = container.querySelector<HTMLTextAreaElement>(
"textarea.xterm-helper-textarea",
);
if (!el) throw new Error("xterm helper textarea not found");
return el;
}
beforeEach(() => {
vi.stubGlobal("ResizeObserver", NoopResizeObserver);
// xterm's renderer asks the window for its device pixel ratio on open.
vi.stubGlobal(
"matchMedia",
(query: string) => ({
matches: false,
media: query,
addEventListener() {},
removeEventListener() {},
addListener() {},
removeListener() {},
onchange: null,
dispatchEvent: () => false,
}),
);
terminalInput.mockClear();
useAppState.setState({ sessions: [] });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe("TerminalView — Shift+Enter", () => {
it("sends ESC+CR and nothing else in a Claude session", () => {
const { container } = mountSession("claude");
fireEvent.keyDown(helperTextarea(container), {
key: "Enter",
keyCode: 13,
shiftKey: true,
});
// The bytes `/terminal-setup` installs for every other editor.
expect(sent()).toEqual(["\x1b\r"]);
// And specifically not the bare CR that would have submitted the prompt.
expect(sent()).not.toContain("\r");
});
it("leaves a plain Enter alone", () => {
const { container } = mountSession("claude");
fireEvent.keyDown(helperTextarea(container), { key: "Enter", keyCode: 13 });
expect(sent()).toEqual(["\r"]);
});
it("does not bind it in a bash session", () => {
// `bash -l` runs readline, which has no binding for `\e\r`: it would answer
// with a bell and swallow the Enter the user actually pressed.
const { container } = mountSession("bash");
fireEvent.keyDown(helperTextarea(container), {
key: "Enter",
keyCode: 13,
shiftKey: true,
});
expect(sent()).toEqual(["\r"]);
});
it("leaves a modified Shift+Enter to xterm", () => {
// Adding Ctrl is not the chord this binds; whatever xterm does with it is
// xterm's business.
const { container } = mountSession("claude");
fireEvent.keyDown(helperTextarea(container), {
key: "Enter",
keyCode: 13,
shiftKey: true,
ctrlKey: true,
});
expect(sent()).not.toContain("\x1b\r");
});
it("Alt+Enter already produced ESC+CR without any handler", () => {
// Pinned because it is the reason Shift+Enter was the only gap: xterm
// ESC-prefixes on `altKey` by itself, so Alt+Enter has always inserted a
// newline in Claude Code. It was simply undocumented.
const { container } = mountSession("bash"); // no custom branch involved
fireEvent.keyDown(helperTextarea(container), {
key: "Enter",
keyCode: 13,
altKey: true,
});
expect(sent()).toEqual(["\x1b\r"]);
});
});
describe("supersedes — who owns the prompt slot", () => {
const relay = (url: string) => ({ url, source: "relay" as const });
const osc8 = (url: string) => ({ url, source: "osc8" as const });
const guess = (url: string) => ({ url, source: "heuristic" as const });
const COMPLETE =
"https://claude.ai/oauth/authorize?code=true&client_id=abc123&response_type=code&redirect_uri=https%3A%2F%2Fconsole.anthropic.com%2Foauth%2Fcode%2Fcallback&scope=user%3Ainference";
// What the screen-scraper reconstructs from the visible text: parses, points
// at the right host, authorises nothing.
const TRUNCATED = COMPLETE.slice(0, 80);
it("fills an empty slot from anywhere", () => {
expect(supersedes(guess(TRUNCATED), null)).toBe(true);
});
it("refuses to let a truncated guess replace the exact copy", () => {
// The whole bug: the relay lands first with the complete URL, and 300 ms
// later the detector's debounce fires with a prefix of it.
expect(supersedes(guess(TRUNCATED), relay(COMPLETE))).toBe(false);
expect(supersedes(guess(TRUNCATED), osc8(COMPLETE))).toBe(false);
});
it("lets a better source take over from a worse one", () => {
expect(supersedes(osc8(COMPLETE), guess(TRUNCATED))).toBe(true);
expect(supersedes(relay(COMPLETE), guess(TRUNCATED))).toBe(true);
});
it("lets a scraped candidate grow into the complete link", () => {
// A repaint can land the truncated copy first. Extending it is safe: a
// longer string with the same prefix has the same origin.
expect(supersedes(guess(COMPLETE), guess(TRUNCATED))).toBe(true);
});
it("does not let an unrelated scrape displace what is on screen", () => {
// Longest-wins without the prefix test hands the choice to whoever pads
// their URL the most.
expect(
supersedes(guess("https://evil.tld/" + "a".repeat(400)), guess(COMPLETE)),
).toBe(false);
});
it("lets a second explicit relay request through", () => {
// Each OSC 7777 is a fresh deliberate ask, not another view of the last
// one — a second `gh auth login` must be able to replace the first.
expect(
supersedes(relay("https://github.com/login/device"), relay(COMPLETE)),
).toBe(true);
});
});
+123 -13
View File
@@ -13,10 +13,11 @@ import {
uploadHostFileToTerminal, uploadHostFileToTerminal,
} from "../../lib/tauri-commands"; } from "../../lib/tauri-commands";
import { getCurrentWebview } from "@tauri-apps/api/webview"; import { getCurrentWebview } from "@tauri-apps/api/webview";
import { UrlDetector } from "../../lib/urlDetector"; import { UrlDetector, type UrlSource } from "../../lib/urlDetector";
import { import {
RelayRateLimiter, RelayRateLimiter,
URL_RELAY_OSC, URL_RELAY_OSC,
extendsUrl,
parseUrlRelayOsc, parseUrlRelayOsc,
sanitizeRelayUrl, sanitizeRelayUrl,
} from "../../lib/urlRelay"; } from "../../lib/urlRelay";
@@ -29,6 +30,58 @@ interface Props {
active: boolean; active: boolean;
} }
/**
* Where a prompted URL came from.
*
* `relay` is the container asking explicitly, over OSC 7777, with the URL
* base64-encoded exact by construction. `osc8` is lifted verbatim out of a
* hyperlink parameter also exact, but nobody asked for it. `heuristic` was
* reassembled from painted text and is the only one that can be a *truncated
* guess* at the link it is showing.
*/
export type PromptSource = "relay" | UrlSource;
/** Higher wins. Provenance, not recency. */
const SOURCE_RANK: Record<PromptSource, number> = {
heuristic: 0,
osc8: 1,
relay: 2,
};
/**
* Whether `next` may take over the prompt slot from `current`.
*
* The bug this exists for: `claude login` relays its OAuth URL over OSC 7777,
* base64-encoded and therefore complete; the screen-scraper's 300 ms debounce
* then fires, finds the same link cut into terminal-width pieces, and under
* the old last-writer-wins slot replaced the good URL with a truncated one
* that still parses, still points at the right host, and cannot authorise
* anything. The user is the one who has to notice.
*
* Two rules, in order:
*
* - Better provenance always wins, worse provenance never does. A scraped
* guess cannot displace an exact copy.
* - Between equals, only an *extension* of what is showing may replace it.
* That is {@link extendsUrl}, the same rule and the same reasoning as
* `pickSignInUrl` in `hooks/useClaudeAuth.ts`: a repaint can land a
* truncated copy before the complete one, and a longer string sharing a
* prefix cannot move the origin. The relay is exempt because each OSC 7777
* is a fresh deliberate request rather than another view of the last one
* a second `gh auth login` must be able to replace the first.
*/
export function supersedes(
next: { url: string; source: PromptSource },
current: { url: string; source: PromptSource } | null,
): boolean {
if (!current) return true;
if (SOURCE_RANK[next.source] !== SOURCE_RANK[current.source]) {
return SOURCE_RANK[next.source] > SOURCE_RANK[current.source];
}
if (next.source === "relay") return true;
return extendsUrl(next.url, current.url);
}
export default function TerminalView({ sessionId, active }: Props) { export default function TerminalView({ sessionId, active }: Props) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const terminalContainerRef = useRef<HTMLDivElement>(null); const terminalContainerRef = useRef<HTMLDivElement>(null);
@@ -47,13 +100,24 @@ export default function TerminalView({ sessionId, active }: Props) {
(s) => s.sessions.find((sess) => sess.id === sessionId)?.projectId (s) => s.sessions.find((sess) => sess.id === sessionId)?.projectId
); );
// One toast slot, two producers: the heuristic long-URL detector and the // Which program is on the other end of the PTY. Read through a ref because
// container's explicit "open this in the host browser" relay (OSC 7777). // the key handler is registered once, in the mount effect keyed on
// Sharing the slot keeps them from stacking on top of each other. // `sessionId`, and a value captured there would go stale if the session
// record arrived after the first render.
const sessionType = useAppState(
(s) => s.sessions.find((sess) => sess.id === sessionId)?.sessionType
);
const sessionTypeRef = useRef(sessionType);
sessionTypeRef.current = sessionType;
// One toast slot, three producers: the container's explicit "open this in the
// host browser" relay (OSC 7777), OSC 8 hyperlink targets, and the heuristic
// long-URL detector. Sharing the slot keeps them from stacking on top of each
// other.
// //
// Both producers read the container's PTY output, so both are untrusted, and // All three read the container's PTY output, so all three are untrusted, and
// both must go through `sanitizeRelayUrl` before anything is stored here // all three must go through `sanitizeRelayUrl` before anything is stored here
// see `promptUrl` below, which is the only writer. // see `promptUrl` below, which is the only writer.
// //
// `seq` exists because the slot is shared and long-lived: a second prompt // `seq` exists because the slot is shared and long-lived: a second prompt
// replacing a first would otherwise mutate the toast in place, swapping the // replacing a first would otherwise mutate the toast in place, swapping the
@@ -62,6 +126,7 @@ export default function TerminalView({ sessionId, active }: Props) {
const [urlPrompt, setUrlPrompt] = useState<{ const [urlPrompt, setUrlPrompt] = useState<{
url: string; url: string;
label: string; label: string;
source: PromptSource;
seq: number; seq: number;
} | null>(null); } | null>(null);
const promptSeqRef = useRef(0); const promptSeqRef = useRef(0);
@@ -72,16 +137,27 @@ export default function TerminalView({ sessionId, active }: Props) {
* found: the OSC relay branch has already been through `parseUrlRelayOsc`, * found: the OSC relay branch has already been through `parseUrlRelayOsc`,
* but the heuristic detector branch has been through nothing at all, and a * but the heuristic detector branch has been through nothing at all, and a
* raw regex match is exactly the input `sanitizeRelayUrl` exists to refuse. * raw regex match is exactly the input `sanitizeRelayUrl` exists to refuse.
*
* Last-writer-wins is what this used to be, and it lost the OAuth URL every
* time: the relay delivers the link base64-encoded and therefore exact, and
* ~300 ms later the screen-scraper's debounce fired and overwrote it with a
* truncated guess at the same link. `supersedes` is the fix see there.
*/ */
const promptUrl = useCallback((raw: string, label: string) => { const promptUrl = useCallback(
(raw: string, label: string, source: PromptSource) => {
const url = sanitizeRelayUrl(raw); const url = sanitizeRelayUrl(raw);
if (!url) { if (!url) {
console.warn("Refusing to prompt for a URL that failed validation"); console.warn("Refusing to prompt for a URL that failed validation");
return; return;
} }
setUrlPrompt((current) => {
if (!supersedes({ url, source }, current)) return current;
promptSeqRef.current += 1; promptSeqRef.current += 1;
setUrlPrompt({ url, label, seq: promptSeqRef.current }); return { url, label, source, seq: promptSeqRef.current };
}, []); });
},
[],
);
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null); const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
const [isAtBottom, setIsAtBottom] = useState(true); const [isAtBottom, setIsAtBottom] = useState(true);
const [isAutoFollow, setIsAutoFollow] = useState(true); const [isAutoFollow, setIsAutoFollow] = useState(true);
@@ -234,6 +310,34 @@ export default function TerminalView({ sessionId, active }: Props) {
useAppState.getState().sttToggle(); useAppState.getState().sttToggle();
return false; return false;
} }
// Shift+Enter inserts a newline in Claude Code's prompt instead of
// submitting it. xterm.js does not consult `shiftKey` for Enter
// (`Keyboard.ts`, `case 13`), so without this branch Shift+Enter is
// byte-identical to Enter and submits.
//
// `\x1b\r` — ESC then CR — is what Claude Code parses as `return` with
// meta, and it is exactly what its own `/terminal-setup` writes into the
// VS Code, Cursor, Alacritty and Zed keymaps. These are the in-band
// bytes, not a guess, which is why this must NOT be "simplified" to
// `\n`: Claude Code accepts `\n` too, but a shell would *run* the line,
// so the two session types would quietly diverge.
//
// Scoped to Claude sessions for the same reason. A bash tab runs
// `bash -l`, where readline has no binding for `\e\r` and answers with a
// bell — harmless, but there is nothing to gain from sending it.
if (
event.type === "keydown" &&
event.key === "Enter" &&
event.shiftKey &&
!event.ctrlKey &&
!event.altKey &&
!event.metaKey &&
!event.isComposing &&
sessionTypeRef.current === "claude"
) {
sendInput(sessionId, "\x1b\r");
return false; // xterm must not also send a bare CR, which submits
}
return true; return true;
}); });
@@ -287,7 +391,7 @@ export default function TerminalView({ sessionId, active }: Props) {
console.warn("URL relay: rate-limited", url); console.warn("URL relay: rate-limited", url);
return true; return true;
} }
promptUrl(url, "Container asked to open a URL"); promptUrl(url, "Container asked to open a URL", "relay");
return true; return true;
}); });
@@ -374,11 +478,17 @@ export default function TerminalView({ sessionId, active }: Props) {
// Handle backend output -> terminal // Handle backend output -> terminal
let aborted = false; let aborted = false;
// The width is read per scan, not captured: only a break the terminal // The detector samples this getter on every `feed`, so what it reassembles
// with is the width the bytes were *printed* at — only a break the terminal
// itself inserted may be deleted, and where that is moves with every // itself inserted may be deleted, and where that is moves with every
// resize. // resize.
const detector = new UrlDetector( const detector = new UrlDetector(
(url) => promptUrl(url, "Long URL detected"), (url, source) =>
promptUrl(
url,
source === "osc8" ? "Link detected" : "Long URL detected",
source,
),
() => termRef.current?.cols ?? 0, () => termRef.current?.cols ?? 0,
); );
detectorRef.current = detector; detectorRef.current = detector;
@@ -58,4 +58,79 @@ describe("UrlToast", () => {
screen.getByRole("button", { name: "Open" }).click(); screen.getByRole("button", { name: "Open" }).click();
expect(onOpen).toHaveBeenCalledTimes(1); expect(onOpen).toHaveBeenCalledTimes(1);
}); });
describe("Anthropic sign-in links", () => {
// The callback listener a `claude login` is waiting on is *inside* the
// container. Sending the user to their host browser completes the sign-in
// and then posts the result where nothing is listening, and the terminal
// hangs to its timeout — so for these, and only these, the container-side
// browser leads.
const SIGN_IN =
"https://claude.ai/oauth/authorize?code=true&client_id=abc&response_type=code";
function actions() {
return screen
.getAllByRole("button")
.map((b) => b.textContent)
.filter((t) => t === "Open" || t === "In container");
}
it("puts the container browser first", () => {
render(
<UrlToast
url={SIGN_IN}
onOpen={noop}
onOpenInContainer={noop}
onDismiss={noop}
/>,
);
expect(actions()).toEqual(["In container", "Open"]);
expect(screen.getByTestId("url-toast-signin-hint")).toHaveTextContent(
/callback listener is inside the container/i,
);
});
it("keeps the host browser available as a fallback", () => {
const onOpen = vi.fn();
render(
<UrlToast
url={SIGN_IN}
onOpen={onOpen}
onOpenInContainer={noop}
onDismiss={noop}
/>,
);
screen.getByRole("button", { name: "Open" }).click();
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("leaves an ordinary URL alone", () => {
// A `gh auth login` device code, a docs page, a preview build — the host
// browser is the right answer for all of them and stays the default.
render(
<UrlToast
url="https://github.com/login/device?code=ABCD-EFGH"
onOpen={noop}
onOpenInContainer={noop}
onDismiss={noop}
/>,
);
expect(actions()).toEqual(["Open", "In container"]);
expect(screen.queryByTestId("url-toast-signin-hint")).not.toBeInTheDocument();
});
it("is not fooled by a lookalike host", () => {
// `isAnthropicSignInUrl` uses the same allowlist the sign-in flow does,
// so a URL that merely says "claude.ai" somewhere is not one.
render(
<UrlToast
url="https://claude.ai.evil.tld/oauth/authorize?x=1"
onOpen={noop}
onOpenInContainer={noop}
onDismiss={noop}
/>,
);
expect(actions()).toEqual(["Open", "In container"]);
});
});
}); });
+114 -47
View File
@@ -1,4 +1,5 @@
import { urlOrigin } from "../../lib/urlRelay"; import type { CSSProperties, MouseEvent } from "react";
import { isAnthropicSignInUrl, urlOrigin } from "../../lib/urlRelay";
interface Props { interface Props {
/** Already validated by `sanitizeRelayUrl` — this component never opens it. */ /** Already validated by `sanitizeRelayUrl` — this component never opens it. */
@@ -28,6 +29,18 @@ interface Props {
* is shared and long-lived, so without one React mutates the node in place: the * is shared and long-lived, so without one React mutates the node in place: the
* text swaps with no animation, and a user reading URL A can click Open on URL * text swaps with no animation, and a user reading URL A can click Open on URL
* B that arrived a second later. * B that arrived a second later.
*
* ## Anthropic sign-in links default to the container's browser
*
* For an ordinary URL the host browser is the right answer and stays the
* default. For a sign-in it is the *wrong* one: the callback listener the CLI
* is waiting on is inside the container, so a host browser completes the sign-in
* and then posts the result somewhere nothing is listening, and the terminal
* hangs until it times out. Making the host button primary there was quietly
* steering every user into that. The container-side browser closes the loop
* with no host round trip and no auth bridge, so it leads and the host button
* stays, because a user who has the auth bridge on, or who wants their existing
* browser session, still needs it.
*/ */
export default function UrlToast({ export default function UrlToast({
url, url,
@@ -38,6 +51,81 @@ export default function UrlToast({
}: Props) { }: Props) {
const origin = urlOrigin(url); const origin = urlOrigin(url);
const rest = origin && url.startsWith(origin) ? url.slice(origin.length) : url; const rest = origin && url.startsWith(origin) ? url.slice(origin.length) : url;
// Only when there is somewhere to send it: without `onOpenInContainer` the
// host button is the only action there is, so it stays primary.
const signIn = !!onOpenInContainer && isAnthropicSignInUrl(url);
// Filled uses `--accent-emphasis`, never `--accent` — the latter is the
// foreground/link accent and fails WCAG AA behind white text.
const primaryStyle: CSSProperties = {
padding: "4px 12px",
fontSize: 12,
fontWeight: 600,
color: "#fff",
background: "var(--accent-emphasis)",
border: "1px solid transparent",
borderRadius: 4,
cursor: "pointer",
whiteSpace: "nowrap",
flexShrink: 0,
};
const secondaryStyle: CSSProperties = {
padding: "4px 10px",
fontSize: 12,
fontWeight: 600,
color: "var(--text-primary)",
background: "transparent",
border: "1px solid var(--border-color)",
borderRadius: 4,
cursor: "pointer",
whiteSpace: "nowrap",
flexShrink: 0,
};
/** Hover feedback for whichever button is currently the filled one. */
const hover = (primary: boolean) =>
primary
? {
onMouseEnter: (e: MouseEvent<HTMLButtonElement>) =>
(e.currentTarget.style.background = "var(--accent-emphasis-hover)"),
onMouseLeave: (e: MouseEvent<HTMLButtonElement>) =>
(e.currentTarget.style.background = "var(--accent-emphasis)"),
}
: {
onMouseEnter: (e: MouseEvent<HTMLButtonElement>) =>
(e.currentTarget.style.background = "var(--bg-tertiary)"),
onMouseLeave: (e: MouseEvent<HTMLButtonElement>) =>
(e.currentTarget.style.background = "transparent"),
};
const hostButton = (
<button
onClick={onOpen}
title={
signIn
? "Open in your own browser instead — the callback then has to reach the container by some other route"
: undefined
}
style={signIn ? secondaryStyle : primaryStyle}
{...hover(!signIn)}
>
Open
</button>
);
const containerButton = onOpenInContainer && (
// A sign-in completed in the *container's* browser lands its callback on
// the container's own loopback, which is where the tool waiting for it is
// listening — no host round trip, no auth bridge.
<button
onClick={onOpenInContainer}
title="Open in a browser inside the container, and watch it in the Browser tab"
style={signIn ? primaryStyle : secondaryStyle}
{...hover(signIn)}
>
In container
</button>
);
return ( return (
<div <div
@@ -109,54 +197,33 @@ export default function UrlToast({
{rest} {rest}
</span> </span>
</div> </div>
{signIn && (
<div
data-testid="url-toast-signin-hint"
style={{
marginTop: 3,
fontSize: 11,
color: "var(--text-secondary)",
lineHeight: 1.35,
}}
>
Sign-in link the callback listener is inside the container.
Opening it there closes the loop; the host browser needs the auth
bridge.
</div>
)}
</div> </div>
<button {signIn ? (
onClick={onOpen} <>
style={{ {containerButton}
padding: "4px 12px", {hostButton}
fontSize: 12, </>
fontWeight: 600, ) : (
color: "#fff", <>
background: "var(--accent)", {hostButton}
border: "none", {containerButton}
borderRadius: 4, </>
cursor: "pointer",
whiteSpace: "nowrap",
flexShrink: 0,
}}
onMouseEnter={(e) =>
(e.currentTarget.style.background = "var(--accent-hover)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.background = "var(--accent)")
}
>
Open
</button>
{onOpenInContainer && (
// A sign-in completed in the *container's* browser lands its callback
// on the container's own loopback, which is where the tool waiting for
// it is listening — no host round trip, no auth bridge.
<button
onClick={onOpenInContainer}
title="Open in a browser inside the container, and watch it in the Browser tab"
style={{
padding: "4px 10px",
fontSize: 12,
fontWeight: 600,
color: "var(--text-primary)",
background: "transparent",
border: "1px solid var(--border-color)",
borderRadius: 4,
cursor: "pointer",
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
In container
</button>
)} )}
<button <button
+4 -3
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import * as commands from "../lib/tauri-commands"; import * as commands from "../lib/tauri-commands";
import { ANTHROPIC_SIGN_IN_HOSTS, sanitizeRelayUrl } from "../lib/urlRelay"; import { ANTHROPIC_SIGN_IN_HOSTS, extendsUrl, sanitizeRelayUrl } from "../lib/urlRelay";
import type { import type {
ClaudeTokenCodeRejectedEvent, ClaudeTokenCodeRejectedEvent,
ClaudeTokenLinkEvent, ClaudeTokenLinkEvent,
@@ -67,7 +67,8 @@ export function authErrorMessage(e: unknown, fallback: string): string {
* starts with it. That is the case longest-wins existed for a repainting * starts with it. That is the case longest-wins existed for a repainting
* TUI can land a truncated copy of the same link in the transcript before * TUI can land a truncated copy of the same link in the transcript before
* the complete one and it cannot swap the origin, because a longer string * the complete one and it cannot swap the origin, because a longer string
* with the same prefix has the same host. * with the same prefix has the same host. {@link extendsUrl} is that rule;
* the terminal's URL prompt slot shares it.
*/ */
export function pickSignInUrl(candidates: readonly string[]): string | null { export function pickSignInUrl(candidates: readonly string[]): string | null {
const cleaned = candidates const cleaned = candidates
@@ -79,7 +80,7 @@ export function pickSignInUrl(candidates: readonly string[]): string | null {
let best: string | null = null; let best: string | null = null;
for (const url of pool) { for (const url of pool) {
if (best === null || url.startsWith(best)) best = url; if (best === null || extendsUrl(url, best)) best = url;
} }
return best; return best;
} }
+7 -2
View File
@@ -277,8 +277,13 @@ export const setBrowserViewPopoutAlwaysOnTop = (projectId: string, onTop: boolea
// lives in the OS keychain and is injected as a container env var. // lives in the OS keychain and is injected as a container env var.
// //
// `acquireClaudeToken` borrows the given project's running container to run the // `acquireClaudeToken` borrows the given project's running container to run the
// login (temporarily enabling its auth bridge), and streams progress on the // login and streams progress on the `claude-token-progress` and
// `claude-token-progress` and `claude-token-output` events. It resolves only // `claude-token-output` events. It deliberately does *not* touch the project's
// auth bridge: `setup-token` finishes on an Anthropic-hosted page and pastes a
// code back, so there is no loopback callback for a bridge to carry — and an
// earlier version that enabled it "just in case" persisted that flag to
// projects.json and left it latched on whenever the flow was killed. See the
// module comment in `commands/auth_token_commands.rs`. It resolves only
// once the whole flow finishes, so call it without awaiting the UI on it. // once the whole flow finishes, so call it without awaiting the UI on it.
// //
// Partway through, `claude setup-token` prints a sign-in URL and then waits at // Partway through, `claude setup-token` prints a sign-in URL and then waits at
+5
View File
@@ -437,6 +437,11 @@ export interface BridgedPort {
family: AuthBridgePortFamily; family: AuthBridgePortFamily;
/** RFC 3339 timestamp of when the host listener was bound. */ /** RFC 3339 timestamp of when the host listener was bound. */
bridged_at: string; bridged_at: string;
/** Set when only the IPv4 half of the host listener could be bound. The port
* is carrying traffic, but a client that resolves `localhost` to `::1` and
* does not fall back will still be refused which otherwise presents as a
* login that hangs while the bridge reports itself healthy. */
ipv6_warning: string | null;
} }
/** A discovered loopback listener that could not be bridged (host port taken). */ /** A discovered loopback listener that could not be bridged (host port taken). */
+193 -1
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { UrlDetector, flatten } from "./urlDetector"; import { UrlDetector, flatten, osc8Targets, usableLink } from "./urlDetector";
import type { UrlSource } from "./urlDetector";
const COLS = 80; const COLS = 80;
const enc = new TextEncoder(); const enc = new TextEncoder();
@@ -17,6 +18,34 @@ function ptyWrap(text: string, cols = COLS): string {
return lines.join("\r\n"); return lines.join("\r\n");
} }
/**
* One OSC 8 hyperlink emission: the whole URL in the parameter, a slice of it
* as visible text.
*
* This is what `claude setup-token` actually prints measured against 2.1.226,
* a 346-character URL arrives as five of these, each carrying the complete URL
* and 80 characters of it on screen.
*/
function osc8(uri: string, visible: string): string {
return `\x1b]8;;${uri}\x07${visible}\x1b]8;;\x07`;
}
/** Slice `uri` into `width`-character visible pieces, each a full hyperlink. */
function slicedHyperlink(uri: string, width = COLS): string {
const parts: string[] = [];
for (let i = 0; i < uri.length; i += width) {
parts.push(osc8(uri, uri.slice(i, i + width)));
}
return parts.join("\r\n");
}
/** The URL Claude Code prints, at the length it really is. */
const SIGN_IN_URL =
"https://claude.ai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e" +
"&response_type=code&redirect_uri=https%3A%2F%2Fconsole.anthropic.com%2Foauth%2Fcode%2Fcallback" +
"&scope=org%3Acreate_api_key+user%3Aprofile+user%3Ainference&code_challenge=" +
"vJ8Kq2mN4pR7sT9wX1zA3bC5dE6fG8hJ0kL2mN4pQ6r&code_challenge_method=S256&state=aB3dE5gH7jK9";
beforeEach(() => vi.useFakeTimers()); beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers()); afterEach(() => vi.useRealTimers());
@@ -116,3 +145,166 @@ describe("UrlDetector", () => {
expect(seen).toEqual([url]); expect(seen).toEqual([url]);
}); });
}); });
describe("usableLink", () => {
// Ports `usable_sign_in_link` from `commands/auth_token_commands.rs` — a junk
// filter, not the security decision. `sanitizeRelayUrl` is still what stands
// between any of this and `openUrl`.
it("accepts an ordinary authorize URL", () => {
expect(usableLink(SIGN_IN_URL)).toBe(true);
});
it("rejects a scheme that is not http(s)", () => {
expect(usableLink("file:///etc/passwd")).toBe(false);
expect(usableLink("javascript:alert(1)")).toBe(false);
});
it("rejects anything outside printable ASCII", () => {
// A control character is how a URL is smuggled past a display, and
// `new URL()` strips some of them silently.
expect(usableLink("https://example.com/\u0000x")).toBe(false);
expect(usableLink("https://exa\u200bmple.com/x")).toBe(false);
expect(usableLink("https://example.com/a b")).toBe(false);
});
});
describe("osc8Targets", () => {
it("lifts the whole URL out of a sliced emission", () => {
const raw = slicedHyperlink(SIGN_IN_URL);
// Every piece carries the complete URL, however little of it is on screen.
expect(new Set(osc8Targets(raw))).toEqual(new Set([SIGN_IN_URL]));
});
it("ignores the closing half of a hyperlink", () => {
expect(osc8Targets("\x1b]8;;\x07")).toEqual([]);
});
it("ignores other OSCs, including the URL relay's own", () => {
expect(osc8Targets("\x1b]0;a window title\x07")).toEqual([]);
expect(osc8Targets("\x1b]7777;open;aHR0cHM6Ly9leGFtcGxlLmNvbQ==\x07")).toEqual([]);
});
it("reads a hyperlink terminated by ST as well as by BEL", () => {
expect(osc8Targets(`\x1b]8;id=1;${SIGN_IN_URL}\x1b\\text`)).toEqual([SIGN_IN_URL]);
});
});
describe("UrlDetector — OSC 8", () => {
it("recovers the complete URL from a sliced hyperlink", () => {
// The bug this branch exists for. `ANSI_RE` strips OSC sequences wholesale,
// so the scraper never saw the parameter and reassembled the *visible*
// pieces instead — a URL that parses, points at claude.ai, and cannot
// authorise anything.
const seen: [string, UrlSource][] = [];
const d = new UrlDetector((u, src) => seen.push([u, src]), () => COLS);
feed(d, "Open this link to sign in:\r\n" + slicedHyperlink(SIGN_IN_URL) + "\r\ndone\r\n");
expect(seen[0]).toEqual([SIGN_IN_URL, "osc8"]);
});
it("does not emit the same hyperlink again when it is repainted", () => {
const seen: [string, UrlSource][] = [];
const d = new UrlDetector((u, s) => seen.push([u, s]), () => COLS);
feed(d, slicedHyperlink(SIGN_IN_URL) + "\r\n");
feed(d, slicedHyperlink(SIGN_IN_URL) + "\r\n");
expect(seen.filter(([u]) => u === SIGN_IN_URL)).toHaveLength(1);
});
it("marks a scraped candidate as a guess, so the slot can refuse it", () => {
// Nothing here decides precedence — that is `supersedes` in TerminalView —
// but it is what makes the decision possible.
const seen: [string, UrlSource][] = [];
const d = new UrlDetector((u, s) => seen.push([u, s]), () => COLS);
const url = "https://example.com/" + "z".repeat(120);
feed(d, url + "\r\nnext\r\n");
expect(seen).toEqual([[url, "heuristic"]]);
});
it("ignores a short hyperlink", () => {
// `ls --hyperlink` decorates every filename; none of that is a prompt.
const seen: string[] = [];
const d = new UrlDetector((u) => seen.push(u), () => COLS);
feed(d, osc8("https://example.com/a", "a") + "\r\nnext\r\n");
expect(seen).toEqual([]);
});
});
describe("UrlDetector — repaints and resizes", () => {
it("treats a bare CR as a line break", () => {
// A TUI repaints by returning to column 0 without a line feed. Splitting on
// `\r?\n` alone leaves a whole frame on one "line", which is then longer
// than the width — so the `===` test says "not wrapped" and a break the
// terminal really did insert is never rejoined.
const seen: string[] = [];
const d = new UrlDetector((u) => seen.push(u), () => COLS);
const url = "https://example.com/" + "q".repeat(100);
feed(d, "spinner frame one\rspinner frame two\r" + ptyWrap(url) + "\r\ndone\r\n");
expect(seen).toEqual([url]);
});
it("does not glue two repainted frames into one token", () => {
const seen: string[] = [];
const d = new UrlDetector((u) => seen.push(u), () => COLS);
feed(
d,
"https://example.com/" + "a".repeat(90) + "\rhttps://evil.tld/" + "b".repeat(90) + "\r\n\r\ndone\r\n",
);
for (const url of seen) expect(new URL(url).host).not.toBe("example.comhttps");
});
it("reassembles with the width the bytes were printed at, not the current one", () => {
// The scan runs 300 ms after the print. A resize inside that window used to
// change every join decision retroactively: text wrapped at 80 columns,
// rejoined against a width of 120, comes back as separate lines glued with
// spaces — or, the other way round, as a URL nobody printed.
const seen: string[] = [];
let cols = COLS;
const d = new UrlDetector((u) => seen.push(u), () => cols);
const url =
"https://accounts.example.com/o/oauth2/auth?client_id=1234567890-abcdefghijklmnop.apps.example.com&redirect_uri=http%3A%2F%2Flocalhost%3A45678";
d.feed(new TextEncoder().encode(ptyWrap(url) + "\r\nWaiting…\r\n"));
cols = 120; // the user drags the window wider before the debounce fires
vi.advanceTimersByTime(2000);
expect(seen).toEqual([url]);
});
it("drops text buffered at a width that no longer applies", () => {
// Half printed at 80, half at 120: no single width reassembles both, so the
// older half goes rather than being joined by a rule that is wrong for it.
const seen: string[] = [];
let cols = COLS;
const d = new UrlDetector((u) => seen.push(u), () => cols);
const url = "https://example.com/" + "m".repeat(120);
d.feed(new TextEncoder().encode(ptyWrap(url.slice(0, 100))));
cols = 120;
feed(d, url.slice(100) + "\r\ndone\r\n");
// Whatever survives, it is never a URL that was not printed.
for (const u of seen) expect(url.startsWith(u) || u.startsWith(url)).toBe(true);
});
});
describe("flatten — bare CR", () => {
it("splits on a lone CR as well as on LF", () => {
expect(flatten("abcde\rfghij", 5)).toBe("abcdefghij");
expect(flatten("abc\rdef", 5)).toBe("abc def");
});
it("counts CRLF as one break, not two", () => {
expect(flatten("abcde\r\nfghij", 5)).toBe("abcdefghij");
});
});
+157 -5
View File
@@ -24,17 +24,109 @@
* When a URL match extends to the end of the flattened buffer, emission is * When a URL match extends to the end of the flattened buffer, emission is
* deferred (more chunks may still be arriving). A confirmation timer emits * deferred (more chunks may still be arriving). A confirmation timer emits
* the pending URL if no further data arrives within 500 ms. * the pending URL if no further data arrives within 500 ms.
*
* ## OSC 8 comes first, and the scraping is the fallback
*
* Everything above is guesswork over what a terminal *painted*. When the
* program emits an **OSC 8 hyperlink** there is no guesswork to do: the
* complete URL is in the sequence's parameter, contiguous and exact, however
* the visible text was sliced.
*
* That distinction is the whole reason this file grew a second branch. Claude
* Code prints its sign-in link as an OSC 8 hyperlink whose *visible* text is
* cut into terminal-width pieces on separate lines measured against 2.1.226,
* a 346-character URL arrives as five emissions, each carrying the whole URL in
* its parameter and 80 characters of it on screen. `ANSI_RE` strips OSC
* sequences wholesale, so the scraper never saw the parameter and reassembled
* the visible pieces instead: a URL that parses, that points at claude.com, and
* that cannot authorise anything. The backend hit this first and solved it the
* same way see `commands/auth_token_commands.rs`, whose `osc8_target` and
* `usable_sign_in_link` this mirrors.
*
* So each emitted candidate is tagged with where it came from, and the consumer
* refuses to let a `heuristic` candidate displace an `osc8` one.
*/ */
const ANSI_RE = const ANSI_RE =
/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()#][A-Za-z0-9]|.)/g; /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()#][A-Za-z0-9]|.)/g;
/**
* OSC 8 hyperlink: `ESC ] 8 ; <params> ; <uri> (BEL | ESC \\)`.
*
* The params field is `key=value` pairs separated by `:`, never `;`, so the
* first `;` after the `8;` ends it the same split `osc8_target` makes in
* Rust. The closing half of a hyperlink is `8;;` with an empty uri.
*/
// eslint-disable-next-line no-control-regex
const OSC8_RE = /\x1b\]8;([^;\x07\x1b]*);([^\x07\x1b]*)(?:\x07|\x1b\\)/g;
const MAX_BUFFER = 8 * 1024; // 8 KB rolling buffer cap const MAX_BUFFER = 8 * 1024; // 8 KB rolling buffer cap
const DEBOUNCE_MS = 300; const DEBOUNCE_MS = 300;
const CONFIRM_MS = 500; // extra wait when URL reaches end of buffer const CONFIRM_MS = 500; // extra wait when URL reaches end of buffer
const MIN_URL_LENGTH = 100; const MIN_URL_LENGTH = 100;
export type UrlCallback = (url: string) => void; /** Mirrors `MAX_LINK_LENGTH` in `commands/auth_token_commands.rs`. */
const MAX_LINK_LENGTH = 8192;
/** Bound on remembered OSC 8 targets, so a program printing a fresh hyperlink
* every frame cannot grow this without limit. */
const MAX_REMEMBERED_LINKS = 32;
/**
* Where a candidate came from, which is the same thing as how much it can be
* trusted to be *complete*.
*
* `osc8` is lifted verbatim out of a hyperlink parameter; `heuristic` was
* reassembled from painted text and may be a truncated guess. The consumer
* uses this to decide precedence see `promptUrl` in `TerminalView.tsx`.
*/
export type UrlSource = "osc8" | "heuristic";
export type UrlCallback = (url: string, source: UrlSource) => void;
/**
* Whether an OSC 8 target is worth offering as a candidate at all.
*
* A direct port of `usable_sign_in_link` in
* `commands/auth_token_commands.rs`, and deliberately just as shallow: this is
* a junk filter, not the security decision. `sanitizeRelayUrl` is still the
* only thing standing between any of this and `openUrl`, and duplicating its
* rules here would be a second place for them to go stale.
*
* The one rule from the Rust that is not ported is its `sk-ant-` check: that
* exists because the backend's link path bypasses `SecretRedactor`, and there
* is no redactor on this side to bypass.
*/
export function usableLink(uri: string): boolean {
if (!uri.startsWith("https://") && !uri.startsWith("http://")) return false;
if (uri.length > MAX_LINK_LENGTH) return false;
// Printable ASCII only. Control characters and whitespace are exactly how a
// URL is smuggled past a display, and `new URL()` strips some of them
// silently; a real authorize URL is percent-encoded anyway.
for (let i = 0; i < uri.length; i++) {
const code = uri.charCodeAt(i);
if (code < 0x21 || code > 0x7e) return false;
}
return true;
}
/**
* Every usable OSC 8 hyperlink target in `raw`, in the order they were emitted.
*
* Takes the *unstripped* stream: `ANSI_RE` deletes OSC sequences wholesale, so
* by the time the buffer is clean the parameter this reads is already gone.
*/
export function osc8Targets(raw: string): string[] {
const out: string[] = [];
OSC8_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = OSC8_RE.exec(raw)) !== null) {
const uri = m[2];
// `8;;` closes a hyperlink and carries no target.
if (uri && usableLink(uri)) out.push(uri);
}
return out;
}
/** /**
* How wide the terminal is right now. * How wide the terminal is right now.
@@ -61,7 +153,13 @@ export type ColumnsGetter = () => number;
* confirmed by the user before anything opens. * confirmed by the user before anything opens.
*/ */
export function flatten(clean: string, columns: number): string { export function flatten(clean: string, columns: number): string {
const lines = clean.split(/\r?\n/); // A bare `\r` is a break too. A TUI repaints a frame by returning to column
// 0 without a line feed, so splitting on `\r?\n` alone leaves a whole
// frame's worth of text on one "line" — which is then far longer than
// `columns`, so the `===` test below says "not wrapped" and a URL the
// terminal really did cut is never rejoined. Splitting here is also what the
// backend does with a lone CR (`strip_ansi_prefix`), for the same reason.
const lines = clean.split(/\r\n|\r|\n/);
let out = ""; let out = "";
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
out += lines[i]; out += lines[i];
@@ -84,6 +182,20 @@ export class UrlDetector {
private pendingUrl: string | null = null; private pendingUrl: string | null = null;
private callback: UrlCallback; private callback: UrlCallback;
private columns: ColumnsGetter; private columns: ColumnsGetter;
/**
* The width in effect when the buffered bytes were *printed*, sampled in
* `feed`.
*
* Not read in `scan`, which is where it used to be read: the scan happens
* 300 ms after the print, and a resize inside that window would reassemble
* text wrapped at 80 columns using a width of 120 every join decision
* wrong, and a fabricated URL out the other end. `-1` means "nothing fed
* yet".
*/
private feedColumns = -1;
/** OSC 8 targets already offered, so a hyperlink repainted every frame does
* not re-prompt. Bounded by {@link MAX_REMEMBERED_LINKS}. */
private emittedLinks = new Set<string>();
constructor(callback: UrlCallback, columns: ColumnsGetter) { constructor(callback: UrlCallback, columns: ColumnsGetter) {
this.callback = callback; this.callback = callback;
@@ -92,6 +204,18 @@ export class UrlDetector {
/** Feed raw PTY output chunks. */ /** Feed raw PTY output chunks. */
feed(data: Uint8Array): void { feed(data: Uint8Array): void {
const columns = this.columns();
if (this.feedColumns !== -1 && columns !== this.feedColumns) {
// The buffered text was wrapped at a width that no longer applies, and
// the new text will be wrapped at this one. There is no single width
// that reassembles both, so the older half is dropped rather than
// joined by a rule that is now wrong for it. Costs a URL that was
// mid-print across a resize; never invents one.
this.buffer = "";
this.pendingUrl = null;
}
this.feedColumns = columns;
this.buffer += this.decoder.decode(data, { stream: true }); this.buffer += this.decoder.decode(data, { stream: true });
// Cap buffer to avoid unbounded growth // Cap buffer to avoid unbounded growth
@@ -114,11 +238,19 @@ export class UrlDetector {
} }
private scan(): void { private scan(): void {
// 0. The exact copy first. An OSC 8 parameter needs no reassembly, so
// anything found here beats whatever the steps below reconstruct — and
// it has to be read from the raw buffer, because step 1 deletes the
// sequence that carries it.
this.scanLinks();
// 1. Strip ANSI escape sequences // 1. Strip ANSI escape sequences
const clean = this.buffer.replace(ANSI_RE, ""); const clean = this.buffer.replace(ANSI_RE, "");
// 2. Flatten the buffer: rejoin hard wraps, terminate on everything else. // 2. Flatten the buffer: rejoin hard wraps, terminate on everything else.
const flat = flatten(clean, this.columns()); // The width is the one that was in effect when these bytes were
// printed, not the one the terminal happens to have now.
const flat = flatten(clean, this.feedColumns);
if (!flat) return; if (!flat) return;
@@ -155,7 +287,7 @@ export class UrlDetector {
this.pendingUrl = null; this.pendingUrl = null;
if (url !== this.lastEmitted) { if (url !== this.lastEmitted) {
this.lastEmitted = url; this.lastEmitted = url;
this.callback(url); this.callback(url, "heuristic");
} }
} }
@@ -166,10 +298,30 @@ export class UrlDetector {
} }
} }
/**
* Offer every OSC 8 target in the buffer that has not been offered before.
*
* `lastEmitted` is moved along with them so an identical string arriving on
* the heuristic path a moment later is recognised as the same candidate
* rather than fired a second time.
*/
private scanLinks(): void {
for (const uri of osc8Targets(this.buffer)) {
if (uri.length < MIN_URL_LENGTH) continue;
if (this.emittedLinks.has(uri)) continue;
if (this.emittedLinks.size >= MAX_REMEMBERED_LINKS) {
this.emittedLinks.clear();
}
this.emittedLinks.add(uri);
this.lastEmitted = uri;
this.callback(uri, "osc8");
}
}
private emitPending(): void { private emitPending(): void {
if (this.pendingUrl && this.pendingUrl !== this.lastEmitted) { if (this.pendingUrl && this.pendingUrl !== this.lastEmitted) {
this.lastEmitted = this.pendingUrl; this.lastEmitted = this.pendingUrl;
this.callback(this.pendingUrl); this.callback(this.pendingUrl, "heuristic");
} }
this.pendingUrl = null; this.pendingUrl = null;
} }
+36
View File
@@ -158,6 +158,42 @@ export function sanitizeRelayUrl(
return normalized; return normalized;
} }
/**
* Whether `next` is the same link as `current`, only longer.
*
* The single rule that lets a later candidate displace an earlier one when both
* were scraped from the same untrusted stream. A repainting TUI lands a
* truncated copy of a link in the transcript before the complete one, and this
* is what joins them back up safely, because a longer string sharing a prefix
* with the current pick necessarily has the same scheme, host and port, so an
* attacker cannot use it to move the origin.
*
* Longest-wins without the prefix test is what this replaced, and it handed the
* choice to the attacker: pad a hostile URL and it displaces the real one.
*
* Used by `pickSignInUrl` (`hooks/useClaudeAuth.ts`) and by the terminal's URL
* prompt slot (`components/terminal/TerminalView.tsx`). One implementation, on
* purpose.
*/
export function extendsUrl(next: string, current: string): boolean {
return next.length > current.length && next.startsWith(current);
}
/**
* Whether this is a URL that signs the user in to Anthropic.
*
* Used to decide *presentation*, not permission the toast makes the
* container-side browser the default action for these, because the OAuth
* callback listener is inside the container and the host has nothing to catch
* it with. It is deliberately the same host allowlist the sign-in flow itself
* uses, so the two cannot disagree about what a sign-in link is.
*/
export function isAnthropicSignInUrl(url: string): boolean {
const safe = sanitizeRelayUrl(url, { allowHosts: ANTHROPIC_SIGN_IN_HOSTS });
if (!safe) return false;
return /oauth|authorize|login|sign-?in/i.test(safe);
}
/** /**
* The origin of an already-sanitized URL, for display. * The origin of an already-sanitized URL, for display.
* *
+23
View File
@@ -458,6 +458,29 @@ elif [ -f "$CLAUDE_JSON" ] && grep -q '"awsAuthRefresh"' "$CLAUDE_JSON" 2>/dev/n
fi fi
fi fi
# ── Shift+Enter key binding ───────────────────────────────────────────────────
# Triple-C's terminals send ESC+CR (`\e\r`) on Shift+Enter — the same bytes
# `/terminal-setup` installs for VS Code, Cursor, Alacritty and Zed — and Claude
# Code decodes those unconditionally. What it cannot see is that the binding is
# already in place, so it keeps printing its "run /terminal-setup" tip. This
# flag is what that tip is gated on: purely cosmetic, and it changes nothing
# about how the sequence is decoded.
CLAUDE_JSON="/home/claude/.claude.json"
if [ -f "$CLAUDE_JSON" ]; then
# Only rewrite when the value isn't already true, to avoid a needless jq
# reformat of ~/.claude.json on every single start.
if ! grep -q '"shiftEnterKeyBindingInstalled"[[:space:]]*:[[:space:]]*true' "$CLAUDE_JSON" 2>/dev/null; then
MERGED=$(jq '.shiftEnterKeyBindingInstalled = true' "$CLAUDE_JSON" 2>/dev/null)
if [ -n "$MERGED" ]; then
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
fi
fi
else
printf '{"shiftEnterKeyBindingInstalled":true}\n' > "$CLAUDE_JSON"
fi
chown claude:claude "$CLAUDE_JSON"
chmod 600 "$CLAUDE_JSON"
# ── Docker socket permissions ──────────────────────────────────────────────── # ── Docker socket permissions ────────────────────────────────────────────────
if [ -S /var/run/docker.sock ]; then if [ -S /var/run/docker.sock ]; then
DOCKER_GID=$(stat -c '%g' /var/run/docker.sock) DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)