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
6 changes: 5 additions & 1 deletion scripts/test-herdr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,11 @@ export async function useTestHerdr(): Promise<string | null> {
}
if (process.env["HERDR_TEST_LIVE"] === "1") return null;
const herdr = herdrBinary();
if (!herdr) return null;
// no herdr to isolate: a socket that does not exist, never the default one (the user's own session)
if (!herdr) {
process.env["HERDR_SOCKET"] = join(tmpdir(), "ovis-tests-found-no-herdr.sock");
return null;
}
const socket = testSocketPath();
// an empty ZDOTDIR: a pane's zsh (macOS's login shell) reads none of the user's own files,
// where a shell integration can start a CLI herdr takes for an agent, ending the agent a test
Expand Down
14 changes: 14 additions & 0 deletions server/herdr-core/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
* Calls are typed by hc's own schema (hc-api.generated.ts, `bun run generate:types`).
*/

import { homedir } from "node:os";
import { liveSessionsRefused, sameSocket } from "../herdr/client.ts";
import { coreSocket } from "./discovery.ts";
import type { HcMethod, HcMethods } from "./hc-api.generated.ts";

/** The calls that answer with a stream of their own. */
Expand Down Expand Up @@ -153,10 +156,21 @@ export interface HcTransport {
kill: () => void;
}

/** Whether hc in `env` would reach the user's default core: the one this process's environment, or its owner's home, names when no HC_SOCKET or HC_SESSION does. */
function userCore(env: Record<string, string | undefined>): boolean {
const unnamed = { ...process.env, HC_SOCKET: undefined, HC_SESSION: undefined };
const defaults = [coreSocket({ env: unnamed }).path, coreSocket({ env: unnamed, home: homedir() }).path].map(sameSocket);
return defaults.includes(sameSocket(coreSocket({ env }).path));
}

