feat(studio): a phone on the same network pairs by QR code and drives the window's panes

Settings > Remote > Phone access opens a second listener on a private
address the person picks (Ethernet/Wi-Fi first, Tailscale next, tunnels
and virtual switches last, read from the interface's flags and hardware
address). The window shows a QR code carrying a one-time pairing code
in the URL fragment; the page trades it at POST /pair for an HttpOnly,
SameSite=Strict device cookie. A code pairs once and lapses after five
minutes; closing the share or quitting Studio unpairs every device, and
each device can be disconnected on its own.

A paired device holds what a networked serve client holds, and no more.
The window's grants (provider edits, account sign-in, workspace switch,
editor open, provider setup) are now read per request, so they stay shut
for device reach even though the device reaches the same handler. The
window's own routes (folder picker, SSH remotes, tray, notifications,
browser host, updates, asks, and the share controls themselves) answer
a device with 404 device.host_only, which the page already reads as a
kernel with no window.

The device gate refuses a Host other than the listener's own address
(rebinding), a state change from a foreign Origin, and every kernel
route without a known credential; the page's own files stay public so
an unpaired phone can load the page it pairs from.

The listener is plain HTTP, and the panel says so: fine on a home
network, not on a public one. Pairings live in memory only.
This commit is contained in:
yhh committed 2026-09-24 02:14:55 -07:00
1 parent fc902b846c
commit 74f76057fe
40 files changed
+1564 -58

No files matched your search

