Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
2f655ba
fix(updates): keep a fresh 0.4.14 install on its state dir after an u…
Oct 25, 2026
8cf5610
fix(state): take over the paired devices, preferences and push of the…
Oct 25, 2026
5133b77
fix(state): keep the merge fail-closed and revocations kept through p…
Oct 25, 2026
09e86ad
fix(state): resolve the state dir once, and keep merges right through…
Oct 25, 2026
de517ef
fix(state): keep the gate closed by a placeholder registry in the oth…
Oct 25, 2026
8bf434d
fix(state): find the other dir by real path, and keep merges right af…
Oct 25, 2026
7b3d9bd
fix(state): narrow the role of a device whose credential differs betw…
Oct 25, 2026
e306e28
fix(state): keep the gate closed when both registries are gone after …
Oct 25, 2026
4867115
fix(state): survive a crash between publishing the registries and the…
Oct 25, 2026
d1c3c34
fix(state): leave a closed gate in the other dir when the merged regi…
Oct 25, 2026
bfeadda
fix(updates): make the old state dir's link first, and never strand a…
Oct 25, 2026
1a247b3
fix(updates): link an old state dir that holds only the merge's copies
Oct 25, 2026
43fdddc
fix(updates): link the default state dirs only from a process that us…
Oct 25, 2026
1387489
fix(state): know from the prepared files whether a merge published ea…
Oct 25, 2026
69f765f
fix(updates): never leave the old state dir's name free when its link…
Oct 25, 2026
ed8e8b5
fix(updates): keep the old name taken when an interrupted link cannot…
Oct 25, 2026
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.d/update-state-handover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
### Fixed
- An install made fresh with 0.4.14 or 0.4.15 no longer opens its sign-in gate after an update or a
restart. Such an install moved to the state folder from before the rename, which held none of its
paired devices, so any device, and anyone who could reach the address, got in without pairing;
its shared settings were reset too. Ovis now uses the right folder, and takes the paired devices,
shared settings and alert subscriptions of the other folder over into it, so devices paired before
or after the update keep working and a revoked device stays revoked. A device list that cannot be
read keeps the gate closed.
- An in-app update from such an install now stays on the new version after it restarts, instead of
going back to the old one and offering the same update again.
69 changes: 68 additions & 1 deletion server/device-access.contract.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { expect, it, spyOn } from "bun:test";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DEVICE_COOKIE } from "./auth.ts";
import { DeviceStore } from "./devices.ts";
import { privateStateFs } from "./private-state.ts";
import { MachineRelay } from "./machine-relay.ts";
Expand Down Expand Up @@ -40,6 +42,71 @@ async function until(check: () => boolean): Promise<void> {
}
}

it("keeps the gate closed, and a device paired in the install's other state dir signed in, on a fresh state dir", async () => {
const root = mkdtempSync(join(tmpdir(), "herdr-device-access-"));
// an update from a fresh 0.4.14 install starts on the old dir, while the phone was paired in the new one
const [active, sibling] = [join(root, "herdr-web-ui"), join(root, "ovis")];
for (const dir of [active, sibling]) mkdirSync(dir);
const token = "7".repeat(64);
const created = "2026-10-25T05:20:00.000Z";
writeFileSync(join(sibling, "devices.json"), JSON.stringify({ gate_closed_at: created, devices: [{ id: "0123456789abcdef", label: "Phone", role: "drive", token_hash: createHash("sha256").update(token).digest("hex"), created_at: created, last_seen_at: null }] }));
writeFileSync(join(sibling, "preferences.json"), JSON.stringify({ revision: 1, settings: { language: "en", sidebarRows: "two" } }));
const server = createServer({ port: 0, stateDir: active, siblingStateDir: sibling, tailscaleOwner: null });
const base = `http://127.0.0.1:${server.port}`;
// a request through a proxy on this PC stands for one from another device
const remote = { "x-forwarded-for": "100.64.0.9" };
try {
expect((await fetch(`${base}/api/preferences`, { headers: remote })).status).toBe(401);
const anonymous = await (await fetch(`${base}/api/health`, { headers: remote })).json() as { auth: unknown };
expect(anonymous.auth).toMatchObject({ required: true, authenticated: false });
const cookie = `${DEVICE_COOKIE}=${token}`;
const paired = await (await fetch(`${base}/api/health`, { headers: { ...remote, cookie } })).json() as { auth: unknown };
expect(paired.auth).toMatchObject({ authenticated: true, via: "device" });
const preferences = await fetch(`${base}/api/preferences`, { headers: { ...remote, cookie } });
expect(await preferences.json()).toMatchObject({ settings: { language: "en", sidebarRows: "two" } });
} finally {
server.stop();
rmSync(root, { recursive: true, force: true });
}
});

