Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa

## [Unreleased]

### Added
- **Shared settings and pins.** The browsers of one server, a PC's and a phone's, keep the same
pinned agents and the same settings that do not depend on the device: theme and colors, language,
the sidebar's rows, order, cards and pins, chat and usage-meter choices. A
change shows on the other open pages at once. Each device keeps its own terminal and chat font
sizes, key bar, shortcuts, density, alerts, microphone, the lens panes open in and the like. Opening a page never
overwrites anything: a server that holds no shared settings yet takes them from the first change,
and the browser that shares first brings its pins along. A page that lost its connection catches up
when it is back. A watching device follows the shared settings and cannot change them for everyone. ([#854](https://github.com/devswha/herdr-web-ui/pull/854))

## [0.4.8] - 2026-10-12

### Added
Expand Down
1 change: 1 addition & 0 deletions scripts/ci-browser.sh
Original file line number Diff line number Diff line change
Expand Up @@ -91,5 +91,6 @@ run_script scripts/narrow-pane-demo-regression.ts
run_script scripts/touch-long-press-demo-regression.ts
run_script scripts/phone-split-grid-demo-regression.ts
run_script scripts/recent-folders-regression.ts
run_script scripts/preferences-sync-regression.ts
run_script scripts/machine-dialog-regression.ts
run_script scripts/machine-conflict-regression.ts
2 changes: 1 addition & 1 deletion scripts/ci-browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe("browser lane", () => {
it("summarizes every script and exits zero on success without enabling local evidence", async () => {
const { text, code, evidence } = await lane();
expect(code).toBe(0);
expect(rows(text)).toHaveLength(38);
expect(rows(text)).toHaveLength(39);
expect(rows(text).map((row) => row.script)).toContain("scripts/agy-chat-browser-qa.ts");
expect(rows(text).map((row) => row.script)).toContain("scripts/pending-pane-switch-regression.ts");
expect(rows(text).map((row) => row.script)).toContain("scripts/chat-fallback-browser-qa.ts");
Expand Down
130 changes: 130 additions & 0 deletions scripts/preferences-sync-regression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* Two browsers of one server keep the shared settings and pins the same (lib/preferencesSync.ts):
* a theme changed in one shows in the other at once, the other's terminal font (this device's)
* does not move and is never sent, the first browser to share carries its pins along and pins
* written elsewhere reach an open page, a change from elsewhere that lands while one of this
* browser's is on its way is kept, a page whose GET fails still catches up from the event stream,
* and opening a page on an empty server sends nothing. No herdr session is opened.
*/
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { chromium, type BrowserContext, type Page } from "playwright-core";

await import("./test-herdr.ts");
const { createServer } = await import("../server/index.ts");
const { UsageService } = await import("../server/usage.ts");

const root = mkdtempSync(join(tmpdir(), "herdr-preferences-sync-"));
const server = createServer({ port: 0, hostname: "127.0.0.1", token: "", stateDir: join(root, "state"), usage: new UsageService(undefined, []) });
const origin = `http://127.0.0.1:${server.port}`;

async function until(check: () => Promise<boolean>, label: string): Promise<void> {
const deadline = Date.now() + 10_000;
while (!(await check())) {
assert.ok(Date.now() < deadline, label);
await Bun.sleep(50);
}
}

interface Opened { context: BrowserContext; page: Page; puts: { settings: Record<string, unknown>; pins?: string[] }[] }

/** a browser of its own with `own` in its settings and `pins` pinned; `puts` collects what it sends to /api/preferences */
async function browserAt(browser: Awaited<ReturnType<typeof chromium.launch>>, own: Record<string, unknown> = {}, pins: string[] = [], before?: (context: BrowserContext) => Promise<void>): Promise<Opened> {
const context = await browser.newContext({ viewport: { width: 1280, height: 860 }, locale: "en-US" });
await context.addInitScript(([own, pins]) => {
if (!localStorage.getItem("herdr-web-ui:settings")) localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en", theme: "dark", terminalFontSize: 13, ...own }));
if (!localStorage.getItem("herdr-web-ui:sidebar-agent-pins")) localStorage.setItem("herdr-web-ui:sidebar-agent-pins", JSON.stringify(pins));
}, [own, pins] as const);
await before?.(context);
const puts: Opened["puts"] = [];
const page = await context.newPage();
page.on("request", (request) => { if (request.method() === "PUT" && request.url().endsWith("/api/preferences")) puts.push(request.postDataJSON()); });
await page.goto(origin);
await page.locator(".conn-live").waitFor({ state: "attached" });
return { context, page, puts };
}

const theme = (page: Page) => page.evaluate(() => document.documentElement.dataset["theme"]);
const stored = (page: Page) => page.evaluate(() => JSON.parse(localStorage.getItem("herdr-web-ui:settings") ?? "{}") as Record<string, unknown>);
const storedPins = (page: Page) => page.evaluate(() => JSON.parse(localStorage.getItem("herdr-web-ui:sidebar-agent-pins") ?? "[]") as string[]);
const held = async () => await (await fetch(`${origin}/api/preferences`)).json() as { revision: number; settings: Record<string, unknown>; pins?: string[] };
const write = (page: Page, body: object) => page.evaluate(async (body) => (await fetch("/api/preferences", { method: "PUT", headers: { "content-type": "application/json" }, body })).status, JSON.stringify(body));

try {
const browser = await chromium.launch({ executablePath: process.env["CHROME_PATH"] ?? "/opt/google/chrome/chrome", headless: true, args: ["--no-sandbox"] });
try {
// A has pinned an agent before this release; B has not. The pane need not exist here: a pin of a
// pane this page cannot see stays in the list (a PC still loading would look the same)
const a = await browserAt(browser, {}, ["local:w1:pa"]);
const b = await browserAt(browser);
await Bun.sleep(1_500);
assert.deepEqual([...a.puts, ...b.puts], [], "opening a page on an empty server sends nothing");
assert.deepEqual(await held(), { revision: 0, settings: {} }, "the server holds nothing, pins included");

// A turns light: B follows while open, only the shared setting went out, and A's pins went with
// the first change since the server held none
await a.page.goto(`${origin}/?settings=appearance`);
await a.page.getByRole("group", { name: "Theme", exact: true }).getByRole("button", { name: "Light", exact: true }).click();
await until(async () => await theme(b.page) === "light", "B takes A's theme without a reload");
assert.deepEqual(a.puts.map((body) => Object.keys(body.settings)), [["theme"]], "only the setting that changed is sent");
assert.deepEqual(a.puts[0]?.pins, ["local:w1:pa"], "the first change carries this browser's pins");
await until(async () => JSON.stringify(await storedPins(b.page)) === JSON.stringify(["local:w1:pa"]), "B takes A's pins");

// A's terminal font is A's own: B keeps 13
await a.page.goto(`${origin}/?settings=terminal`);
await a.page.getByRole("button", { name: "Increase terminal font size", exact: true }).click();
await until(async () => (await stored(a.page))["terminalFontSize"] === 14, "A keeps its new font");
await Bun.sleep(1_500);
assert.equal(a.puts.length, 1, "a device setting is never sent");
assert.equal((await stored(b.page))["terminalFontSize"], 13, "B's terminal font does not move");

// pins written by another browser reach an open page, and stay (the last write wins now)
assert.equal(await write(b.page, { settings: {}, pins: ["local:w9:p1"] }), 200);
await until(async () => JSON.stringify(await storedPins(a.page)) === JSON.stringify(["local:w9:p1"]), "A takes the pins written elsewhere");

// a change from elsewhere that lands while A's own is on its way is kept, and A's still arrives
await a.page.route("**/api/preferences", async (route) => {
if (route.request().method() === "PUT") await Bun.sleep(1_500);
await route.continue();
});
await a.page.goto(`${origin}/?settings=appearance`);
await a.page.getByRole("group", { name: "Theme", exact: true }).getByRole("button", { name: "Dark", exact: true }).click();
await Bun.sleep(900);
assert.equal(a.puts.length, 2, "A's theme is on its way");
assert.equal(await write(b.page, { settings: { palette: "forest" } }), 200);
await until(async () => (await stored(a.page))["palette"] === "forest", "A takes B's palette while its own change is in flight");
await until(async () => { const now = await held(); return now.settings["theme"] === "dark" && now.settings["palette"] === "forest"; }, "the server holds both changes");
assert.equal((await stored(a.page))["theme"], "dark", "A keeps its own change");
await until(async () => await theme(b.page) === "dark", "B takes A's theme");
await Bun.sleep(1_500);
assert.equal(a.puts.length, 2, "A sends nothing back for what it took");
await a.page.unrouteAll({ behavior: "wait" });

// a page opened later starts from what the server holds, and keeps a shared setting nobody changed
const c = await browserAt(browser, { showThinking: false });
await until(async () => await theme(c.page) === "dark" && (await stored(c.page))["palette"] === "forest", "a new page takes the shared theme and palette");
assert.equal((await stored(c.page))["terminalFontSize"], 13, "and keeps its own terminal font");
assert.equal((await stored(c.page))["showThinking"], false, "and a shared setting nobody changed stays its own");
assert.deepEqual(await storedPins(c.page), ["local:w9:p1"], "and takes the shared pins");

// a page whose GET fails catches up from the event stream, which opens with the document
const d = await browserAt(browser, {}, [], async (context) => {
await context.route("**/api/preferences", async (route) => {
if (route.request().method() === "GET") await route.abort();
else await route.continue();
});
});
await until(async () => (await stored(d.page))["palette"] === "forest", "a page without the GET takes the document from the event stream");
await Bun.sleep(1_500);
assert.deepEqual([...c.puts, ...d.puts], [], "pages opened later send nothing");
for (const opened of [a, b, c, d]) await opened.context.close();
console.log("PASS shared settings and pins follow across browsers, device settings stay, concurrent changes both land, an empty server is never written on load");
} finally {
await browser.close();
}
} finally {
server.stop(true);
rmSync(root, { recursive: true, force: true });
}
1 change: 1 addition & 0 deletions server/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ Bun backend: HTTP API + WebSocket fan-out over herdr's unix-socket API, plus the
| What an update brings | release-notes.ts | CHANGELOG.md sections plus `release-summaries.json`, newest first and bounded; `## [Unreleased]` is no release, and another level-two heading belongs to the notes it sits in |
| Agent stores beyond claude/codex/omo | gjc-runtime.ts, pi.ts, claude-store.ts, omp.ts (transcript-records.ts and skill-activity.ts parse what they find) | each store lookup answers null rather than guessing which transcript a pane writes. pi's store moves with `PI_CODING_AGENT_SESSION_DIR` or `PI_CODING_AGENT_DIR`, and a session file is not written until the first answer, so an absent path can still be the pane's. gjc on Windows is found through the PC's process table, shared between polls for PROCESS_TABLE_MS; an empty table is a failed read, not an answer |
| Paired devices | devices.ts + /api/devices* in index.ts | six-digit code valid ten minutes, one per pairing; the device's token is stored only as a hash in devices.json (0600, written whole), and revoking also closes that device's live streams |
| Shared preferences | preferences.ts + /api/preferences in index.ts; shared/preferences.ts | the settings every browser of this server shares (`SHARED_SETTING_KEYS`) and the sidebar pins, one document in preferences.json (0600, written whole) with a `revision` counter; a PUT carries the settings that changed (laid over the held ones, last write wins per setting) and the pins when they changed, and keeps only the shared names, at most 500 pins and 64 KB; a watching device reads it and is refused a PUT by the general read-only gate; each write goes to every open browser as a `preferences` event on /api/machines/events |

- Transcript caches are keyed by file, so a pane's chat is remembered against what it read and `forgetPaneTranscriptState(paneId)` (conversation.ts) drops it from every reader (pi-tree, codex, claude-store, gjc-runtime, opencode) when the pane closes or ends. `forgetTranscriptState()` is the test-suite wipe and clears the same state plus the pane index. Any new transcript cache joins one of those two.
- The shell and every static file carry the app's Content-Security-Policy from `serveStatic` (static.ts); `/api/fs/*` answers carry file-view.ts's own stricter one: scripts and connections are `'self'` only, styles carry `'unsafe-inline'` for the measured layout attributes and KaTeX. `HERDR_WEB_CSP=report-only` measures it without enforcing. A new third-party resource needs a directive there and a reason in the comment.
3 changes: 3 additions & 0 deletions server/device-access.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ it("watch credentials cannot mutate HTTP state, read credential files, or elevat
for (const path of ["pane/input", "workspace/create", "tab/create", "tab/rename", "tab/close", "machines/setup", "updates/install", "herdr/update"]) {
expect((await fetch(`${base}/api/${path}`, { method: "POST", headers, body: "{}" })).status).toBe(403);
}
// the shared settings: a watching device reads them, and cannot change them for every browser
expect((await fetch(`${base}/api/preferences`, { headers })).status).toBe(200);
expect((await fetch(`${base}/api/preferences`, { method: "PUT", headers, body: JSON.stringify({ settings: { theme: "light" }, pins: [] }) })).status).toBe(403);
expect((await fetch(`${base}/api/fs/file?path=${encodeURIComponent(join(root, "devices.json"))}`, { headers })).status).toBe(403);
// a PC's file is read without the origin check (#448, Android's Open button): the watch refusal still comes first, for GET and HEAD alike
for (const method of ["GET", "HEAD"]) {
Expand Down
6 changes: 5 additions & 1 deletion server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { DEFAULT_PORT } from "../shared/protocol.ts";
import { DEVICE_COOKIE, authClient, handleAuthRequest, isAuthenticated, parseCookies, presentedToken, presentedTokenHeld, recordPresentedTokenFailure, requiresAuth, unauthorizedJson } from "./auth.ts";
import { cameThroughProxy, decideAccess, isLoopbackAddress, isServeOwnerRequest } from "./access.ts";
import { DeviceStore, handleDeviceRequest } from "./devices.ts";
import { handlePreferencesRequest, PreferenceStore } from "./preferences.ts";
import { remoteAccess, TailnetIdentitySource } from "./tailscale.ts";
import { paneCommands } from "./commands.ts";
import { paneFiles } from "./files.ts";
Expand Down Expand Up @@ -525,6 +526,7 @@ export function createServer(
const token = options.token ?? process.env["HERDR_WEB_TOKEN"] ?? "";
/** paired devices (server/devices.ts) and the PC's Tailscale login: the two ways in besides the token and this PC itself */
const devices = new DeviceStore(options.stateDir ?? defaultStateDir());
const preferences = new PreferenceStore(options.stateDir ?? defaultStateDir());
const usage = options.usage ?? new UsageService();
const voice = options.voice ?? new VoiceService({ stateDir: options.stateDir ?? defaultStateDir(), env: process.env, fetch });
/** a login named here is taken as it is: a tagged node has none of its own to read (HERDR_WEB_TAILSCALE_OWNER) */
Expand Down Expand Up @@ -1698,7 +1700,7 @@ export function createServer(
// a worktree made with an agent waits on git and then agent.start, up to 150 s on the PC
bunServer.timeout(request, pathname === "/api/machines/events" ? 0 : pathname.endsWith("/worktree/create") ? 180 : 80);
const deviceId = access.level === "full" ? access.device?.id : undefined;
const response = await handleMachineRequest(request, machines, deviceId ? (close) => devices.onRevoke(deviceId, close) : undefined);
const response = await handleMachineRequest(request, machines, deviceId ? (close) => devices.onRevoke(deviceId, close) : undefined, preferences);
response.headers.set("cache-control", "no-store");
return response;
}
Expand Down Expand Up @@ -1742,6 +1744,8 @@ export function createServer(
}
if (pathname === "/api/herdr/update") return handleHerdrUpdateRequest(request, options.herdrUpdate);
if (pathname === "/api/telemetry") return handleTelemetryRequest(request, options.telemetry);
// what every browser of this server shares; a watching device reads it and cannot change it (above)
if (pathname === "/api/preferences") return handlePreferencesRequest(request, preferences);

if (pathname === "/api/usage") return handleUsageRequest(request, url, usage);
// a long clip can keep the provider silent past Bun's 10 s idle limit before the first line
Expand Down
Loading
Loading