/** `hc connect` as a child process (in `env`, else this process's); a program that cannot be started is an HcError (`closed`). */
export function spawnHcConnect(bin: string, env?: Record<string, string | undefined>): HcTransport {
// runtime.ts hands out this path that cannot exist while a moved hc is found again
if (bin === "/dev/null/herdr-core-moved") throw new HcError("closed", "herdr-core moved (an update?), and Ovis is looking for it again: try again in a moment");
// a process that asked for an isolated herdr never reaches the user's default core, however it is named
if (liveSessionsRefused(process.env) && userCore(env ?? process.env)) {
throw new HcError("live_core_refused", "HERDR_TEST_SESSION is set, but hc would reach the user's own herdr-core: give it HC_SOCKET or an HC_SESSION of its own, or set OVIS_ALLOW_LIVE_HERDR=1");
}
let child: Bun.Subprocess<"pipe", "pipe", "ignore">;
try {
child = Bun.spawn([bin, "connect"], { stdin: "pipe", stdout: "pipe", stderr: "ignore", ...(env ? { env } : {}) });
Expand Down
40 changes: 40 additions & 0 deletions server/herdr/client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { realpathSync } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, join, resolve, win32 } from "node:path";
import { stripVTControlCharacters } from "node:util";
import { ConnectionDiagnostics, transportFailureCode } from "../connection-diagnostics.ts";
import type {
Expand Down Expand Up @@ -27,11 +30,48 @@ export function herdrSocketPath(): string {
return `${process.env.HOME ?? ""}/.config/herdr/herdr.sock`;
}

/**
* Whether this process asked for an isolated herdr (HERDR_TEST_SESSION) and did not say the user's own
* session is meant (OVIS_ALLOW_LIVE_HERDR=1, or the tests' HERDR_TEST_LIVE=1): its herdr and herdr-core
* connections then never reach the user's own sessions.
*/
export function liveSessionsRefused(env: Record<string, string | undefined> = process.env): boolean {
return Boolean(env["HERDR_TEST_SESSION"]) && env["OVIS_ALLOW_LIVE_HERDR"] !== "1" && env["HERDR_TEST_LIVE"] !== "1";
}

/** One spelling of a socket: absolute, through the links of its folder, and on Windows the pipe's name in one case. */
export function sameSocket(path: string): string {
// Windows resolves `..` and `.` in a \\.\ device path, a pipe's too: one name, in one case
if (process.platform === "win32") return win32.normalize(path.replace(/^\\\\\.\\pipe\\/i, "")).toLowerCase();
const absolute = resolve(path);
try { return join(realpathSync(dirname(absolute)), basename(absolute)); } catch { return absolute; }
}

/**
* A process that names a test session never reaches herdr's default socket, the user's own session:
* a script whose scripts/test-herdr.ts import Bun dropped (its binding unused) would otherwise create,
* type into and close panes there. Both homes count (HOME may be changed for a fixture after the
* socket was chosen), and every spelling of the same socket.
*/
function refuseLiveHerdr(socketPath: string, env: Record<string, string | undefined> = process.env): void {
if (!liveSessionsRefused(env)) return;
const homes = [...new Set([env["HOME"], homedir()].filter((home): home is string => Boolean(home)))];
const defaults = [
...homes.map((home) => join(home, ".config", "herdr", "herdr.sock")),
...(env["XDG_CONFIG_HOME"] ? [join(env["XDG_CONFIG_HOME"], "herdr", "herdr.sock")] : []),
...(process.platform === "win32" && env["APPDATA"] ? [`${env["APPDATA"]}\\herdr\\herdr.sock`] : []),
].map(sameSocket);
if (defaults.includes(sameSocket(socketPath))) {
throw new HerdrError("live_herdr_refused", `HERDR_TEST_SESSION=${env["HERDR_TEST_SESSION"]} is set, but this call would reach the user's own herdr at ${socketPath}: import scripts/test-herdr.ts for its effect (import "./test-herdr.ts"), or set OVIS_ALLOW_LIVE_HERDR=1`);
}
}
Comment thread
devswha marked this conversation as resolved.
Resolved

/**
* On Windows herdr.sock is a marker file (`pid:start`), and the server listens on a named
* pipe of the same name: `\\.\pipe\C:\Users\…\herdr.sock` (live-verified, herdr 0.9.3).
*/
export function socketAddress(socketPath: string): string {
refuseLiveHerdr(socketPath);
return process.platform === "win32" && !socketPath.startsWith("\\\\.\\pipe\\") ? `\\\\.\\pipe\\${socketPath}` : socketPath;
}

Expand Down
51 changes: 51 additions & 0 deletions server/herdr/live-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { afterEach, expect, it } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { herdrRpc, herdrSocketPath } from "./client.ts";
import { spawnHcConnect } from "../herdr-core/connection.ts";
import { coreSocket } from "../herdr-core/discovery.ts";

// A script that names a test session but never ran scripts/test-herdr.ts (an import Bun drops
// because its binding is unused) is left on herdr's default socket: the user's own session.
// HOME is a temp folder here, so the default socket is nobody's and nothing live is reached.
const NAMES = ["HOME", "HERDR_SOCKET", "HERDR_TEST_SESSION", "HERDR_TEST_LIVE", "OVIS_ALLOW_LIVE_HERDR", "HC_SOCKET", "HC_SESSION"] as const;
const saved = Object.fromEntries(NAMES.map((name) => [name, process.env[name]]));
const home = mkdtempSync(join(tmpdir(), "ovis-live-guard-"));
afterEach(() => {
for (const [name, value] of Object.entries(saved)) if (value === undefined) delete process.env[name]; else process.env[name] = value;
});
const codeOf = (error: unknown): string | undefined => (error as { code?: string; kind?: string } | null)?.code ?? (error as { kind?: string } | null)?.kind;
const isolatedIntent = () => {
process.env["HOME"] = home;
for (const name of ["HERDR_SOCKET", "HERDR_TEST_LIVE", "OVIS_ALLOW_LIVE_HERDR", "HC_SOCKET", "HC_SESSION"]) delete process.env[name];
process.env["HERDR_TEST_SESSION"] = "ovis-test-guard";
};

it("refuses herdr's default socket to a process that names a test session", async () => {
try {
isolatedIntent();
const refused = await herdrRpc("ping", {}).then(() => null, (error: unknown) => error);
expect(codeOf(refused)).toBe("live_herdr_refused");
// another spelling of the same socket
const spelled = await herdrRpc("ping", {}, join(home, ".config", "herdr", "..", "herdr", "herdr.sock")).then(() => null, (error: unknown) => error);
expect(codeOf(spelled)).toBe(process.platform === "win32" ? codeOf(spelled) : "live_herdr_refused");
// said out loud, the live session may be used
process.env["OVIS_ALLOW_LIVE_HERDR"] = "1";
const reached = await herdrRpc("ping", {}, herdrSocketPath()).then(() => null, (error: unknown) => error);
expect(codeOf(reached)).not.toBe("live_herdr_refused");
} finally {
rmSync(home, { recursive: true, force: true });
}
});

it("refuses hc's default core to a process that names a test session, not a core it names", () => {
isolatedIntent();
const refused = (() => { try { spawnHcConnect(join(home, "no-such-hc")); return null; } catch (error) { return error; } })();
expect(codeOf(refused)).toBe("live_core_refused");
const named = (() => { try { spawnHcConnect(join(home, "no-such-hc"), { HC_SOCKET: join(home, "hc.sock") }); return null; } catch (error) { return error; } })();
expect(codeOf(named)).not.toBe("live_core_refused");
// an inherited HC_SOCKET that names the default core is the user's core all the same
const inherited = (() => { try { spawnHcConnect(join(home, "no-such-hc"), { HC_SOCKET: coreSocket({ env: { ...process.env, HC_SOCKET: undefined, HC_SESSION: undefined } }).path }); return null; } catch (error) { return error; } })();
expect(codeOf(inherited)).toBe("live_core_refused");
});
Loading