it("serves the state dir it merged into, even when the merge gave the old dir files of its own", async () => {
const root = mkdtempSync(join(tmpdir(), "herdr-device-access-"));
// the old name holds only the bridge registry; the phone is paired in the new dir
const [legacy, next] = [join(root, "herdr-web-ui"), join(root, "ovis")];
mkdirSync(join(legacy, "bridges"), { recursive: true });
writeFileSync(join(legacy, "bridges", "local.json"), "{}");
mkdirSync(next);
const token = "8".repeat(64);
const created = "2026-10-25T05:20:00.000Z";
writeFileSync(join(next, "devices.json"), JSON.stringify({ gate_closed_at: created, devices: [{ id: "0123456789abcdef", label: "Phone", role: "drive", token_hash: createHash("sha256").update(token).digest("hex"), created_at: created, last_seen_at: null }] }));
const env = { XDG_CONFIG_HOME: process.env["XDG_CONFIG_HOME"], OVIS_STATE_DIR: process.env["OVIS_STATE_DIR"] };
process.env["XDG_CONFIG_HOME"] = root;
delete process.env["OVIS_STATE_DIR"];
// the copy of the registry for the old dir fails; its merge record does not, and gives it files of its own
const rename = privateStateFs.renameSync;
const failing = spyOn(privateStateFs, "renameSync").mockImplementation((from, to) => {
if (to === join(legacy, "devices.json")) throw Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" });
rename(from, to);
});
let server: ReturnType<typeof createServer> | undefined;
try {
server = createServer({ port: 0, siblingStateDir: legacy, tailscaleOwner: null, machines: false });
const base = `http://127.0.0.1:${server.port}`;
const remote = { "x-forwarded-for": "100.64.0.9" };
expect((await fetch(`${base}/api/preferences`, { headers: remote })).status).toBe(401);
expect((await fetch(`${base}/api/preferences`, { headers: { ...remote, cookie: `${DEVICE_COOKIE}=${token}` } })).status).toBe(200);
} finally {
failing.mockRestore();
server?.stop();
for (const [name, value] of Object.entries(env)) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
rmSync(root, { recursive: true, force: true });
}
});