+9
View File
@@ -307,6 +307,13 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
browserHost := serve.NewBrowserHost()
boot.SetBrowserHost(browserHost.Dial)
hubCfg := hostServeConfig(cfg.Serve)
// Shut until the person at the window opens it; the context ending closes
// it with the rest of the kernel, unpairing every device.
share := serve.NewDeviceShare(page)
go func() {
<-ctx.Done()
share.Close()
}()
hub := serve.NewHub(serve.HubOptions{
Serve: hubCfg,
Surface: surface.Desktop,
@@ -321,7 +328,9 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
OnClose: func(rt *serve.Runtime) { tracker.Drop(paneKey(rt.Events)) },
Install: studioInstall(shell),
Update: studioUpdateHost(shell, handshakeTo),
Share: share,
})
share.Attach(hub.Handler())
srv := serve.New(built.Controller, bc, hubCfg)
srv.SetPaneSink(paneSink)
srv.AdoptRuntime(built)
+6 -3
View File
@@ -22,18 +22,21 @@
// browser never sends. Trading it for the cookie has to happen before the
// bundle makes its first request, and the fragment is wiped from the
// address bar and history on the way past so it cannot be copied on.
// A scanned pairing link carries a one-time code the same way.
window.__rxAuth = (function () {
try {
var f = new URLSearchParams(window.location.hash.slice(1));
var token = f.get("token");
if (!token) return Promise.resolve();
var pair = f.get("pair");
if (!token && !pair) return Promise.resolve();
f.delete("token");
f.delete("pair");
var rest = f.toString();
window.history.replaceState(null, "", window.location.pathname + window.location.search + (rest ? "#" + rest : ""));
return fetch("/auth/token", {
return fetch(pair ? "/pair" : "/auth/token", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token: token }),
body: JSON.stringify(pair ? { code: pair } : { token: token }),
}).then(function () {});
} catch (e) {
return Promise.resolve();
+6
View File
@@ -252,6 +252,12 @@ export const ACTIONS: UIAction[] = [
{ id: "remotes.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "remotes.save", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "remotes.remove", kind: "destructive", target: "entity", proof: "authority-effect" },
{ id: "share.toggle", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "share.address", kind: "view", target: "none", proof: "interaction" },
{ id: "share.offer", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "share.ask-revoke", kind: "view", target: "entity", proof: "interaction" },
{ id: "share.keep", kind: "view", target: "entity", proof: "interaction" },
{ id: "share.revoke", kind: "destructive", target: "entity", proof: "authority-effect" },
{ id: "versions.reload", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "versions.pin", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "versions.activate", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -104,6 +104,16 @@ export const EN_KERNEL: Record<string, string> = {
"通知设置没能保存:{error}": "The notification settings could not be saved: {error}",
"这台机器上没找到 VS Code、Cursor 这类编辑器。装一个,或在配置里用 [desktop] editor 指定路径。": "No editor such as VS Code or Cursor was found on this machine. Install one, or name its path with [desktop] editor.",
"编辑器没能启动:{error}": "The editor could not be started: {error}",
"这项操作只能在电脑上的窗口里做,已配对的手机做不了。": "Only the window on the computer can do this; a paired phone cannot.",
"这个地址不是本机共享的地址,请重新扫码。": "This address is not the one this computer is sharing. Scan the code again.",
"请求来自其他网页,已拒绝。": "The request came from another page and was refused.",
"这台设备还没配对,或已被移除。请在电脑上重新显示二维码并扫码。": "This device is not paired, or was removed. Show a new code on the computer and scan it.",
"配对码已失效:可能已被使用、已过期,或已换了新码。请在电脑上重新显示二维码。": "That pairing code is no longer valid: it was used, expired, or replaced. Show a new code on the computer.",
"共享入口配置不完整,无法接受连接。": "The sharing listener is not fully configured and cannot accept connections.",
"手机访问已关闭,请先开启。": "Phone access is off. Turn it on first.",
"地址 {ip} 不是本机的局域网地址。": "{ip} is not a private address on this computer.",
"无法在 {ip} 上开启监听:{error}": "Could not listen on {ip}: {error}",
"没有这台已配对的设备,可能已被移除。": "No paired device has that id; it may already have been removed.",
"这个内核没有窗口,打不开本机的编辑器。": "This kernel has no window, so it cannot open an editor on this machine.",
"这个系统没有可用的文件夹选择框,请直接填写路径。": "This system has no folder picker available; type the path instead.",
"打不开文件夹选择框:{error}": "The folder picker could not be opened: {error}",
@@ -113,4 +113,21 @@ export const EN_REMOTE: Record<string, string> = {
"无法连接 —— 解决上述任意一项即可": "Not ready — clearing any one of the above is enough",
"读取 {name} 上的会话": "Read the sessions on {name}",
"刷新 {name} 上的会话": "Refresh the sessions on {name}",
// ── 手机访问 ─────────────────────────────────────────────────
"手机访问": "Phone access",
"允许手机访问": "Allow phones to connect",
"这台电脑现在没有局域网地址,连上 Wi-Fi 或网线后再试。": "This computer has no private network address right now. Connect to Wi-Fi or Ethernet and try again.",
"手机和这台电脑在同一个网络里,扫码后就能看会话、发消息、批准操作。关闭或退出 Studio 会断开所有手机。": "A phone on the same network as this computer can scan a code to follow sessions, send messages and approve actions. Turning this off or quitting Studio disconnects every phone.",
"虚拟网卡": "virtual adapter",
"配对二维码": "Pairing QR code",
"用手机相机扫码": "Scan with your phone's camera",
"二维码只能配对一台手机,{time} 前有效。别把它发到群里或截图外传。": "The code pairs one phone and is valid until {time}. Do not post it to a group chat or share a screenshot of it.",
"显示配对二维码": "Show a pairing code",
"未知设备": "Unknown device",
"{time} 配对 · 最近 {seen}": "Paired {time} · last seen {seen}",
"断开这台手机?它需要重新扫码才能再连上。": "Disconnect this phone? It will need to scan a new code to connect again.",
"断开": "Disconnect",
"还没有手机连上来。": "No phone is connected yet.",
"连接走的是局域网里的明文 HTTP:同一个网络里的其他人能看到传输内容。在自己家里可以用,咖啡馆、机场这类公共网络不要开。Tailscale 地址的流量本身是加密的。": "The connection is plain HTTP on your local network, so others on the same network can read it. Fine at home; do not turn it on at a café, an airport or any other public network. Traffic to a Tailscale address is already encrypted.",
};
+10
View File
@@ -102,6 +102,16 @@ const SAID: Record<string, string> = {
"editor.not_installed": "这台机器上没找到 VS Code、Cursor 这类编辑器。装一个,或在配置里用 [desktop] editor 指定路径。",
"editor.launch_failed": "编辑器没能启动:{error}",
"editor.no_window": "这个内核没有窗口,打不开本机的编辑器。",
"device.host_only": "这项操作只能在电脑上的窗口里做,已配对的手机做不了。",
"device.host_rejected": "这个地址不是本机共享的地址,请重新扫码。",
"device.origin_rejected": "请求来自其他网页,已拒绝。",
"device.unauthorized": "这台设备还没配对,或已被移除。请在电脑上重新显示二维码并扫码。",
"device.pairing_invalid": "配对码已失效:可能已被使用、已过期,或已换了新码。请在电脑上重新显示二维码。",
"device.misconfigured": "共享入口配置不完整,无法接受连接。",
"share.closed": "手机访问已关闭,请先开启。",
"share.address_rejected": "地址 {ip} 不是本机的局域网地址。",
"share.listen_failed": "无法在 {ip} 上开启监听:{error}",
"share.device_unknown": "没有这台已配对的设备,可能已被移除。",
"picker.unsupported": "这个系统没有可用的文件夹选择框,请直接填写路径。",
"picker.failed": "打不开文件夹选择框:{error}",
"provider.bad_key_slot": "名称「{name}」不能用来存放密钥:密钥槽位由名称推导,而它不能以数字开头。改一个以字母开头的名称即可,密钥本身没有问题。",
+9 -1
View File
@@ -8,6 +8,7 @@ import "./styles/app.css";
import "./styles/studio.css";
import { App } from "./ui/App";
import { SseHub } from "./port/hub";
import { HttpError } from "./port/http_error";
import type { HubPort } from "./port/hub";
import { install as installFileDrop } from "./ui/filedrop";
import { host } from "./port/host";
@@ -24,7 +25,14 @@ async function pick(): Promise<HubPort> {
// pane closed, which is when a reload has to find the kernel again.
const res = await fetch("/runtimes", { credentials: "same-origin" });
if (res.ok && (res.headers.get("content-type") ?? "").includes("json")) return new SseHub();
} catch {
// A kernel that answered and refused: a phone that is not paired, or no
// longer is. Its code says which, and "cannot reach" would say neither.
if (res.status === 401) {
const body = (await res.json().catch(() => null)) as { code?: string; error?: string } | null;
if (body?.code) throw new HttpError(res.status, body.error ?? "", body);
}
} catch (e) {
if (e instanceof HttpError) throw e;
// no serve reachable
}
// A shipped build is served by the kernel it talks to. Falling back to the
+29 -4
View File
@@ -1,5 +1,6 @@
import { HttpError, type AgentPort } from "./port";
import type { RemoteAsk, RemoteHost, RemoteHostEdit, RemoteListing, RemoteProbe } from "./remote";
import type { ShareOffer, SharePort, ShareStatus } from "./share";
import { SsePort } from "./sse";
// How often a client waiting on a dial looks for the question it might be
@@ -60,7 +61,7 @@ export interface TreeWorkspace {
// HubPort is the window's view of the kernel: which panes it drives and which
// folders it can open. One AgentPort hangs off each pane.
export interface HubPort {
export interface HubPort extends SharePort {
runtimes(): Promise<RuntimeView[]>;
open(req: { root?: string; sessionPath?: string }): Promise<RuntimeView>;
close(id: string): Promise<void>;
@@ -222,8 +223,9 @@ export class SseHub implements HubPort {
async remoteHosts() {
const res = await fetch("/remotes", { credentials: "same-origin" });
// Not an error to report: this kernel simply does not do remote panes.
if (res.status === 501) return null;
// Not an error to report: this kernel does not do remote panes, or answers
// a paired device, which the window's own routes are not found for.
if (res.status === 501 || res.status === 404) return null;
if (!res.ok) await SseHub.fail("/remotes", res);
return (await res.json()) as RemoteHost[];
}
@@ -354,12 +356,35 @@ export class SseHub implements HubPort {
credentials: "same-origin",
body: "{}",
});
if (res.status === 501) return null;
if (res.status === 501 || res.status === 404) return null;
if (!res.ok) await SseHub.fail("/host/pick-folder", res);
const body = (await res.json()) as { path?: string };
return body.path ?? "";
}
async shareStatus() {
const res = await fetch("/share", { credentials: "same-origin" });
if (res.status === 404) return null;
if (!res.ok) await SseHub.fail("/share", res);
return (await res.json()) as ShareStatus;
}
openShare(ip: string) {
return this.post<ShareStatus>("/share/open", { ip });
}
closeShare() {
return this.post<ShareStatus>("/share/close", {});
}
offerShare() {
return this.post<ShareOffer>("/share/offer", {});
}
revokeDevice(id: string) {
return this.post<ShareStatus>("/share/revoke", { id });
}
portFor(rt: RuntimeView): AgentPort {
const held = this.ports.get(rt.id);
if (held) return held;
@@ -1,6 +1,7 @@
import type { AgentPort } from "./port";
import type { HubPort, RuntimeView, TreeWorkspace } from "./hub";
import type { RemoteHost, RemoteListing, RemoteProbe } from "./remote";
import type { ShareStatus } from "./share";
import { MockPort } from "./mock";
// MockHub is the fixture's answer to a window that drives several panes. Each
@@ -216,6 +217,42 @@ export class MockHub implements HubPort {
return Promise.resolve<string | null>("~/projects/mock-workspace");
}
private share: ShareStatus = {
open: false,
addresses: [
{ interface: "Wi-Fi", ip: "192.168.1.20", kind: "lan" },
{ interface: "Tailscale", ip: "100.101.7.3", kind: "tailnet" },
{ interface: "vEthernet (WSL)", ip: "172.28.160.1", kind: "virtual" },
],
devices: [],
};
shareStatus() {
return Promise.resolve<ShareStatus | null>({ ...this.share });
}
openShare(ip: string) {
this.share = { ...this.share, open: true, origin: `http://${ip}:41234`, devices: [] };
return Promise.resolve({ ...this.share });
}
closeShare() {
this.share = { ...this.share, open: false, origin: undefined, devices: [], offerExpires: undefined };
return Promise.resolve({ ...this.share });
}
offerShare() {
const expires = new Date(Date.now() + 5 * 60_000).toISOString();
this.share = { ...this.share, offerExpires: expires };
const qr = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 29 29"><rect width="29" height="29" fill="#fff"/><path fill="#000" d="M4 4h7v7h-7zM18 4h7v7h-7zM4 18h7v7h-7zM13 13h3v3h-3z"/></svg>`;
return Promise.resolve({ url: `${this.share.origin}/#pair=mock-code`, qr, expires });
}
revokeDevice(id: string) {
this.share = { ...this.share, devices: this.share.devices.filter((d) => d.id !== id) };
return Promise.resolve({ ...this.share });
}
portFor(rt: RuntimeView): AgentPort {
const held = this.ports.get(rt.id);
if (held) return held;
+45
View File
@@ -0,0 +1,45 @@
// The window's door for phones: a second listener on a private address that a
// paired device reaches the same panes through. Mirrors serve.ShareStatus,
// serve.ShareOffer and serve.DeviceView.
// lan is Wi-Fi or Ethernet; virtual is a VPN or proxy tunnel a phone usually
// cannot reach. The kernel lists them in that order, so the first is the default.
export type AddressKind = "lan" | "tailnet" | "virtual";
export interface ShareAddress {
interface: string;
ip: string;
kind: AddressKind;
}
export interface PairedDevice {
id: string;
name: string;
pairedAt: string;
lastSeen: string;
}
export interface ShareStatus {
open: boolean;
origin?: string;
addresses: ShareAddress[];
devices: PairedDevice[];
offerExpires?: string;
}
export interface ShareOffer {
url: string;
// An SVG document, drawn dark on light whatever the theme so it scans.
qr: string;
expires: string;
}
export interface SharePort {
// Null where this kernel has no window to share from: a browser tab, or a
// phone that is itself paired.
shareStatus(): Promise<ShareStatus | null>;
openShare(ip: string): Promise<ShareStatus>;
closeShare(): Promise<ShareStatus>;
offerShare(): Promise<ShareOffer>;
revokeDevice(id: string): Promise<ShareStatus>;
}
+31
View File
@@ -4686,6 +4686,37 @@
}
.rmtadd:hover { border-color: var(--accent); color: var(--text) }
.share .rmtf { margin-top: 10px }
.shareopen { display: grid; gap: 12px; margin-top: 12px }
.shareopen .rmtadd { margin-top: 0; justify-self: start }
.shareqr { display: flex; align-items: flex-start; gap: 16px; flex-wrap: wrap }
.shareqr img {
flex: none; width: 184px; height: 184px; padding: 8px; box-sizing: content-box;
background: #fff; border: 1px solid var(--border); border-radius: var(--r-sm);
image-rendering: pixelated;
}
.sharehow { display: grid; gap: 6px; flex: 1; min-width: 22ch }
.sharehow .lb { font: var(--w-med) var(--fs-ui) var(--ui); color: var(--text) }
.sharehow .ds { font: var(--w-reg) var(--fs-meta)/1.6 var(--ui); color: var(--faint) }
.sharehow code { font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--muted); overflow-wrap: anywhere }
.sharedevs { display: grid; gap: 4px }
.sharedev { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 8px 2px; border-top: 1px solid var(--hair) }
.sharedev .nm {
flex: 1 1 18ch; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font: var(--w-reg) var(--fs-ui) var(--ui); color: var(--text);
}
.sharedev .st { flex: none; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint) }
.sharedev .rmtlnk {
flex: none; border: 0; background: none; cursor: pointer; padding: 0 2px;
font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint);
}
.sharedev .rmtlnk:hover { color: var(--err) }
.sharedev .rmtconfirm { flex: 1 1 100%; margin: 0 }
.sharewarn {
margin: 0; padding: 8px 10px; border-left: 2px solid var(--warn);
font: var(--w-reg) var(--fs-meta)/1.6 var(--ui); color: var(--muted); max-width: 64ch;
}
.find .ntimes {
margin-left: 6px; padding: 0 5px; border-radius: var(--r-pill);
background: var(--overlay); color: var(--faint);
@@ -0,0 +1,179 @@
import { useCallback, useEffect, useState } from "react";
import { t } from "../i18n";
import type { HubPort } from "../port/hub";
import type { ShareOffer, ShareStatus } from "../port/share";
import { ApplyNote } from "./Group";
import { Switch } from "./Switch";
interface Props {
hub: HubPort;
onError: (e: unknown) => void;
}
// While the door is open the list is what changes under the reader: a phone
// pairs, the code on screen is spent. Nothing pushes that, so it is read.
const POLL_MS = 3000;
function clock(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "" : d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
/** A phone on the same network drives this window's panes. Nothing here is
* kept: the door shuts with the window, and shutting it unpairs every phone. */
export function PhoneAccess({ hub, onError }: Props) {
const [st, setSt] = useState<ShareStatus | null>(null);
const [ip, setIp] = useState("");
const [offer, setOffer] = useState<ShareOffer | null>(null);
const [busy, setBusy] = useState(false);
const [confirm, setConfirm] = useState("");
const read = useCallback(() => hub.shareStatus().then(setSt), [hub]);
useEffect(() => {
read().catch(() => setSt(null));
}, [read]);
useEffect(() => {
if (!st?.open) return;
const timer = setInterval(() => read().catch(() => {}), POLL_MS);
return () => clearInterval(timer);
}, [st?.open, read]);
// The kernel withdraws a code once it pairs or lapses; the picture of it has
// to go too, or the next phone scans a code that can no longer work.
useEffect(() => {
if (offer && st && !st.offerExpires) setOffer(null);
}, [st, offer]);
const run = useCallback(
async (step: () => Promise<void>) => {
setBusy(true);
try {
await step();
} catch (e) {
onError(e);
} finally {
setBusy(false);
}
},
[onError],
);
const newCode = () => run(async () => {
setOffer(await hub.offerShare());
await read();
});
const toggle = () => run(async () => {
if (st?.open) {
setOffer(null);
setSt(await hub.closeShare());
return;
}
const at = ip || st?.addresses[0]?.ip || "";
setSt(await hub.openShare(at));
setOffer(await hub.offerShare());
await read();
});
// A settings page must not grow a heading for a door this kernel cannot open.
if (!st) return null;
const chosen = ip || (st.open ? st.origin?.replace(/^https?:\/\//, "").replace(/:\d+$/, "") : "") || st.addresses[0]?.ip || "";
const noNetwork = st.addresses.length === 0;
return (
<section className="grp" id="set-phone" data-setting="phone">
<div className="grp-hd">
<h3>{t("手机访问")}</h3>
</div>
<div className="grp-items share">
<div className="lrow">
<span className="tx">
<span className="lb">{t("允许手机访问")}</span>
<span className="ds">
{noNetwork
? t("这台电脑现在没有局域网地址,连上 Wi-Fi 或网线后再试。")
: t("手机和这台电脑在同一个网络里,扫码后就能看会话、发消息、批准操作。关闭或退出 Studio 会断开所有手机。")}
</span>
</span>
<Switch data-action="share.toggle" on={st.open} busy={busy || noNetwork} label={t("允许手机访问")} onClick={() => void toggle()} />
</div>
{st.addresses.length > 1 && (
<label className="rmtf">
<span>{t("网络")}</span>
<select
data-action="share.address"
value={chosen}
disabled={busy || st.open}
onChange={(e) => setIp(e.target.value)}
>
{st.addresses.map((a) => (
<option key={a.ip} value={a.ip}>
{a.kind === "tailnet" ? `${a.ip} · Tailscale` : a.kind === "virtual" ? `${a.ip} · ${a.interface} · ${t("虚拟网卡")}` : `${a.ip} · ${a.interface}`}
</option>
))}
</select>
</label>
)}
{st.open && (
<div className="shareopen">
{offer ? (
<div className="shareqr">
<img src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(offer.qr)}`} alt={t("配对二维码")} width={184} height={184} />
<div className="sharehow">
<span className="lb">{t("用手机相机扫码")}</span>
<span className="ds">{t("二维码只能配对一台手机,{time} 前有效。别把它发到群里或截图外传。", { time: clock(offer.expires) })}</span>
<code dir="ltr">{st.origin}</code>
</div>
</div>
) : (
<button className="rmtadd" data-action="share.offer" disabled={busy} onClick={() => void newCode()}>
{t("显示配对二维码")}
</button>
)}
<div className="sharedevs">
{st.devices.map((d) => (
<div className="sharedev" key={d.id}>
<span className="nm" title={d.name}>{d.name || t("未知设备")}</span>
<span className="st">{t("{time} 配对 · 最近 {seen}", { time: clock(d.pairedAt), seen: clock(d.lastSeen) })}</span>
{confirm === d.id ? (
<span className="rmtconfirm" role="alertdialog">
<span>{t("断开这台手机?它需要重新扫码才能再连上。")}</span>
<button data-action="share.keep" data-target={d.id} onClick={() => setConfirm("")}>{t("取消")}</button>
<button
data-danger=""
data-action="share.revoke"
data-target={d.id}
autoFocus
onClick={() => {
setConfirm("");
void run(async () => setSt(await hub.revokeDevice(d.id)));
}}
>
{t("断开")}
</button>
</span>
) : (
<button className="rmtlnk" data-danger="" data-action="share.ask-revoke" data-target={d.id} onClick={() => setConfirm(d.id)}>
{t("断开")}
</button>
)}
</div>
))}
{st.devices.length === 0 && <p className="rmtempty">{t("还没有手机连上来。")}</p>}
</div>
<p className="sharewarn">
{t("连接走的是局域网里的明文 HTTP:同一个网络里的其他人能看到传输内容。在自己家里可以用,咖啡馆、机场这类公共网络不要开。Tailscale 地址的流量本身是加密的。")}
</p>
</div>
)}
</div>
<ApplyNote id="phone" />
</section>
);
}
@@ -9,6 +9,7 @@ import { ICON, NAV, SECTION_NAME, SETTINGS, settingMatches } from "./prefsnav";
import { Group, SCOPE_SAID } from "./Group";
import type { Section } from "./prefsnav";
import { AddServer } from "./AddServer";
import { PhoneAccess } from "./PhoneAccess";
import { Remotes } from "./Remotes";
import type { HubPort } from "../port/hub";
import type { RemoteHost } from "../port/remote";
@@ -717,6 +718,7 @@ export function Settings({ hub, onError, port, networkPort, networkHost, status,
<Remotes hub={hub} onError={onError} />
</Group>
)}
{at === "remote" && <PhoneAccess hub={hub} onError={onError} />}
{at === "account" && (
<Group id="account"
@@ -220,6 +220,10 @@ export const SETTINGS: SettingEntry[] = [
{ section: "network", anchor: "network", title: "网络", scope: "machine", apply: "immediate", keywords: ["代理", "proxy", "抓取", "超时"] },
{ section: "remote", anchor: "remote", title: "远程", scope: "machine", apply: "immediate", keywords: ["ssh", "机器", "远端工作区"] },
// Machine rather than session: the door is this window's, and a phone that
// walks through it reaches every pane. Nothing is written, so nothing outlives
// the window either.
{ section: "remote", anchor: "phone", title: "手机访问", scope: "machine", apply: "immediate", keywords: ["手机", "扫码", "二维码", "局域网", "配对", "phone", "mobile", "qr"] },
{ section: "account", anchor: "account", title: "账号", scope: "account", apply: "immediate", keywords: ["登录", "社区"] },
{ section: "versions", anchor: "versions", title: "版本", scope: "machine", apply: "immediate", keywords: ["更新", "升级"] },
{ section: "memory", anchor: "memory", title: "记忆", scope: "chosen", apply: "immediate", keywords: ["记住", "忘记", "事实"] },
+1 -1
View File
@@ -23,7 +23,7 @@ const ROUTES = [
"/extensions", "/themes", "/plugins", "/surfaces",
"/fork", "/summarize", "/forget", "/bypass", "/auto-approve-tools",
"/permissions", "/sandbox", "/context", "/storage", "/tray", "/browser", "/browser-host", "/asks", "/update",
"/host", "/notifications",
"/host", "/notifications", "/share", "/pair",
"/slash", "/workspaces", "/welcome", "/usage", "/config", "/studio",
];
+1
View File
@@ -47,6 +47,7 @@ require (
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.58.0
mvdan.cc/sh/v3 v3.14.0
rsc.io/qr v0.2.0
)
require (
+2
View File
@@ -244,3 +244,5 @@ modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
mvdan.cc/sh/v3 v3.14.0 h1:wsPjcfrSXLJomdxwhVZqZ5j5Cue68l549Aj/DCqzbpc=
mvdan.cc/sh/v3 v3.14.0/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
+6 -6
View File
@@ -38,7 +38,7 @@ func accountsBaseURL() string { return strings.TrimSpace(os.Getenv("REASONIX_ACC
// error, and an unreachable identity service is reported as itself so the UI
// never tells a signed-in user to sign in again because a worker was down.
func (s *Server) account(w http.ResponseWriter, r *http.Request) {
if !s.accountAuthAllowed(w) {
if !s.accountAuthAllowed(w, r) {
return
}
token := account.Token()
@@ -67,7 +67,7 @@ func (s *Server) account(w http.ResponseWriter, r *http.Request) {
// exactly as it does for the CLI: it is the client's half of the exchange and
// is worthless without the human approving the user code in a browser.
func (s *Server) accountLogin(w http.ResponseWriter, r *http.Request) {
if !s.accountAuthAllowed(w) {
if !s.accountAuthAllowed(w, r) {
return
}
client, err := s.accountClient()
@@ -93,7 +93,7 @@ func (s *Server) accountLogin(w http.ResponseWriter, r *http.Request) {
// accountPoll answers one poll. The frontend drives the loop so it can show a
// live "waiting" state and cancel; the kernel only stores the token that ends it.
func (s *Server) accountPoll(w http.ResponseWriter, r *http.Request) {
if !s.accountAuthAllowed(w) {
if !s.accountAuthAllowed(w, r) {
return
}
var req struct {
@@ -127,7 +127,7 @@ func (s *Server) accountPoll(w http.ResponseWriter, r *http.Request) {
// accountLogout revokes the session and forgets the token. Revoking remotely is
// best effort: an offline machine must still be able to sign out.
func (s *Server) accountLogout(w http.ResponseWriter, r *http.Request) {
if !s.accountAuthAllowed(w) {
if !s.accountAuthAllowed(w, r) {
return
}
if token := account.Token(); token != "" {
@@ -142,8 +142,8 @@ func (s *Server) accountLogout(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) accountAuthAllowed(w http.ResponseWriter) bool {
if s.grants.accountAuth {
func (s *Server) accountAuthAllowed(w http.ResponseWriter, r *http.Request) bool {
if s.grants.at(r).accountAuth {
return true
}
refuse(w, http.StatusForbidden, "account.signin_disabled", "account sign-in is not enabled for this server", nil)
+2 -2
View File
@@ -31,7 +31,7 @@ func (s *Server) permissions(w http.ResponseWriter, _ *http.Request) {
// ride the same grant as the other host-owned settings: a networked client must
// not be able to widen its own boundary.
func (s *Server) savePermissions(w http.ResponseWriter, r *http.Request) {
if !s.grants.providerEdit {
if !s.grants.at(r).providerEdit {
refuse(w, http.StatusForbidden, "permissions.editing_disabled", "permission editing is not enabled on this server", nil)
return
}
@@ -71,7 +71,7 @@ func (s *Server) sandboxSettings(w http.ResponseWriter, _ *http.Request) {
}
func (s *Server) saveSandboxSettings(w http.ResponseWriter, r *http.Request) {
if !s.grants.providerEdit {
if !s.grants.at(r).providerEdit {
refuse(w, http.StatusForbidden, "sandbox.editing_disabled", "sandbox editing is not enabled on this server", nil)
return
}
+3 -3
View File
@@ -14,8 +14,8 @@ func (s *Server) registerConfigFileRoutes(mux *http.ServeMux) {
// once instead of each panel learning it from its own refused save. It rides
// the edit grant despite only reading: it returns a line of the file as
// written, and the line that stopped the parser may be the one holding a key.
func (s *Server) configProblem(w http.ResponseWriter, _ *http.Request) {
if !s.grants.providerEdit {
func (s *Server) configProblem(w http.ResponseWriter, r *http.Request) {
if !s.grants.at(r).providerEdit {
refuse(w, http.StatusForbidden, "config.editing_disabled", "config editing is not enabled on this server", nil)
return
}
@@ -27,7 +27,7 @@ func (s *Server) configProblem(w http.ResponseWriter, _ *http.Request) {
// provider-edit grant with every other route that writes the config of the
// machine the kernel runs on.
func (s *Server) repairConfigFile(w http.ResponseWriter, r *http.Request) {
if !s.grants.providerEdit {
if !s.grants.at(r).providerEdit {
refuse(w, http.StatusForbidden, "config.editing_disabled", "config editing is not enabled on this server", nil)
return
}
+1 -1
View File
@@ -44,7 +44,7 @@ func (s *Server) contextView() contextView {
// catalogue can answer what that model holds — only whoever bought it can, and
// until they say, the gauge has no denominator and compaction stays off.
func (s *Server) setContextWindow(w http.ResponseWriter, r *http.Request) {
if !s.grants.providerEdit {
if !s.grants.at(r).providerEdit {
refuse(w, http.StatusForbidden, "provider.editing_disabled", "provider editing is not enabled on this server", nil)
return
}
+162
View File
@@ -0,0 +1,162 @@
package serve
import (
"encoding/json"
"errors"
"io"
"io/fs"
"net/http"
"net/url"
"strings"
)
const (
// DeviceCookie carries a paired device's credential.
DeviceCookie = "reasonix_device"
// PairPath is where a device trades a pairing code for its credential.
PairPath = "/pair"
codeDeviceHost = "device.host_rejected"
codeDeviceOrigin = "device.origin_rejected"
codeDeviceUnauthorized = "device.unauthorized"
codeDevicePairing = "device.pairing_invalid"
codeDeviceMisconfig = "device.misconfigured"
deviceCookieMaxAge = 30 * 24 * 60 * 60
)
// DeviceGateOptions is the policy of one listener devices reach.
type DeviceGateOptions struct {
Registry *DeviceRegistry
// Origin is what this listener answers for, scheme and authority only:
// http://192.168.1.20:41234. Requests addressed to any other name are
// refused, which is what stops a rebinding page from borrowing the socket.
Origin string
// Page is the built frontend. Its files are public here, because a device
// that has not paired yet needs the page to pair from.
Page fs.FS
}
// NewDeviceGate guards a kernel reached by paired devices. Every request must
// be addressed to Origin; a state change must name it as its origin; and past
// the page and PairPath, a request must carry a credential the registry knows.
// What passes is marked as device reach, so the window's own grants stay shut.
func NewDeviceGate(next http.Handler, opts DeviceGateOptions) http.Handler {
origin, authority, secure, ok := deviceOrigin(opts.Origin)
if !ok || opts.Registry == nil {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
refuse(w, http.StatusInternalServerError, codeDeviceMisconfig, "this gate was built without an origin and a registry", nil)
})
}
reg := opts.Registry
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.EqualFold(r.Host, authority) {
refuse(w, http.StatusForbidden, codeDeviceHost, "this listener does not answer for that host", nil)
return
}
if !loopbackOriginAllowed(r, origin) {
refuse(w, http.StatusForbidden, codeDeviceOrigin, "that origin may not reach this listener", nil)
return
}
if r.URL.Path == PairPath {
pairDevice(w, r, reg, secure)
return
}
if c, err := r.Cookie(DeviceCookie); err == nil {
if id, ok := reg.Authenticate(c.Value); ok {
next.ServeHTTP(w, r.WithContext(withDeviceReach(r.Context(), id)))
return
}
}
if devicePublicPath(r, opts.Page) {
// Still device reach: the shell a stranger loads must not come back
// holding anything the window's grants would add to it.
next.ServeHTTP(w, r.WithContext(withDeviceReach(r.Context(), "")))
return
}
refuse(w, http.StatusUnauthorized, codeDeviceUnauthorized, "this device is not paired", nil)
})
}
// devicePublicPath is what an unpaired device may load: the page's own files
// and the shell entry points it routes itself. Nothing that answers from the
// kernel's state.
func devicePublicPath(r *http.Request, page fs.FS) bool {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
return false
}
if tokenBootstrapPublicPath(r) {
return true
}
if page == nil {
return false
}
name := strings.Trim(r.URL.Path, "/")
if name == "" || !fs.ValidPath(name) {
return false
}
st, err := fs.Stat(page, name)
return err == nil && !st.IsDir()
}
// pairDevice spends a pairing code. The code rides a JSON body rather than the
// URL, so it never reaches a request line, a log or a referrer.
func pairDevice(w http.ResponseWriter, r *http.Request, reg *DeviceRegistry, secure bool) {
w.Header().Set("Cache-Control", "no-store")
if r.Method != http.MethodPost {
refuse(w, http.StatusMethodNotAllowed, "request.method_not_allowed", "that method is not accepted here", nil)
return
}
if !jsonContentType(r) {
refuse(w, http.StatusUnsupportedMediaType, "request.bad_content_type", "the body must be application/json", nil)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 8<<10)
var body struct {
Code string `json:"code"`
}
dec := json.NewDecoder(r.Body)
if err := dec.Decode(&body); err != nil {
badBody(w)
return
}
if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
badBody(w)
return
}
credential, view, err := reg.Redeem(body.Code, r.UserAgent())
if err != nil {
refuse(w, http.StatusForbidden, codeDevicePairing, "that pairing code is not valid; show a new one and scan again", nil)
return
}
// codeql[go/cookie-secure-not-set] Secure follows the origin's scheme; a LAN listener is plain HTTP.
http.SetCookie(w, &http.Cookie{
Name: DeviceCookie,
Value: credential,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
MaxAge: deviceCookieMaxAge,
})
writeJSON(w, view)
}
func jsonContentType(r *http.Request) bool {
ct := r.Header.Get("Content-Type")
if i := strings.IndexByte(ct, ';'); i >= 0 {
ct = ct[:i]
}
return strings.EqualFold(strings.TrimSpace(ct), "application/json")
}
// deviceOrigin reads an origin a device gate may answer for: http or https,
// an authority with a port, and nothing after it.
func deviceOrigin(raw string) (origin, authority string, secure, ok bool) {
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.Port() == "" ||
(u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.User != nil {
return "", "", false, false
}
return u.Scheme + "://" + u.Host, u.Host, u.Scheme == "https", true
}
+205
View File
@@ -0,0 +1,205 @@
package serve
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"slices"
"strings"
"sync"
"time"
)
const (
// pairingTTL bounds how long a displayed code is worth anything: long
// enough to find a phone, short enough that a photo of the screen is stale.
pairingTTL = 5 * time.Minute
// deviceNameMax caps the label a device is listed under.
deviceNameMax = 80
)
// ErrPairingInvalid is every way a code can fail to pair: unknown, used,
// expired or superseded. The caller learns nothing about which.
var ErrPairingInvalid = errors.New("pairing code is not valid")
// DeviceRegistry holds which devices may reach a kernel and the one pairing
// code that can add another. Only digests are kept: a registry dumped from
// memory hands out no credential. It lives for the process, so a restart
// unpairs every device.
type DeviceRegistry struct {
mu sync.Mutex
now func() time.Time
pending pairingOffer
devices map[string]*pairedDevice
}
// pairingOffer is the code on screen. A zero digest is no offer.
type pairingOffer struct {
digest [sha256.Size]byte
expires time.Time
}
type pairedDevice struct {
id string
name string
digest [sha256.Size]byte
paired time.Time
seen time.Time
}
// DeviceView is a paired device as the host lists it.
type DeviceView struct {
ID string `json:"id"`
Name string `json:"name"`
PairedAt time.Time `json:"pairedAt"`
LastSeen time.Time `json:"lastSeen"`
}
// NewDeviceRegistry returns an empty registry with no code on offer.
func NewDeviceRegistry() *DeviceRegistry {
return newDeviceRegistryAt(time.Now)
}
func newDeviceRegistryAt(now func() time.Time) *DeviceRegistry {
return &DeviceRegistry{now: now, devices: map[string]*pairedDevice{}}
}
// Offer mints the code a new device pairs with, replacing any earlier one.
func (d *DeviceRegistry) Offer() (code string, expires time.Time) {
code = randomSecret()
d.mu.Lock()
defer d.mu.Unlock()
expires = d.now().Add(pairingTTL)
d.pending = pairingOffer{digest: sha256.Sum256([]byte(code)), expires: expires}
return code, expires
}
// Withdraw takes the code off offer without pairing anything.
func (d *DeviceRegistry) Withdraw() {
d.mu.Lock()
d.pending = pairingOffer{}
d.mu.Unlock()
}
// OfferExpires reports when the code on offer lapses, if one is.
func (d *DeviceRegistry) OfferExpires() (time.Time, bool) {
d.mu.Lock()
defer d.mu.Unlock()
if !d.offerLiveLocked() {
return time.Time{}, false
}
return d.pending.expires, true
}
func (d *DeviceRegistry) offerLiveLocked() bool {
return d.pending.digest != [sha256.Size]byte{} && d.now().Before(d.pending.expires)
}
// Redeem spends the code on offer for a device credential. A code pairs once:
// whoever presents it first is the device, and the offer is gone either way.
func (d *DeviceRegistry) Redeem(code, name string) (credential string, view DeviceView, err error) {
sum := sha256.Sum256([]byte(code))
d.mu.Lock()
defer d.mu.Unlock()
if code == "" || !d.offerLiveLocked() || subtle.ConstantTimeCompare(sum[:], d.pending.digest[:]) != 1 {
return "", DeviceView{}, ErrPairingInvalid
}
d.pending = pairingOffer{}
credential = randomSecret()
now := d.now()
dev := &pairedDevice{
id: deviceID(),
name: deviceLabel(name),
digest: sha256.Sum256([]byte(credential)),
paired: now,
seen: now,
}
d.devices[dev.id] = dev
return credential, dev.view(), nil
}
// Authenticate names the device a credential belongs to, and records that it
// was seen.
func (d *DeviceRegistry) Authenticate(credential string) (string, bool) {
if credential == "" {
return "", false
}
sum := sha256.Sum256([]byte(credential))
d.mu.Lock()
defer d.mu.Unlock()
for _, dev := range d.devices {
if subtle.ConstantTimeCompare(sum[:], dev.digest[:]) == 1 {
dev.seen = d.now()
return dev.id, true
}
}
return "", false
}
// Devices lists what is paired, earliest first.
func (d *DeviceRegistry) Devices() []DeviceView {
d.mu.Lock()
out := make([]DeviceView, 0, len(d.devices))
for _, dev := range d.devices {
out = append(out, dev.view())
}
d.mu.Unlock()
slices.SortFunc(out, func(a, b DeviceView) int {
if c := a.PairedAt.Compare(b.PairedAt); c != 0 {
return c
}
return strings.Compare(a.ID, b.ID)
})
return out
}
// Revoke unpairs one device. Its next request is refused.
func (d *DeviceRegistry) Revoke(id string) bool {
d.mu.Lock()
defer d.mu.Unlock()
if _, ok := d.devices[id]; !ok {
return false
}
delete(d.devices, id)
return true
}
// RevokeAll unpairs every device and withdraws the code on offer.
func (d *DeviceRegistry) RevokeAll() {
d.mu.Lock()
d.devices = map[string]*pairedDevice{}
d.pending = pairingOffer{}
d.mu.Unlock()
}
func (p *pairedDevice) view() DeviceView {
return DeviceView{ID: p.id, Name: p.name, PairedAt: p.paired, LastSeen: p.seen}
}
// deviceLabel is display text only; nothing decides on it.
func deviceLabel(name string) string {
name = strings.Join(strings.Fields(name), " ")
if r := []rune(name); len(r) > deviceNameMax {
name = string(r[:deviceNameMax])
}
return name
}
func randomSecret() string {
b := make([]byte, tokenByteLen)
if _, err := rand.Read(b); err != nil {
panic("serve/device: crypto/rand.Read failed: " + err.Error())
}
return base64.RawURLEncoding.EncodeToString(b)
}
func deviceID() string {
b := make([]byte, 6)
if _, err := rand.Read(b); err != nil {
panic("serve/device: crypto/rand.Read failed: " + err.Error())
}
return hex.EncodeToString(b)
}
+238
View File
@@ -0,0 +1,238 @@
package serve
import (
"context"
"errors"
"io/fs"
"log/slog"
"net"
"net/http"
"net/netip"
"slices"
"sync"
"time"
)
// ErrShareAddress refuses an address the window does not own on a private
// network. Binding a wildcard or a public address is not a sharing decision
// this surface makes.
var ErrShareAddress = errors.New("that address is not a private address on this machine")
// ErrShareClosed is an offer asked of a share that is not listening.
var ErrShareClosed = errors.New("sharing is off")
// ErrShareUnattached is a share opened before the host named what it serves.
var ErrShareUnattached = errors.New("sharing has nothing to serve yet")
// tailnetPrefix is the CGNAT range Tailscale hands out; its traffic is already
// encrypted end to end, which makes it the one routed network worth offering.
var tailnetPrefix = netip.MustParsePrefix("100.64.0.0/10")
// DeviceShare is a window's second door: a listener on a private address that
// paired devices reach the window's hub through, behind a device gate. It is
// shut until the person at the window opens it, and shutting it unpairs every
// device.
type DeviceShare struct {
registry *DeviceRegistry
page fs.FS
// addresses lists what may be bound; a variable seam so tests need no NIC.
addresses func() []ShareAddress
// turn serialises Open and Close, so two opens cannot leave one listener
// running that nothing will ever stop.
turn sync.Mutex
mu sync.Mutex
handler http.Handler
live *shareListener
}
type shareListener struct {
origin string
stop context.CancelFunc
done chan struct{}
}
// ShareAddress is one address a share can listen on.
type ShareAddress struct {
Interface string `json:"interface"`
IP string `json:"ip"`
Kind AddressKind `json:"kind"`
}
// AddressKind says what reaches an address, read from the interface itself.
type AddressKind string
const (
// AddressLAN is a broadcast-capable adapter with a hardware address: Wi-Fi
// or Ethernet, which a phone on the same network can reach.
AddressLAN AddressKind = "lan"
// AddressTailnet is a Tailscale address, reachable from anywhere on the
// tailnet and encrypted on the way.
AddressTailnet AddressKind = "tailnet"
// AddressVirtual is anything else: a VPN or proxy tunnel, a VM switch.
// Listed, because it may be what someone wants, but never the default.
AddressVirtual AddressKind = "virtual"
)
var addressRank = map[AddressKind]int{AddressLAN: 0, AddressTailnet: 1, AddressVirtual: 2}
// ShareStatus is the whole state a window draws its sharing panel from.
type ShareStatus struct {
Open bool `json:"open"`
Origin string `json:"origin,omitempty"`
Addresses []ShareAddress `json:"addresses"`
Devices []DeviceView `json:"devices"`
OfferExpires *time.Time `json:"offerExpires,omitempty"`
}
// ShareOffer is a pairing code as a device receives it: a link that carries the
// code in its fragment, and that link drawn as a QR code.
type ShareOffer struct {
URL string `json:"url"`
QR string `json:"qr"`
Expires time.Time `json:"expires"`
}
// NewDeviceShare returns a closed share serving page to devices.
func NewDeviceShare(page fs.FS) *DeviceShare {
return &DeviceShare{registry: NewDeviceRegistry(), page: page, addresses: privateAddresses}
}
// Attach names the handler devices reach. The hub is built after the share it
// registers routes for, so this closes that loop before anything opens.
func (s *DeviceShare) Attach(h http.Handler) {
s.mu.Lock()
s.handler = h
s.mu.Unlock()
}
// Open listens on ip, one of the addresses Status lists, on a port the system
// picks. A share already open is closed first, so its devices go with it.
func (s *DeviceShare) Open(ip string) (ShareStatus, error) {
if !slices.ContainsFunc(s.addresses(), func(a ShareAddress) bool { return a.IP == ip }) {
return s.Status(), ErrShareAddress
}
s.turn.Lock()
defer s.turn.Unlock()
s.closeLocked()
s.mu.Lock()
handler := s.handler
s.mu.Unlock()
if handler == nil {
return s.Status(), ErrShareUnattached
}
ln, err := net.Listen("tcp", net.JoinHostPort(ip, "0"))
if err != nil {
return s.Status(), err
}
origin := "http://" + ln.Addr().String()
ctx, stop := context.WithCancel(context.Background())
live := &shareListener{origin: origin, stop: stop, done: make(chan struct{})}
gate := NewDeviceGate(handler, DeviceGateOptions{Registry: s.registry, Origin: origin, Page: s.page})
s.mu.Lock()
s.live = live
s.mu.Unlock()
go func() {
defer close(live.done)
if err := runGracefulListener(ctx, ln, gate); err != nil {
slog.Warn("serve: device share stopped", "origin", origin, "err", err)
}
}()
return s.Status(), nil
}
// Close stops listening and unpairs every device.
func (s *DeviceShare) Close() {
s.turn.Lock()
defer s.turn.Unlock()
s.closeLocked()
}
func (s *DeviceShare) closeLocked() {
s.mu.Lock()
live := s.live
s.live = nil
s.mu.Unlock()
s.registry.RevokeAll()
if live != nil {
live.stop()
<-live.done
}
}
// Offer mints a pairing code for the open share.
func (s *DeviceShare) Offer() (ShareOffer, error) {
s.mu.Lock()
live := s.live
s.mu.Unlock()
if live == nil {
return ShareOffer{}, ErrShareClosed
}
code, expires := s.registry.Offer()
link := live.origin + "/#pair=" + code
svg, err := QRSVG(link)
if err != nil {
s.registry.Withdraw()
return ShareOffer{}, err
}
return ShareOffer{URL: link, QR: svg, Expires: expires}, nil
}
// Revoke unpairs one device.
func (s *DeviceShare) Revoke(id string) bool { return s.registry.Revoke(id) }
// Status reports what is open, what could be, and who is paired.
func (s *DeviceShare) Status() ShareStatus {
s.mu.Lock()
live := s.live
s.mu.Unlock()
st := ShareStatus{Addresses: s.addresses(), Devices: s.registry.Devices()}
if live != nil {
st.Open, st.Origin = true, live.origin
}
if exp, ok := s.registry.OfferExpires(); ok {
st.OfferExpires = &exp
}
return st
}
// privateAddresses is every private IPv4 address on an up interface, the ones
// a phone is likeliest to reach first: the first entry is the default.
func privateAddresses() []ShareAddress {
ifaces, err := net.Interfaces()
if err != nil {
return nil
}
out := []ShareAddress{}
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, a := range addrs {
pfx, err := netip.ParsePrefix(a.String())
if err != nil {
continue
}
ip := pfx.Addr().Unmap()
if !ip.Is4() {
continue
}
kind := AddressVirtual
switch {
case tailnetPrefix.Contains(ip):
kind = AddressTailnet
case !ip.IsPrivate():
continue
case len(iface.HardwareAddr) > 0 && iface.Flags&net.FlagBroadcast != 0:
kind = AddressLAN
}
out = append(out, ShareAddress{Interface: iface.Name, IP: ip.String(), Kind: kind})
}
}
slices.SortStableFunc(out, func(a, b ShareAddress) int { return addressRank[a.Kind] - addressRank[b.Kind] })
return out
}
@@ -0,0 +1,87 @@
package serve
import (
"encoding/json"
"errors"
"net/http"
)
const (
codeShareAddress = "share.address_rejected"
codeShareClosed = "share.closed"
codeShareListen = "share.listen_failed"
codeShareUnknown = "share.device_unknown"
)
// registerShareRoutes is registered only where a window holds a share, and
// only on the host's side of hostOnly: a paired device cannot mint a code that
// pairs another, nor unpair the window's other devices.
func (h *Hub) registerShareRoutes(mux *http.ServeMux) {
if h.opts.Share == nil {
return
}
mux.HandleFunc("GET /share", h.shareStatus)
mux.HandleFunc("POST /share/open", h.shareOpen)
mux.HandleFunc("POST /share/close", h.shareClose)
mux.HandleFunc("POST /share/offer", h.shareOffer)
mux.HandleFunc("POST /share/revoke", h.shareRevoke)
}
func (h *Hub) shareStatus(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, h.opts.Share.Status())
}
func (h *Hub) shareOpen(w http.ResponseWriter, r *http.Request) {
var body struct {
IP string `json:"ip"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.IP == "" {
missingField(w, "ip")
return
}
st, err := h.opts.Share.Open(body.IP)
switch {
case errors.Is(err, ErrShareAddress):
refuse(w, http.StatusBadRequest, codeShareAddress, err.Error(), map[string]any{"ip": body.IP})
case err != nil:
// The socket is ours to open, so a failure is this machine's, not the
// request's: a port taken, a firewall, an address that went away.
refuse(w, http.StatusInternalServerError, codeShareListen, err.Error(), map[string]any{"ip": body.IP, "error": err.Error()})
default:
writeJSON(w, st)
}
}
func (h *Hub) shareClose(w http.ResponseWriter, _ *http.Request) {
h.opts.Share.Close()
writeJSON(w, h.opts.Share.Status())
}
func (h *Hub) shareOffer(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Cache-Control", "no-store")
offer, err := h.opts.Share.Offer()
switch {
case errors.Is(err, ErrShareClosed):
refuse(w, http.StatusConflict, codeShareClosed, err.Error(), nil)
case err != nil:
writeErr(w, http.StatusInternalServerError, err)
default:
writeJSON(w, offer)
}
}
func (h *Hub) shareRevoke(w http.ResponseWriter, r *http.Request) {
var body struct {
ID string `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ID == "" {
missingField(w, "id")
return
}
if !h.opts.Share.Revoke(body.ID) {
refuse(w, http.StatusNotFound, codeShareUnknown, "no device is paired under that id", map[string]any{"id": body.ID})
return
}
writeJSON(w, h.opts.Share.Status())
}
@@ -0,0 +1,310 @@
package serve
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"time"
"reasonix/internal/base/testenv"
)
func TestPairingCodePairsOnce(t *testing.T) {
reg := NewDeviceRegistry()
code, _ := reg.Offer()
cred, view, err := reg.Redeem(code, "Mozilla/5.0 (iPhone)")
if err != nil {
t.Fatalf("first redeem: %v", err)
}
if id, ok := reg.Authenticate(cred); !ok || id != view.ID {
t.Fatalf("Authenticate = %q %v, want %q", id, ok, view.ID)
}
if _, _, err := reg.Redeem(code, "second"); !errors.Is(err, ErrPairingInvalid) {
t.Fatalf("second redeem err = %v, want ErrPairingInvalid", err)
}
if _, ok := reg.OfferExpires(); ok {
t.Fatal("a spent code is still on offer")
}
}
func TestPairingCodeLapses(t *testing.T) {
now := time.Unix(1_000_000, 0)
reg := newDeviceRegistryAt(func() time.Time { return now })
code, _ := reg.Offer()
now = now.Add(pairingTTL)
if _, _, err := reg.Redeem(code, ""); !errors.Is(err, ErrPairingInvalid) {
t.Fatalf("redeem at expiry err = %v, want ErrPairingInvalid", err)
}
}
func TestANewOfferRetiresTheOldCode(t *testing.T) {
reg := NewDeviceRegistry()
old, _ := reg.Offer()
fresh, _ := reg.Offer()
if _, _, err := reg.Redeem(old, ""); !errors.Is(err, ErrPairingInvalid) {
t.Fatalf("superseded code err = %v, want ErrPairingInvalid", err)
}
if _, _, err := reg.Redeem(fresh, ""); err != nil {
t.Fatalf("current code: %v", err)
}
}
func TestShareRefusesAnAddressItDoesNotList(t *testing.T) {
share := NewDeviceShare(nil)
share.addresses = func() []ShareAddress { return []ShareAddress{{Interface: "lo", IP: "127.0.0.1", Kind: AddressLAN}} }
share.Attach(http.NotFoundHandler())
for _, ip := range []string{"0.0.0.0", "8.8.8.8", ""} {
if _, err := share.Open(ip); !errors.Is(err, ErrShareAddress) {
t.Fatalf("Open(%q) err = %v, want ErrShareAddress", ip, err)
}
}
}
// shareRig is a window's hub with the share open on loopback, which stands in
// for the private address a real share binds.
type shareRig struct {
hub *Hub
share *DeviceShare
window *httptest.Server
origin string
}
func newShareRig(t *testing.T) *shareRig {
t.Helper()
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
page := fstest.MapFS{
"index.html": {Data: []byte("<title>studio</title>")},
"assets/app-1.js": {Data: []byte("console.log(1)")},
}
share := NewDeviceShare(page)
share.addresses = func() []ShareAddress { return []ShareAddress{{Interface: "lo", IP: "127.0.0.1", Kind: AddressLAN}} }
h := NewHub(HubOptions{Page: page, Share: share, Grant: func(s *Server) { s.AllowProviderEdit() }})
hubRuntime(t, h, testenv.TempDir(t))
share.Attach(h.Handler())
window := httptest.NewServer(h.Handler())
t.Cleanup(func() {
window.Close()
share.Close()
h.Shutdown()
})
st, err := share.Open("127.0.0.1")
if err != nil {
t.Fatalf("open share: %v", err)
}
return &shareRig{hub: h, share: share, window: window, origin: st.Origin}
}
// pair scans a fresh code the way the page does and returns the device cookie.
func (r *shareRig) pair(t *testing.T) *http.Cookie {
t.Helper()
offer, err := r.share.Offer()
if err != nil {
t.Fatalf("offer: %v", err)
}
code := offer.URL[strings.Index(offer.URL, "#pair=")+len("#pair="):]
resp := r.device(t, http.MethodPost, PairPath, `{"code":"`+code+`"}`, nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("pair = %d", resp.StatusCode)
}
for _, c := range resp.Cookies() {
if c.Name == DeviceCookie {
if !c.HttpOnly || c.SameSite != http.SameSiteStrictMode {
t.Fatalf("device cookie = %+v, want HttpOnly and SameSite=Strict", c)
}
return c
}
}
t.Fatal("pairing set no device cookie")
return nil
}
func (r *shareRig) device(t *testing.T, method, path, body string, cookie *http.Cookie) *http.Response {
t.Helper()
req, _ := http.NewRequest(method, r.origin+path, strings.NewReader(body))
if method != http.MethodGet {
req.Header.Set("Origin", r.origin)
req.Header.Set("Content-Type", "application/json")
}
if cookie != nil {
req.AddCookie(cookie)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
func deviceRefusal(t *testing.T, resp *http.Response) string {
t.Helper()
defer resp.Body.Close()
var body Reason
_ = json.NewDecoder(resp.Body).Decode(&body)
return body.Code
}
func TestAnUnpairedDeviceGetsThePageButNotTheKernel(t *testing.T) {
rig := newShareRig(t)
for _, path := range []string{"/", "/assets/app-1.js"} {
resp := rig.device(t, http.MethodGet, path, "", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET %s unpaired = %d, want the page", path, resp.StatusCode)
}
}
for _, path := range []string{"/runtimes", "/events", "/history", "/tree"} {
resp := rig.device(t, http.MethodGet, path, "", nil)
if code := deviceRefusal(t, resp); resp.StatusCode != http.StatusUnauthorized || code != codeDeviceUnauthorized {
t.Fatalf("GET %s unpaired = %d %q, want 401 %s", path, resp.StatusCode, code, codeDeviceUnauthorized)
}
}
}
func TestAPairedDeviceDrivesTheWindowsPanes(t *testing.T) {
rig := newShareRig(t)
cookie := rig.pair(t)
resp := rig.device(t, http.MethodGet, "/runtimes", "", cookie)
var views []RuntimeView
_ = json.NewDecoder(resp.Body).Decode(&views)
resp.Body.Close()
if resp.StatusCode != http.StatusOK || len(views) != 1 {
t.Fatalf("GET /runtimes paired = %d %+v, want the window's pane", resp.StatusCode, views)
}
if devs := rig.share.Status().Devices; len(devs) != 1 {
t.Fatalf("Status().Devices = %+v, want the one paired device", devs)
}
}
// A device holds what a networked serve client holds. The window's grants are
// its decision about the person at it, and the share must not carry them over.
func TestAPairedDeviceHoldsNoneOfTheWindowsGrants(t *testing.T) {
rig := newShareRig(t)
cookie := rig.pair(t)
win, err := http.Get(rig.window.URL + "/config/problem")
if err != nil {
t.Fatal(err)
}
win.Body.Close()
if win.StatusCode != http.StatusOK {
t.Fatalf("window GET /config/problem = %d, want the grant to hold", win.StatusCode)
}
resp := rig.device(t, http.MethodGet, "/config/problem", "", cookie)
if code := deviceRefusal(t, resp); resp.StatusCode != http.StatusForbidden || code != "config.editing_disabled" {
t.Fatalf("device GET /config/problem = %d %q, want the grant withheld", resp.StatusCode, code)
}
}
func TestAPairedDeviceCannotReachTheWindowsOwnRoutes(t *testing.T) {
rig := newShareRig(t)
cookie := rig.pair(t)
for _, c := range []struct{ method, path string }{
{http.MethodPost, "/share/offer"},
{http.MethodGet, "/share"},
{http.MethodPost, "/host/pick-folder"},
{http.MethodGet, "/remotes"},
} {
resp := rig.device(t, c.method, c.path, "{}", cookie)
if code := deviceRefusal(t, resp); resp.StatusCode != http.StatusNotFound || code != codeDeviceHostOnly {
t.Fatalf("%s %s from a device = %d %q, want %s", c.method, c.path, resp.StatusCode, code, codeDeviceHostOnly)
}
}
resp := rig.device(t, http.MethodGet, "/provider-setup", "", cookie)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("device GET /provider-setup = %d, want 404", resp.StatusCode)
}
}
func TestTheDeviceGateAnswersOnlyForItsOwnName(t *testing.T) {
rig := newShareRig(t)
cookie := rig.pair(t)
req, _ := http.NewRequest(http.MethodGet, rig.origin+"/runtimes", nil)
req.Host = "attacker.example:80"
req.AddCookie(cookie)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
if code := deviceRefusal(t, resp); code != codeDeviceHost {
t.Fatalf("rebound host = %d %q, want %s", resp.StatusCode, code, codeDeviceHost)
}
req, _ = http.NewRequest(http.MethodPost, rig.origin+"/rt/1/cancel", bytes.NewReader([]byte("{}")))
req.Header.Set("Origin", "http://attacker.example")
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
if code := deviceRefusal(t, resp); code != codeDeviceOrigin {
t.Fatalf("foreign origin = %d %q, want %s", resp.StatusCode, code, codeDeviceOrigin)
}
}
func TestARevokedDeviceIsRefused(t *testing.T) {
rig := newShareRig(t)
cookie := rig.pair(t)
id := rig.share.Status().Devices[0].ID
if !rig.share.Revoke(id) {
t.Fatal("Revoke reported no such device")
}
resp := rig.device(t, http.MethodGet, "/runtimes", "", cookie)
if code := deviceRefusal(t, resp); resp.StatusCode != http.StatusUnauthorized || code != codeDeviceUnauthorized {
t.Fatalf("revoked device = %d %q, want 401", resp.StatusCode, code)
}
}
func TestClosingTheShareUnpairsEveryDevice(t *testing.T) {
rig := newShareRig(t)
cookie := rig.pair(t)
rig.share.Close()
st, err := rig.share.Open("127.0.0.1")
if err != nil {
t.Fatal(err)
}
rig.origin = st.Origin
if len(st.Devices) != 0 {
t.Fatalf("reopened share lists %+v, want no devices", st.Devices)
}
resp := rig.device(t, http.MethodGet, "/runtimes", "", cookie)
if code := deviceRefusal(t, resp); resp.StatusCode != http.StatusUnauthorized || code != codeDeviceUnauthorized {
t.Fatalf("device from before the close = %d %q, want 401", resp.StatusCode, code)
}
}
func TestTheWindowDrivesTheShareOverItsRoutes(t *testing.T) {
rig := newShareRig(t)
resp, err := http.Post(rig.window.URL+"/share/offer", "application/json", strings.NewReader("{}"))
if err != nil {
t.Fatal(err)
}
var offer ShareOffer
_ = json.NewDecoder(resp.Body).Decode(&offer)
resp.Body.Close()
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(offer.URL, rig.origin+"/#pair=") || !strings.HasPrefix(offer.QR, "<svg") {
t.Fatalf("POST /share/offer = %d %+v, want a link on the share's origin and its QR", resp.StatusCode, offer)
}
resp, err = http.Post(rig.window.URL+"/share/close", "application/json", strings.NewReader("{}"))
if err != nil {
t.Fatal(err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
resp, err = http.Post(rig.window.URL+"/share/offer", "application/json", strings.NewReader("{}"))
if err != nil {
t.Fatal(err)
}
if code := deviceRefusal(t, resp); resp.StatusCode != http.StatusConflict || code != codeShareClosed {
t.Fatalf("offer on a closed share = %d %q, want 409 %s", resp.StatusCode, code, codeShareClosed)
}
}
+2 -2
View File
@@ -23,8 +23,8 @@ const (
// openInEditor opens this runtime's workspace, not a path the client names:
// the kernel already knows which directory it is driving, and taking one from
// the request would make this a way to open anything on the machine.
func (s *Server) openInEditor(w http.ResponseWriter, _ *http.Request) {
if !s.grants.editorOpen {
func (s *Server) openInEditor(w http.ResponseWriter, r *http.Request) {
if !s.grants.at(r).editorOpen {
refuse(w, http.StatusForbidden, codeEditorNoWindow, "this kernel has no window to open an editor from", nil)
return
}
@@ -69,7 +69,7 @@ func servedRoutes(t *testing.T) [][]string {
if err != nil {
t.Fatal(err)
}
route := regexp.MustCompile(`mux\.HandleFunc\("(?:\w+ )?(/[^"]*)"`)
route := regexp.MustCompile(`(?:mux|hostMux)\.HandleFunc\("(?:\w+ )?(/[^"]*)"`)
var out [][]string
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
+25 -18
View File
@@ -165,6 +165,9 @@ type HubOptions struct {
// inside one. Nil leaves the update routes unregistered: owning the
// application is what makes replacing it this process's business.
Update UpdateHost
// Share is the window's door for paired devices. Nil leaves its routes
// unregistered: a networked server has no window to open one from.
Share *DeviceShare
}
// OpenRequest asks for a runtime. An empty SessionPath opens a fresh session in
@@ -475,28 +478,32 @@ func (h *Hub) Handler() http.Handler {
mux.HandleFunc("GET /runtimes", h.listRuntimes)
mux.HandleFunc("POST /runtimes", h.openRuntime)
mux.HandleFunc("POST /runtimes/{id}/close", h.closeRuntime)
mux.HandleFunc("POST /host/pick-folder", h.pickLocalFolderHTTP)
mux.HandleFunc("GET /remotes", h.listRemoteHosts)
mux.HandleFunc("POST /remotes", h.saveRemoteHost)
mux.HandleFunc("GET /remotes/candidates", h.remoteCandidates)
mux.HandleFunc("GET /remotes/{host}/tree", h.remoteTree)
mux.HandleFunc("POST /remotes/{host}/sessions/remove", h.removeRemoteSession)
mux.HandleFunc("GET /remotes/{host}/dirs", h.remoteDirs)
mux.HandleFunc("GET /remotes/{host}/probe", h.remoteProbe)
mux.HandleFunc("POST /remotes/{host}/workspaces", h.addRemoteWorkspace)
mux.HandleFunc("POST /remotes/{host}/workspaces/remove", h.removeRemoteWorkspace)
mux.HandleFunc("POST /remotes/remove", h.removeRemoteHost)
mux.HandleFunc("POST /remotes/open", h.openRemoteRuntime)
h.registerTreeRoutes(mux)
h.registerTrayRoutes(mux)
h.registerNotifyRoutes(mux)
h.registerBrowserHostRoutes(mux)
h.registerStudioVersionRoutes(mux)
h.registerUpdateRoutes(mux)
h.registerAskRoutes(mux)
mux.HandleFunc(runtimePrefix+"{id}/", h.routeRuntime)
mux.HandleFunc("/", h.routeDefault)
return logMiddleware(h.auth.middleware(withPage(csrfGuard(mux), h.opts.Page)))
// What acts on the machine behind the window, or dials onward from it,
// stays the window's: a paired device reaches the rest.
hostMux := http.NewServeMux()
hostMux.HandleFunc("POST /host/pick-folder", h.pickLocalFolderHTTP)
hostMux.HandleFunc("GET /remotes", h.listRemoteHosts)
hostMux.HandleFunc("POST /remotes", h.saveRemoteHost)
hostMux.HandleFunc("GET /remotes/candidates", h.remoteCandidates)
hostMux.HandleFunc("GET /remotes/{host}/tree", h.remoteTree)
hostMux.HandleFunc("POST /remotes/{host}/sessions/remove", h.removeRemoteSession)
hostMux.HandleFunc("GET /remotes/{host}/dirs", h.remoteDirs)
hostMux.HandleFunc("GET /remotes/{host}/probe", h.remoteProbe)
hostMux.HandleFunc("POST /remotes/{host}/workspaces", h.addRemoteWorkspace)
hostMux.HandleFunc("POST /remotes/{host}/workspaces/remove", h.removeRemoteWorkspace)
hostMux.HandleFunc("POST /remotes/remove", h.removeRemoteHost)
hostMux.HandleFunc("POST /remotes/open", h.openRemoteRuntime)
h.registerTrayRoutes(hostMux)
h.registerNotifyRoutes(hostMux)
h.registerBrowserHostRoutes(hostMux)
h.registerUpdateRoutes(hostMux)
h.registerAskRoutes(hostMux)
h.registerShareRoutes(hostMux)
return logMiddleware(h.auth.middleware(withPage(csrfGuard(hostOnly(hostMux, mux)), h.opts.Page)))
}
func (h *Hub) listRuntimes(w http.ResponseWriter, _ *http.Request) {
+2 -2
View File
@@ -49,7 +49,7 @@ func (s *Server) registerProviderCheckRoutes(mux *http.ServeMux) {
// "is this key still good, and is it still the protocol we recorded" is one
// button rather than a delete and a re-add.
func (s *Server) checkProvider(w http.ResponseWriter, r *http.Request) {
if !s.grants.providerEdit {
if !s.grants.at(r).providerEdit {
refuse(w, http.StatusForbidden, "provider.editing_disabled", "provider editing is not enabled on this server", nil)
return
}
@@ -116,7 +116,7 @@ type providerModelCheck struct {
// asks for it. A model need not appear in the saved or remote catalog: the
// exact caller-supplied ID is put on the wire and nothing is persisted.
func (s *Server) checkProviderModel(w http.ResponseWriter, r *http.Request) {
if !s.grants.providerEdit {
if !s.grants.at(r).providerEdit {
refuse(w, http.StatusForbidden, "provider.editing_disabled", "provider editing is not enabled on this server", nil)
return
}
+4 -4
View File
@@ -15,7 +15,7 @@ import (
// from the form would drop every field the form cannot show — per-model prices,
// effort vocabularies, context windows, headers, the preset it came from.
func (s *Server) editProvider(w http.ResponseWriter, r *http.Request) {
if !s.grants.providerEdit {
if !s.grants.at(r).providerEdit {
refuse(w, http.StatusForbidden, "provider.editing_disabled", "provider editing is not enabled on this server", nil)
return
}
@@ -256,7 +256,7 @@ func visionSettableOf(cfg *config.Config, p *config.ProviderEntry) []string {
// that reject an unknown thinking/reasoning_effort field fail every request
// until this is off, and the endpoint's own error rarely names the field.
func (s *Server) setProviderThinking(w http.ResponseWriter, r *http.Request) {
if !s.grants.providerEdit {
if !s.grants.at(r).providerEdit {
refuse(w, http.StatusForbidden, "provider.editing_disabled", "provider editing is not enabled on this server", nil)
return
}
@@ -291,7 +291,7 @@ func (s *Server) setProviderThinking(w http.ResponseWriter, r *http.Request) {
// until this is stateless. Declared rather than probed: a retry that happens to
// succeed says nothing about why the first attempt did not.
func (s *Server) setProviderContinuation(w http.ResponseWriter, r *http.Request) {
if !s.grants.providerEdit {
if !s.grants.at(r).providerEdit {
refuse(w, http.StatusForbidden, "provider.editing_disabled", "provider editing is not enabled on this server", nil)
return
}
@@ -329,7 +329,7 @@ func (s *Server) setProviderContinuation(w http.ResponseWriter, r *http.Request)
// tool. It is a real per-entry choice, unlike the protocol: the wire format is
// what makes it available, and this only says whether to use it.
func (s *Server) setProviderWebSearch(w http.ResponseWriter, r *http.Request) {
if !s.grants.providerEdit {
if !s.grants.at(r).providerEdit {
refuse(w, http.StatusForbidden, "provider.editing_disabled", "provider editing is not enabled on this server", nil)
return
}
+10
View File
@@ -105,6 +105,10 @@ func (s *Server) providerSetupSnapshot() (providerSetupState, bool) {
func (s *Server) providerSetupStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if !hostReach(r) {
http.NotFound(w, r)
return
}
setup, ok := s.providerSetupSnapshot()
if !ok {
http.NotFound(w, r)
@@ -115,6 +119,12 @@ func (s *Server) providerSetupStatus(w http.ResponseWriter, r *http.Request) {
func (s *Server) providerSetupSave(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
// The surface opens for the listener the host named, and a device reaches
// the same handler through another one.
if !hostReach(r) {
refuse(w, http.StatusNotFound, codeDeviceHostOnly, "a paired device cannot use this", nil)
return
}
r.Body = http.MaxBytesReader(w, r.Body, providerSetupMaxBody)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
+3 -3
View File
@@ -182,7 +182,7 @@ func (s *Server) providerProtocols(w http.ResponseWriter, _ *http.Request) {
// probeProvider reports what an endpoint turns out to be. It writes nothing:
// the user sees the guesses and confirms them before anything is saved.
func (s *Server) probeProvider(w http.ResponseWriter, r *http.Request) {
if !s.grants.providerEdit {
if !s.grants.at(r).providerEdit {
refuse(w, http.StatusForbidden, "provider.editing_disabled", "provider editing is not enabled on this server", nil)
return
}
@@ -235,7 +235,7 @@ func (s *Server) probeProvider(w http.ResponseWriter, r *http.Request) {
// store, never into the config file — a config is something a user pastes into
// an issue.
func (s *Server) saveProvider(w http.ResponseWriter, r *http.Request) {
if !s.grants.providerEdit {
if !s.grants.at(r).providerEdit {
refuse(w, http.StatusForbidden, "provider.editing_disabled", "provider editing is not enabled on this server", nil)
return
}
@@ -316,7 +316,7 @@ func (s *Server) saveProvider(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) removeProvider(w http.ResponseWriter, r *http.Request) {
if !s.grants.providerEdit {
if !s.grants.at(r).providerEdit {
refuse(w, http.StatusForbidden, "provider.editing_disabled", "provider editing is not enabled on this server", nil)
return
}
+33
View File
@@ -0,0 +1,33 @@
package serve
import (
"fmt"
"strings"
"rsc.io/qr"
)
// qrQuiet is the light border a reader needs around the symbol, in modules.
const qrQuiet = 4
// QRSVG draws text as a QR code: one path of unit squares, dark on light, so it
// scans the same in either theme.
func QRSVG(text string) (string, error) {
code, err := qr.Encode(text, qr.M)
if err != nil {
return "", err
}
side := code.Size + 2*qrQuiet
var b strings.Builder
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" shape-rendering="crispEdges">`, side, side)
fmt.Fprintf(&b, `<rect width="%d" height="%d" fill="#fff"/><path fill="#000" d="`, side, side)
for y := range code.Size {
for x := range code.Size {
if code.Black(x, y) {
fmt.Fprintf(&b, "M%d %dh1v1h-1z", x+qrQuiet, y+qrQuiet)
}
}
}
b.WriteString(`"/></svg>`)
return b.String(), nil
}
+58
View File
@@ -0,0 +1,58 @@
package serve
import (
"context"
"net/http"
)
// codeDeviceHostOnly refuses a paired device a capability that belongs to the
// window: the device holds what a networked serve client holds, and no more.
// It rides a 404, which a frontend already reads as a kernel with no window.
const codeDeviceHostOnly = "device.host_only"
type deviceReachKey struct{}
// withDeviceReach marks a request as having arrived through a device gate. Only
// that gate sets it, so a request without the mark reached the kernel through
// the host's own boundary.
func withDeviceReach(ctx context.Context, deviceID string) context.Context {
return context.WithValue(ctx, deviceReachKey{}, deviceID)
}
// DeviceOf names the paired device a request came from, if it came from one.
func DeviceOf(ctx context.Context) (string, bool) {
id, ok := ctx.Value(deviceReachKey{}).(string)
return id, ok
}
// hostReach reports whether r carries the authority of the host that opened
// this listener rather than a device paired with it.
func hostReach(r *http.Request) bool {
_, device := DeviceOf(r.Context())
return !device
}
// at is what the host opened, as seen by r. A grant is a window's decision
// about its own client, so a device reaching the same kernel holds none of it.
func (g hostGrants) at(r *http.Request) hostGrants {
if !hostReach(r) {
return hostGrants{}
}
return g
}
// hostOnly serves the routes a window registers for itself and refuses them to
// a paired device; everything else goes to rest.
func hostOnly(host *http.ServeMux, rest http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, pattern := host.Handler(r); pattern == "" {
rest.ServeHTTP(w, r)
return
}
if !hostReach(r) {
refuse(w, http.StatusNotFound, codeDeviceHostOnly, "a paired device cannot use this", nil)
return
}
host.ServeHTTP(w, r)
})
}
+1 -1
View File
@@ -47,7 +47,7 @@ func (s *Server) roles(w http.ResponseWriter, _ *http.Request) {
// write the configuration of the machine running the kernel, which is the one
// thing a server reachable over a network must not let a client do.
func (s *Server) setRole(w http.ResponseWriter, r *http.Request) {
if !s.grants.providerEdit {
if !s.grants.at(r).providerEdit {
refuse(w, http.StatusForbidden, "roles.editing_disabled", "role editing is not enabled on this server", nil)
return
}
+1 -1
View File
@@ -21,7 +21,7 @@ func (s *Server) shellSettings(w http.ResponseWriter, _ *http.Request) {
// the machine running the kernel — a networked client must not pick which
// program the agent's commands are handed to.
func (s *Server) saveShellSettings(w http.ResponseWriter, r *http.Request) {
if !s.grants.providerEdit {
if !s.grants.at(r).providerEdit {
refuse(w, http.StatusForbidden, "shell.editing_disabled", "shell editing is not enabled on this server", nil)
return
}
+2 -2
View File
@@ -128,7 +128,7 @@ func (s *Server) storage(w http.ResponseWriter, r *http.Request) {
VolumeFree: root.Volume.Free, VolumeTotal: root.Volume.Total,
})
}
body := map[string]any{"roots": out, "editable": s.grants.providerEdit}
body := map[string]any{"roots": out, "editable": s.grants.at(r).providerEdit}
if dir, names := storage.LeftBehind(); len(names) > 0 {
body["leftBehind"] = map[string]any{"dir": dir, "names": names}
}
@@ -157,7 +157,7 @@ func (s *Server) storagePlan(w http.ResponseWriter, r *http.Request) {
// reports the rest through GET /storage: a move copies gigabytes, and holding
// the request open for it would leave the panel unable to say anything.
func (s *Server) storageMove(w http.ResponseWriter, r *http.Request) {
if !s.grants.providerEdit {
if !s.grants.at(r).providerEdit {
refuse(w, http.StatusForbidden, "storage.moving_disabled", "moving data is not enabled on this server", nil)
return
}
+3 -3
View File
@@ -148,8 +148,8 @@ func (s *Server) workspaces(w http.ResponseWriter, r *http.Request) {
Isolated bool `json:"isolated,omitempty"`
}{
Current: current,
Switch: s.grants.workspaceSwitch,
Isolate: s.grants.workspaceSwitch && worktree.Inspect(r.Context(), current).Available,
Switch: s.grants.at(r).workspaceSwitch,
Isolate: s.grants.at(r).workspaceSwitch && worktree.Inspect(r.Context(), current).Available,
Recents: recents,
Isolated: worktree.IsManagedPath(current, config.DeliveryWorktreeDir()),
})
@@ -159,7 +159,7 @@ func (s *Server) workspaces(w http.ResponseWriter, r *http.Request) {
// deliberately not carried: the transcript, the memory index, and the skill set
// all belong to the project that produced them.
func (s *Server) workspace(w http.ResponseWriter, r *http.Request) {
if !s.grants.workspaceSwitch {
if !s.grants.at(r).workspaceSwitch {
refuse(w, http.StatusForbidden, "workspace.changing_disabled", "this server may not change its workspace", nil)
return
}
+7
View File
@@ -21,6 +21,7 @@ const (
tsModelFile = "desktop/frontend-next/src/port/model.ts"
tsMcpFile = "desktop/frontend-next/src/port/mcp.ts"
tsShellFile = "desktop/frontend-next/src/port/shell.ts"
tsShareFile = "desktop/frontend-next/src/port/share.ts"
)
// mirroredWireTypes are the Go types the desktop keeps a second, hand-written
@@ -57,6 +58,12 @@ var mirroredWireTypes = []wireMirror{
// the kernel side and not on this one, so a reopened session drew
// use_capability where the live one drew what it reached.
{"internal/frontend/serve/history.go", "historyToolCall", tsSessionFile, "HistoryToolCall"},
// Who can reach this window from another device. A device the page cannot
// list is one the person at the window cannot see to disconnect.
{"internal/frontend/serve/device_share.go", "ShareStatus", tsShareFile, "ShareStatus"},
{"internal/frontend/serve/device_share.go", "ShareAddress", tsShareFile, "ShareAddress"},
{"internal/frontend/serve/device_share.go", "ShareOffer", tsShareFile, "ShareOffer"},
{"internal/frontend/serve/device_registry.go", "DeviceView", tsShareFile, "PairedDevice"},
{"internal/session/control/boundary.go", "SandboxSettings", tsBoundaryFile, "SandboxSettings"},
// The MCP row: a status the host answered with and the page cannot read is a
// row that goes back to reading the server's own prose for it.