it("revocation closes an active device's terminal and roster stream without disconnecting another device", async () => {
const root = mkdtempSync(join(tmpdir(), "herdr-device-access-"));
const server = createServer({ port: 0, stateDir: root, token: "test-device-access", tailscaleOwner: null });
Expand Down
55 changes: 39 additions & 16 deletions server/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const LAST_SEEN_WRITE_MS = 60_000;
const UNCONFIRMED_FIRST_PAIR_LIMIT = 8;
const LABEL_MAX = 48;

interface StoredDevice {
export interface StoredDevice {
id: string;
label: string;
role: DeviceRole;
Expand Down Expand Up @@ -65,6 +65,29 @@ export function isDeviceRole(value: unknown): value is DeviceRole {
return value === "drive" || value === "watch";
}

/**
* A devices.json as it is on disk: absent, a valid registry, or one that cannot be read or is not valid.
* `placeholder`: written only to close the gate where there was no registry (state-merge.ts); it says
* nothing about which devices were revoked. The store's next save drops the mark.
*/
export type DeviceRegistry = { kind: "missing" } | { kind: "ok"; gateClosedAt: string | null; devices: StoredDevice[]; placeholder: boolean } | { kind: "bad" };

export function readDeviceRegistry(path: string): DeviceRegistry {
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(path, "utf8"));
} catch (error) {
return (error as NodeJS.ErrnoException).code === "ENOENT" ? { kind: "missing" } : { kind: "bad" };
}
if (!isJsonObject(parsed) || !Array.isArray(parsed.devices)
|| !(parsed.gate_closed_at === null || typeof parsed.gate_closed_at === "string")
|| !parsed.devices.every((d) => isJsonObject(d) && typeof d.id === "string"
&& typeof d.token_hash === "string" && isDeviceRole(d.role)
&& typeof d.label === "string" && typeof d.created_at === "string"
&& (d.last_seen_at === null || typeof d.last_seen_at === "string"))) return { kind: "bad" };
return { kind: "ok", gateClosedAt: parsed.gate_closed_at, devices: parsed.devices as StoredDevice[], placeholder: parsed.placeholder === true && parsed.devices.length === 0 };
}

export class DeviceStore {
private devices: StoredDevice[] = [];
/** when the first device was paired: from then on, only this PC, the owner's login, a device or the token get in */
Expand All @@ -76,27 +99,27 @@ export class DeviceStore {
private readonly revocations = new Map<string, Set<() => void>>();
readonly error: string | null = null;

constructor(stateDir: string) {
/**
* `blocked`: why the registry may lack devices that must stay paired (state-merge.ts could not
* take over the other state dir's): the store then stays gated and changes nothing, as for a
* damaged registry, while the devices it holds still get in.
*/
constructor(stateDir: string, blocked: string | null = null) {
this.path = join(stateDir, "devices.json");
try {
const parsed: unknown = JSON.parse(readFileSync(this.path, "utf8"));
if (!isJsonObject(parsed) || !Array.isArray(parsed.devices)
|| !(parsed.gate_closed_at === null || typeof parsed.gate_closed_at === "string")
|| !parsed.devices.every((d) => isJsonObject(d) && typeof d.id === "string"
&& typeof d.token_hash === "string" && isDeviceRole(d.role)
&& typeof d.label === "string" && typeof d.created_at === "string"
&& (d.last_seen_at === null || typeof d.last_seen_at === "string"))) {
throw new Error("invalid device registry");
}
this.gateClosedAt = parsed.gate_closed_at;
this.devices = parsed.devices as StoredDevice[];
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
const read = readDeviceRegistry(this.path);
if (read.kind === "ok") {
this.gateClosedAt = read.gateClosedAt;
this.devices = read.devices;
} else if (read.kind === "bad") {
// A damaged registry is not a first installation. Keep it intact for recovery and
// let the existing local/token/Tailscale routes provide access to the owner.
this.error = `Cannot read ${this.path}. Restore a valid devices.json or fix its permissions, then restart the server. External access without a token or trusted Tailscale login remains blocked.`;
console.error(this.error);
}
if (blocked !== null && this.error === null) {
this.error = blocked;
console.error(this.error);
}
}

/**
Expand Down
35 changes: 23 additions & 12 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
ping,
} from "./herdr/client.ts";
import { type AlertTiming, defaultStateDir, handlePushRequest } from "./push.ts";
import { mergeSiblingState, siblingStateDir } from "./state-merge.ts";
import { moveLegacyStateDir } from "./update-state.ts";
import { handlePromptRequest, OpenCodePromptState } from "./prompt.ts";
import { attachableIdentity } from "./pty/sidecar.ts";
Expand Down Expand Up @@ -115,6 +116,12 @@ export function createServer(
token?: string;
/** where VAPID keys and push subscriptions persist; tests pass a temp dir */
stateDir?: string;
/**
* The other default state dir of an install that kept its state in two (server/state-merge.ts):
* its devices, preferences and push subscriptions are taken over before anything reads them.
* Only the real entrypoint passes one; a test passes a temp dir.
*/
siblingStateDir?: string | null;
/** the PC's own Tailscale login, for the identity check; tests set it, otherwise `tailscale status` says */
tailscaleOwner?: string | null;
/** the operator declares `tailscale serve` as this install's only ingress; OVIS_TAILSCALE_SERVE_ONLY=1 says the same */
Expand Down Expand Up @@ -197,21 +204,24 @@ export function createServer(
/** Empty token = gate disabled; every route then behaves exactly as it did before auth existed. */
const token = options.token ?? process.env["OVIS_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 seen = new SeenStore(options.stateDir ?? defaultStateDir());
// resolved once: a merge can give the old dir files of its own, after which resolving again could name the other dir
const stateDir = options.stateDir ?? defaultStateDir();
const merge = options.siblingStateDir ? mergeSiblingState(stateDir, options.siblingStateDir) : null;
const devices = new DeviceStore(stateDir, merge?.devicesBlocked ?? null);
const preferences = new PreferenceStore(stateDir);
const seen = new SeenStore(stateDir);
// Chat's own rewind (#1500, never released) kept its rewinds here; nothing reads them since #1577
try {
rmSync(join(options.stateDir ?? defaultStateDir(), "claude-rewinds.json"), { force: true });
rmSync(join(stateDir, "claude-rewinds.json"), { force: true });
} catch (error) {
console.error(`cannot remove the unused claude-rewinds.json: ${error instanceof Error ? error.message : String(error)}`);
}
const herdrSidebar = new HerdrSidebarConfig(options.herdrConfigPath ?? herdrConfigPath());
const cliProxy = options.cliProxy ?? new CLIProxyService(options.stateDir ?? defaultStateDir());
const cliProxy = options.cliProxy ?? new CLIProxyService(stateDir);
const cliProxyRoutes = new CLIProxyRoutes(cliProxy);
const usageLogin = options.usageLogin ?? new UsageLoginService();
const usage = options.usage ?? new UsageService(undefined, undefined, (refresh) => cliProxy.report(refresh));
const voice = options.voice ?? new VoiceService({ stateDir: options.stateDir ?? defaultStateDir(), env: process.env, fetch });
const voice = options.voice ?? new VoiceService({ stateDir, env: process.env, fetch });
/** a login named here is taken as it is: a tagged node has none of its own to read (OVIS_TAILSCALE_OWNER) */
const namedOwner = options.tailscaleOwner !== undefined ? options.tailscaleOwner : process.env["OVIS_TAILSCALE_OWNER"]?.trim() || undefined;
const tailnet = options.tailnet ?? new TailnetIdentitySource();
Expand All @@ -227,7 +237,7 @@ export function createServer(
const openCodeState = new OpenCodePromptState();
const draftTimer = setInterval(() => openCodeState.prune(), 60_000);
draftTimer.unref();
const core = createCore({ options: { ...options, openCodeState, orderSequence }, devices, preferences, usage, usageLogin, TYPED_SETTLE_MS, KEY_SETTLE_MS, SUBMIT_DEADLINE_MS, SUBMIT_DELAY_MS, DRAFT_CLEAR_WAIT_MS, DRAFT_CLEAR_POLL_MS });
const core = createCore({ options: { ...options, stateDir, openCodeState, orderSequence }, devices, preferences, usage, usageLogin, TYPED_SETTLE_MS, KEY_SETTLE_MS, SUBMIT_DEADLINE_MS, SUBMIT_DELAY_MS, DRAFT_CLEAR_WAIT_MS, DRAFT_CLEAR_POLL_MS });
const { sidecar, serialize, push, logPushError, omo, claudeAgents, plans, clientSnapshot, machines, broadcastAll, collector, queueFirstMessage } = core;
// started after createCore's Node probe (server/pty/sidecar.ts), which can hold the thread up to 3 s: that wait
// would count against the read's own 2.5 s limit, and a read cut short leaves the owner unknown for 5 minutes
Expand Down Expand Up @@ -563,7 +573,7 @@ export function createServer(
websocket: core.websocket,
});

const requestLog = new RequestLog(options.stateDir ?? defaultStateDir());
const requestLog = new RequestLog(stateDir);

if (options.portal) {
// a Portal that ends on its own is started again only while this server's own token and serve-only allow it
Expand Down Expand Up @@ -612,13 +622,15 @@ export function createServer(

if (import.meta.main) {
moveLegacyStateDir();
// resolved once, for the same reason as in createServer
const stateDir = defaultStateDir();
const updates = connectUpdater();
const version = (JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")) as { version: string }).version;
const telemetry = new Telemetry({ stateDir: defaultStateDir(), version, env: process.env, fetch, previousVersion: () => updates.installed().previous_version });
const portal = new PortalService({ stateDir: defaultStateDir() });
const telemetry = new Telemetry({ stateDir, version, env: process.env, fetch, previousVersion: () => updates.installed().previous_version });
const portal = new PortalService({ stateDir });
let instance: ReturnType<typeof createServer>;
try {
instance = createServer({ updates, herdrUpdate: new HerdrUpdater(), portal, telemetry, registerBridge: true, herdrProfiles: readHerdrProfiles, shellAgents: new ShellAgentLocator() });
instance = createServer({ updates, herdrUpdate: new HerdrUpdater(), portal, telemetry, registerBridge: true, herdrProfiles: readHerdrProfiles, shellAgents: new ShellAgentLocator(), stateDir, siblingStateDir: siblingStateDir(stateDir) });
} catch (err) {
const { code, syscall } = err as { code?: string; syscall?: string };
// only the bind itself: a permission error reading a state file keeps its own message and path
Expand Down Expand Up @@ -653,7 +665,6 @@ if (import.meta.main) {
console.error(`herdr is not reachable at ${socket}: start herdr, or set HERDR_SOCKET to its socket (named sessions: ~/.config/herdr/sessions/<name>/herdr.sock).`);
});
// the state folder is created on first write, so test the nearest folder that exists
const stateDir = defaultStateDir();
let probe = stateDir;
while (true) {
try {
Expand Down
29 changes: 28 additions & 1 deletion server/private-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,28 @@ export interface PrivateStateWrite {
}

export function writePrivateState(path: string, value: unknown, platform: NodeJS.Platform = process.platform): PrivateStateWrite {
const temporary = preparePrivateState(path, value, platform);
try { privateStateFs.renameSync(temporary, path); }
catch (error) {
// Cleanup failures must not mask the write failure the caller has to see.
try { privateStateFs.unlinkSync(temporary); } catch { /* reported through `error` */ }
throw error;
}
return syncDirectory(dirname(path), platform);
}

/**
* The two halves of writePrivateState, for a caller that must know afterwards whether the new
* document reached `path` (state-merge.ts): the prepared file is renamed into place by
* publishPrivateState, so while it still exists, `path` has not got it. A failed publish leaves it.
*/
export function publishPrivateState(temporary: string, path: string, platform: NodeJS.Platform = process.platform): PrivateStateWrite {
privateStateFs.renameSync(temporary, path);
return syncDirectory(dirname(path), platform);
}

/** The new bytes of `path`, flushed, in a private temporary file beside it; returns that file. */
export function preparePrivateState(path: string, value: unknown, platform: NodeJS.Platform = process.platform): string {
const fs = privateStateFs;
const directory = dirname(path);
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
Expand All @@ -52,13 +74,18 @@ export function writePrivateState(path: string, value: unknown, platform: NodeJS
const closing = fd;
fd = null; // a failed close must not be retried: the descriptor is released either way
fs.closeSync(closing);
fs.renameSync(temporary, path);
} catch (error) {
// Cleanup failures must not mask the write failure the caller has to see.
if (fd !== null) try { fs.closeSync(fd); } catch { /* reported through `error` */ }
try { fs.unlinkSync(temporary); } catch { /* reported through `error` */ }
throw error;
}
return temporary;
}

/** Records the rename in `directory` (best effort, see the top of this file). */
function syncDirectory(directory: string, platform: NodeJS.Platform): PrivateStateWrite {
const fs = privateStateFs;
if (platform === "win32") return { directorySynced: false };
let directoryFd: number | null = null;
try {
Expand Down
Loading
Loading