mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
feat(hosts): add rig host edit and remove (#2310)
The host registry could only gain entries, so a mistyped target or a retired machine could only be fixed by hand-editing hosts.yaml. This adds rig host edit <id> (--target, --user, --url, --notes; an empty string clears an optional field) and rig host remove <id>, with matching POST /api/hosts/update and /api/hosts/remove routes beside /add. An edit keeps the host's id and transport fixed, and unknown fields fail with the editable set for that transport. Both routes refuse secret-shaped fields with add's no_secret_values check before any lookup or write, never overwrite an invalid registry, and go through the same parity-pinned writer and validator as add, with byte-parity tests across the CLI and daemon writers. Remove deletes only the registry entry: no credential files, no learned identity, nothing on the remote machine. Both report wasSelected and leave the selection pointer unchanged, and the CLI says where the selection stands. The routes are bearer-gated exactly like /add.
This commit is contained in:
1 parent
cf50c94b1d
commit
9a54284d47
8 files changed
+1096
-20
No files matched your search
@@ -1,12 +1,19 @@
|
||||
// OPR.0.4.4.13 — the host-registry verbs (add / list / doctor — arch
|
||||
// R13-2a: no edit/remove/tunnel/bootstrap verbs; hand-editing hosts.yaml
|
||||
// remains the path for exotica; the bootstrap ships as script + runbook).
|
||||
// OPR.0.4.6.MH1 FR-1 extends the family with `select` — the persisted
|
||||
// host-selection pointer (kubectl current-context shape). The pointer
|
||||
// lives in the config twins as `host.selected`; this verb is a THIN
|
||||
// CLIENT of the daemon config write (one write path for CLI+UI
|
||||
// R13-2a originally: no edit/remove/tunnel/bootstrap verbs; hand-editing
|
||||
// hosts.yaml remains the path for exotica; the bootstrap ships as script +
|
||||
// runbook). OPR.0.4.6.MH1 FR-1 extends the family with `select` — the
|
||||
// persisted host-selection pointer (kubectl current-context shape). The
|
||||
// pointer lives in the config twins as `host.selected`; this verb is a
|
||||
// THIN CLIENT of the daemon config write (one write path for CLI+UI
|
||||
// convergence, arch ruling); reads resolve from the local ConfigStore
|
||||
// (env > file > default — zero new daemon lookups on read paths).
|
||||
// Issue #2128 (maintainer-approved boundary) extends the family again with
|
||||
// `edit` and `remove`: the SAME parity-pinned writer contract as add — id
|
||||
// and transport stay fixed on an edit, unknown fields fail loud, empty
|
||||
// string clears an optional field, and remove deletes only the registry
|
||||
// entry (no credential files, no learned identity, no selection change).
|
||||
// When the selected host is edited or removed, the selection outcome is
|
||||
// shown plainly — never a silent switch.
|
||||
//
|
||||
// Secret hygiene (FR-1): every render carries bearer POINTERS (env var /
|
||||
// file NAMES) only — no code path in this file resolves a bearer value
|
||||
@@ -18,7 +25,7 @@ import { join } from "node:path";
|
||||
import { hostname as osHostname, userInfo } from "node:os";
|
||||
import { connect } from "node:net";
|
||||
import { getOpenRigHome } from "../openrig-compat.js";
|
||||
import { addHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry } from "../host-registry.js";
|
||||
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry } from "../host-registry.js";
|
||||
import { runCrossHostCommand, type CrossHostResult } from "../cross-host-executor.js";
|
||||
import { recordHostObservation, loadHostBindings, describeBindingConflict, defaultHostBindingsPath, type HostObservationOutcome } from "../host-bindings.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
@@ -687,6 +694,66 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
console.log(`This host is now named: ${trimmed}`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("edit")
|
||||
.description("Edit a host entry (id and transport stay fixed; an empty string clears an optional field)")
|
||||
.argument("<id>", "A registered host id")
|
||||
.option("--target <target>", "SSH target — ssh transport")
|
||||
.option("--user <user>", "SSH user — ssh transport (empty string clears the value)")
|
||||
.option("--url <url>", "Remote daemon base URL — http transport")
|
||||
.option("--notes <text>", "Free-form operator note (empty string clears the value)")
|
||||
.option("--json", "JSON output")
|
||||
.action((id: string, opts: { target?: string; user?: string; url?: string; notes?: string; json?: boolean }) => {
|
||||
const changes: Record<string, unknown> = {};
|
||||
if (opts.target !== undefined) changes["target"] = opts.target;
|
||||
if (opts.user !== undefined) changes["user"] = opts.user;
|
||||
if (opts.url !== undefined) changes["url"] = opts.url;
|
||||
if (opts.notes !== undefined) changes["notes"] = opts.notes;
|
||||
|
||||
const res = updateHostEntry(id.trim(), changes);
|
||||
if (!res.ok) {
|
||||
console.error(res.error);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const wasSelected = readSelectedHost() === res.entry.id;
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: true, path: res.path, entry: res.entry, wasSelected }));
|
||||
return;
|
||||
}
|
||||
console.log(`Updated host '${res.entry.id}' (${res.entry.transport}) at ${res.path}`);
|
||||
for (const key of Object.keys(changes) as string[]) {
|
||||
const rec = (e: HostEntry) => e as unknown as Record<string, unknown>;
|
||||
const before = rec(res.previous)[key];
|
||||
const after = rec(res.entry)[key];
|
||||
if (before !== after) {
|
||||
console.log(` ${key}: ${before === undefined ? "(unset)" : String(before)} → ${after === undefined ? "(cleared)" : String(after)}`);
|
||||
}
|
||||
}
|
||||
if (wasSelected) console.log(`note: '${res.entry.id}' is the selected host — it was edited in place and the selection still points at it.`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("remove")
|
||||
.description("Remove a host entry (deletes only the registry entry — credential files, the learned identity and the selection stay as they are)")
|
||||
.argument("<id>", "A registered host id")
|
||||
.option("--json", "JSON output")
|
||||
.action((id: string, opts: { json?: boolean }) => {
|
||||
const res = removeHostEntry(id.trim());
|
||||
if (!res.ok) {
|
||||
console.error(res.error);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const wasSelected = readSelectedHost() === res.entry.id;
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: true, path: res.path, entry: res.entry, wasSelected }));
|
||||
return;
|
||||
}
|
||||
console.log(`Removed host '${res.entry.id}' (${res.entry.transport}) from ${res.path}`);
|
||||
if (wasSelected) console.log(`note: '${res.entry.id}' was the selected host — the selection still points at '${res.entry.id}' and was NOT changed. Pick another: rig host select <id>`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("pair")
|
||||
.description("Pair with a remote host from one pasted address — one approval on the target, done (FR-6)")
|
||||
|
||||
@@ -372,3 +372,128 @@ export function addHostEntry(rawEntry: Record<string, unknown>, path: string = d
|
||||
}
|
||||
return { ok: true, path, entry };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OPR.0.4.6.MH1 FR-1 extension (issue #2128, maintainer boundary) — the
|
||||
// edit/remove write paths, through the SAME ONE write contract as add:
|
||||
// load-validate (a present-but-invalid registry is NEVER overwritten),
|
||||
// candidate through validateHostRegistry (add-time errors are load-time
|
||||
// errors, verbatim), atomic tmp+rename at 0600.
|
||||
//
|
||||
// Boundary pinned here: an edit keeps the host's id and transport FIXED;
|
||||
// unknown fields fail loud with the allowed set (a typo'd flag must never
|
||||
// silently no-op an edit); an empty string clears an OPTIONAL field (user,
|
||||
// notes) — clearing is how a wrong value is dropped, while required fields
|
||||
// (target, url) keep the validator's non-empty rule; remove deletes ONLY the
|
||||
// registry entry (no credential files, no learned identity, no selection).
|
||||
//
|
||||
// The daemon twin (packages/daemon/src/domain/hosts/hosts-registry-writer.ts)
|
||||
// mirrors updateHostEntry/removeHostEntry verbatim; byte parity is pinned by
|
||||
// packages/daemon/test/hosts-registry-parity.test.ts.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Fields an edit may touch, per transport. id/transport never appear here. */
|
||||
const EDITABLE_FIELDS: Record<"ssh" | "http", readonly string[]> = {
|
||||
ssh: ["target", "user", "notes"],
|
||||
http: ["url", "notes"],
|
||||
};
|
||||
|
||||
/** Optional editable fields an empty string CLEARS (drops the key). */
|
||||
const CLEARABLE_FIELDS = new Set(["user", "notes"]);
|
||||
|
||||
export type UpdateHostResult =
|
||||
| { ok: true; path: string; entry: HostEntry; previous: HostEntry }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type RemoveHostResult =
|
||||
| { ok: true; path: string; entry: HostEntry }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/** The write-path load: MISSING is an error here (nothing to edit/remove) with
|
||||
* the loader's canonical message; INVALID is a loud refusal, never clobbered. */
|
||||
function loadExistingForWrite(path: string): { ok: true; existing: HostEntry[] } | { ok: false; error: string } {
|
||||
const loaded = loadHostRegistry(path);
|
||||
if (loaded.ok) return { ok: true, existing: loaded.registry.hosts };
|
||||
const prefix = existsSync(path) ? "refusing to modify an invalid registry: " : "";
|
||||
return { ok: false, error: prefix + loaded.error };
|
||||
}
|
||||
|
||||
/** Unknown-id error for a WRITE: names the id, lists known ids (resolveHost's shape). */
|
||||
function unknownHostIdError(existing: HostEntry[], id: string): string {
|
||||
const knownIds = existing.map(h => h.id).slice(0, 10);
|
||||
const hint = knownIds.length > 0
|
||||
? ` Known host ids: ${knownIds.join(", ")}${existing.length > knownIds.length ? ` (+${existing.length - knownIds.length} more)` : ""}.`
|
||||
: " (registry is empty)";
|
||||
return `unknown host id '${id}'.${hint}`;
|
||||
}
|
||||
|
||||
/** The atomic registry write the write contract shares (tmp+rename, 0600). */
|
||||
function writeRegistryFile(path: string, hosts: HostEntry[]): string | null {
|
||||
try {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tmp = join(dirname(path), `.hosts.yaml.tmp-${process.pid}`);
|
||||
writeFileSync(tmp, yaml().stringify({ hosts }), { mode: 0o600 });
|
||||
renameSync(tmp, path);
|
||||
return null;
|
||||
} catch (err) {
|
||||
return `failed to write host registry at ${path}: ${(err as Error).message}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply an edit to ONE entry: id/transport fixed, unknown fields refused,
|
||||
* empty string clears an optional field, then the WHOLE registry validates. */
|
||||
export function updateHostEntry(id: string, changes: Record<string, unknown>, path: string = defaultHostRegistryPath()): UpdateHostResult {
|
||||
const loaded = loadExistingForWrite(path);
|
||||
if (!loaded.ok) return loaded;
|
||||
const idx = loaded.existing.findIndex(h => h.id === id);
|
||||
if (idx === -1) return { ok: false, error: unknownHostIdError(loaded.existing, id) };
|
||||
const previous = loaded.existing[idx]!;
|
||||
|
||||
if (changes["id"] !== undefined && changes["id"] !== id) {
|
||||
return { ok: false, error: `an edit keeps the host's id fixed — '${id}' cannot become '${String(changes["id"])}'` };
|
||||
}
|
||||
if (changes["transport"] !== undefined) {
|
||||
return { ok: false, error: `an edit keeps the host's transport fixed — '${id}' stays '${previous.transport}'` };
|
||||
}
|
||||
const allowed = EDITABLE_FIELDS[previous.transport];
|
||||
const effective: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (key === "id") continue; // an id echo naming the same host is a no-op
|
||||
if (!allowed.includes(key)) {
|
||||
return { ok: false, error: `field '${key}' is not editable on an ${previous.transport} host (editable fields: ${allowed.join(", ")})` };
|
||||
}
|
||||
effective[key] = value;
|
||||
}
|
||||
if (Object.keys(effective).length === 0) {
|
||||
return { ok: false, error: "no changes given" };
|
||||
}
|
||||
|
||||
const updated = { ...previous } as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(effective)) {
|
||||
if (CLEARABLE_FIELDS.has(key) && value === "") delete updated[key];
|
||||
else updated[key] = value;
|
||||
}
|
||||
const candidate = loaded.existing.map((h, i) => (i === idx ? updated as unknown as HostEntry : h));
|
||||
const validated = validateHostRegistry({ hosts: candidate }, path);
|
||||
if (!validated.ok) return { ok: false, error: validated.error };
|
||||
const entry = validated.registry.hosts.find(h => h.id === id);
|
||||
if (!entry) return { ok: false, error: `host '${id}' did not survive validation` };
|
||||
const writeError = writeRegistryFile(path, validated.registry.hosts);
|
||||
if (writeError) return { ok: false, error: writeError };
|
||||
return { ok: true, path, entry, previous };
|
||||
}
|
||||
|
||||
/** Remove ONE entry — only the registry entry; nothing else is touched. */
|
||||
export function removeHostEntry(id: string, path: string = defaultHostRegistryPath()): RemoveHostResult {
|
||||
const loaded = loadExistingForWrite(path);
|
||||
if (!loaded.ok) return loaded;
|
||||
const idx = loaded.existing.findIndex(h => h.id === id);
|
||||
if (idx === -1) return { ok: false, error: unknownHostIdError(loaded.existing, id) };
|
||||
const entry = loaded.existing[idx]!;
|
||||
const remaining = loaded.existing.filter((_, i) => i !== idx);
|
||||
const validated = validateHostRegistry({ hosts: remaining }, path);
|
||||
if (!validated.ok) return { ok: false, error: validated.error };
|
||||
const writeError = writeRegistryFile(path, validated.registry.hosts);
|
||||
if (writeError) return { ok: false, error: writeError };
|
||||
return { ok: true, path, entry };
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// OPR.0.4.6.MH1 FR-1 extension (issue #2128) — the `rig host edit` and
|
||||
// `rig host remove` COMMANDS themselves: flag→field mapping, the id+transport
|
||||
// fixed rule surfacing as loud CLI errors, empty-string clearing, plain
|
||||
// selection notices (never a silent switch), and the --json shape.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { Command } from "commander";
|
||||
import { hostCommand } from "../src/commands/host.js";
|
||||
import { addHostEntry, loadHostRegistry } from "../src/host-registry.js";
|
||||
|
||||
describe("rig host edit / rig host remove (issue #2128 command path)", () => {
|
||||
let dir: string;
|
||||
let savedEnv: Record<string, string | undefined>;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "hostedit-"));
|
||||
savedEnv = {};
|
||||
for (const k of ["OPENRIG_HOME", "RIGGED_HOME", "OPENRIG_URL", "RIGGED_URL", "OPENRIG_HOST_SELECTED"]) {
|
||||
savedEnv[k] = process.env[k];
|
||||
delete process.env[k];
|
||||
}
|
||||
process.env.OPENRIG_HOME = dir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
for (const [k, v] of Object.entries(savedEnv)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
});
|
||||
|
||||
async function capture(fn: () => Promise<void>): Promise<{ out: string[]; err: string[]; exitCode: number | undefined }> {
|
||||
const out: string[] = []; const err: string[] = [];
|
||||
const ol = console.log; const oe = console.error; const oc = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
console.log = (...a: unknown[]) => out.push(a.join(" "));
|
||||
console.error = (...a: unknown[]) => err.push(a.join(" "));
|
||||
let exitCode: number | undefined;
|
||||
try {
|
||||
await fn();
|
||||
exitCode = process.exitCode as number | undefined;
|
||||
} finally {
|
||||
console.log = ol;
|
||||
console.error = oe;
|
||||
process.exitCode = oc;
|
||||
}
|
||||
return { out, err, exitCode };
|
||||
}
|
||||
|
||||
function run(argv: string[]) {
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(hostCommand());
|
||||
return prog.parseAsync(["node", "rig", "host", ...argv]);
|
||||
}
|
||||
|
||||
function seed() {
|
||||
expect(addHostEntry({ id: "vm-a", transport: "ssh", target: "vm-a.local", user: "admin", notes: "old" }).ok).toBe(true);
|
||||
expect(addHostEntry({ id: "h1", transport: "http", url: "http://h1:7433", bearer_env: "H1_TOKEN" }).ok).toBe(true);
|
||||
}
|
||||
|
||||
it("edit changes fields through the writer; id+transport stay fixed", async () => {
|
||||
seed();
|
||||
const { out, exitCode } = await capture(() => run(["edit", "vm-a", "--target", "vm-b.local", "--user", "bob"]));
|
||||
expect(exitCode).toBeUndefined();
|
||||
const loaded = loadHostRegistry();
|
||||
expect(loaded.ok).toBe(true);
|
||||
if (!loaded.ok) throw new Error(loaded.error);
|
||||
const entry = loaded.registry.hosts.find(h => h.id === "vm-a");
|
||||
expect(entry).toMatchObject({ id: "vm-a", transport: "ssh", target: "vm-b.local", user: "bob" });
|
||||
expect(out.join("\n")).toContain("Updated host 'vm-a'");
|
||||
});
|
||||
|
||||
it("edit clears an optional field with an empty string", async () => {
|
||||
seed();
|
||||
const { exitCode } = await capture(() => run(["edit", "vm-a", "--user", ""]));
|
||||
expect(exitCode).toBeUndefined();
|
||||
const loaded = loadHostRegistry();
|
||||
expect(loaded.ok).toBe(true);
|
||||
if (!loaded.ok) throw new Error(loaded.error);
|
||||
const entry = loaded.registry.hosts.find(h => h.id === "vm-a")!;
|
||||
expect(entry.user).toBeUndefined();
|
||||
expect(entry.notes).toBe("old");
|
||||
});
|
||||
|
||||
it("edit on an unknown id is exit 1 naming the known ids", async () => {
|
||||
seed();
|
||||
const { err, exitCode } = await capture(() => run(["edit", "nope", "--target", "x"]));
|
||||
expect(exitCode).toBe(1);
|
||||
expect(err.join("\n")).toContain("unknown host id 'nope'");
|
||||
expect(err.join("\n")).toContain("vm-a");
|
||||
});
|
||||
|
||||
it("edit with NO flags is refused (nothing to change)", async () => {
|
||||
seed();
|
||||
const { err, exitCode } = await capture(() => run(["edit", "vm-a"]));
|
||||
expect(exitCode).toBe(1);
|
||||
expect(err.join("\n")).toMatch(/no changes/i);
|
||||
});
|
||||
|
||||
it("edit refuses a field that is not editable for the transport", async () => {
|
||||
seed();
|
||||
const { err, exitCode } = await capture(() => run(["edit", "vm-a", "--url", "http://x"]));
|
||||
expect(exitCode).toBe(1);
|
||||
expect(err.join("\n")).toContain("not editable");
|
||||
const { err: err2, exitCode: code2 } = await capture(() => run(["edit", "h1", "--target", "somewhere"]));
|
||||
expect(code2).toBe(1);
|
||||
expect(err2.join("\n")).toContain("not editable");
|
||||
});
|
||||
|
||||
it("edit of the SELECTED host says so plainly (selection unchanged)", async () => {
|
||||
seed();
|
||||
process.env.OPENRIG_HOST_SELECTED = "vm-a";
|
||||
const { out, exitCode } = await capture(() => run(["edit", "vm-a", "--notes", "moved"]));
|
||||
expect(exitCode).toBeUndefined();
|
||||
const joined = out.join("\n");
|
||||
expect(joined).toContain("selected host");
|
||||
expect(joined).toContain("selection");
|
||||
expect(process.env.OPENRIG_HOST_SELECTED).toBe("vm-a"); // never a silent switch
|
||||
});
|
||||
|
||||
it("remove deletes only the entry and reports it plainly", async () => {
|
||||
seed();
|
||||
const { out, exitCode } = await capture(() => run(["remove", "vm-a"]));
|
||||
expect(exitCode).toBeUndefined();
|
||||
const loaded = loadHostRegistry();
|
||||
expect(loaded.ok).toBe(true);
|
||||
if (!loaded.ok) throw new Error(loaded.error);
|
||||
expect(loaded.registry.hosts.map(h => h.id)).toEqual(["h1"]);
|
||||
expect(out.join("\n")).toContain("Removed host 'vm-a'");
|
||||
});
|
||||
|
||||
it("remove of an unknown id is exit 1 naming known ids", async () => {
|
||||
seed();
|
||||
const { err, exitCode } = await capture(() => run(["remove", "nope"]));
|
||||
expect(exitCode).toBe(1);
|
||||
expect(err.join("\n")).toContain("unknown host id 'nope'");
|
||||
});
|
||||
|
||||
it("remove of the SELECTED host shows the dangling selection plainly — never a silent switch", async () => {
|
||||
seed();
|
||||
process.env.OPENRIG_HOST_SELECTED = "vm-a";
|
||||
const { out, exitCode } = await capture(() => run(["remove", "vm-a"]));
|
||||
expect(exitCode).toBeUndefined();
|
||||
const joined = out.join("\n");
|
||||
expect(joined).toContain("was the selected host");
|
||||
expect(joined).toContain("rig host select");
|
||||
expect(process.env.OPENRIG_HOST_SELECTED).toBe("vm-a"); // selection untouched
|
||||
expect(fs.existsSync(path.join(dir, "secrets"))).toBe(false); // no credential state touched
|
||||
});
|
||||
|
||||
it("--json emits one parseable object with ok + entry", async () => {
|
||||
seed();
|
||||
const { out, exitCode } = await capture(() => run(["edit", "h1", "--notes", "n", "--json"]));
|
||||
expect(exitCode).toBeUndefined();
|
||||
const parsed = JSON.parse(out.join("\n")) as { ok: boolean; entry: { id: string; notes: string } };
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.entry).toMatchObject({ id: "h1", notes: "n" });
|
||||
const { out: out2, exitCode: code2 } = await capture(() => run(["remove", "h1", "--json"]));
|
||||
expect(code2).toBeUndefined();
|
||||
const parsed2 = JSON.parse(out2.join("\n")) as { ok: boolean; entry: { id: string } };
|
||||
expect(parsed2.ok).toBe(true);
|
||||
expect(parsed2.entry.id).toBe("h1");
|
||||
});
|
||||
|
||||
it("edit on a MISSING registry is exit 1 with the loader's message", async () => {
|
||||
const { err, exitCode } = await capture(() => run(["edit", "vm-a", "--target", "x"]));
|
||||
expect(exitCode).toBe(1);
|
||||
expect(err.join("\n")).toContain("host registry not found");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
// OPR.0.4.6.MH1 FR-1 extension (issue #2128, maintainer-approved boundary) —
|
||||
// the registry WRITE path for `rig host edit` / `rig host remove`:
|
||||
//
|
||||
// - an edit keeps the host's id and transport FIXED (the boundary's first rule);
|
||||
// - unknown fields fail loud with the allowed set for the transport (a typo'd
|
||||
// flag must never silently no-op an edit);
|
||||
// - an empty string clears an OPTIONAL field (user, notes) — clearing is how
|
||||
// you drop a wrong value; required fields (target, url) stay non-empty;
|
||||
// - a present-but-INVALID registry is never overwritten (add's rule, verbatim);
|
||||
// - remove deletes ONLY the registry entry.
|
||||
//
|
||||
// The daemon twin (packages/daemon/src/domain/hosts/hosts-registry-writer.ts)
|
||||
// mirrors these verbatim; byte-parity is pinned in
|
||||
// packages/daemon/test/hosts-registry-parity.test.ts.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { addHostEntry, loadHostRegistry, updateHostEntry, removeHostEntry } from "../src/host-registry.js";
|
||||
|
||||
describe("updateHostEntry (rig host edit write path)", () => {
|
||||
let dir: string;
|
||||
let regPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "hostreg-edit-"));
|
||||
regPath = path.join(dir, "hosts.yaml");
|
||||
});
|
||||
afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
it("round-trip: add an ssh host, edit target/user/notes, loader reads back the edit; id+transport fixed", () => {
|
||||
expect(addHostEntry({ id: "vm-a", transport: "ssh", target: "vm-a.local", user: "admin", notes: "old" }, regPath).ok).toBe(true);
|
||||
const res = updateHostEntry("vm-a", { target: "vm-b.local", user: "bob", notes: "moved" }, regPath);
|
||||
expect(res.ok).toBe(true);
|
||||
if (!res.ok) throw new Error(res.error);
|
||||
expect(res.entry).toMatchObject({ id: "vm-a", transport: "ssh", target: "vm-b.local", user: "bob", notes: "moved" });
|
||||
expect(res.previous).toMatchObject({ id: "vm-a", transport: "ssh", target: "vm-a.local", user: "admin", notes: "old" });
|
||||
const loaded = loadHostRegistry(regPath);
|
||||
expect(loaded.ok).toBe(true);
|
||||
if (!loaded.ok) throw new Error(loaded.error);
|
||||
expect(loaded.registry.hosts).toHaveLength(1);
|
||||
expect(loaded.registry.hosts[0]).toEqual({ id: "vm-a", transport: "ssh", target: "vm-b.local", user: "bob", notes: "moved" });
|
||||
});
|
||||
|
||||
it("edit keeps the host id and transport fixed (both refusals, bytes untouched)", () => {
|
||||
expect(addHostEntry({ id: "vm-a", transport: "ssh", target: "vm-a.local" }, regPath).ok).toBe(true);
|
||||
const before = fs.readFileSync(regPath, "utf8");
|
||||
const idRes = updateHostEntry("vm-a", { id: "renamed" }, regPath);
|
||||
expect(idRes.ok).toBe(false);
|
||||
if (!idRes.ok) expect(idRes.error).toContain("id");
|
||||
const transportRes = updateHostEntry("vm-a", { transport: "http", url: "http://x" }, regPath);
|
||||
expect(transportRes.ok).toBe(false);
|
||||
if (!transportRes.ok) expect(transportRes.error).toContain("transport");
|
||||
expect(fs.readFileSync(regPath, "utf8")).toBe(before);
|
||||
});
|
||||
|
||||
it("edit on an unknown host id names the id and lists known ids", () => {
|
||||
expect(addHostEntry({ id: "vm-a", transport: "ssh", target: "vm-a.local" }, regPath).ok).toBe(true);
|
||||
const res = updateHostEntry("nope", { target: "x" }, regPath);
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.error).toContain("unknown host id 'nope'");
|
||||
expect(res.error).toContain("Known host ids");
|
||||
}
|
||||
});
|
||||
|
||||
it("edit with no changes given is refused", () => {
|
||||
expect(addHostEntry({ id: "vm-a", transport: "ssh", target: "vm-a.local" }, regPath).ok).toBe(true);
|
||||
const res = updateHostEntry("vm-a", {}, regPath);
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) expect(res.error).toMatch(/no changes/i);
|
||||
});
|
||||
|
||||
it("edit refuses fields that are not editable for the transport, naming the allowed set", () => {
|
||||
expect(addHostEntry({ id: "vm-a", transport: "ssh", target: "vm-a.local" }, regPath).ok).toBe(true);
|
||||
const res = updateHostEntry("vm-a", { url: "http://x" }, regPath);
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.error).toContain("url");
|
||||
expect(res.error).toContain("ssh");
|
||||
}
|
||||
// and for an http host, ssh-only fields are refused
|
||||
expect(addHostEntry({ id: "h1", transport: "http", url: "http://h1:7433" }, regPath).ok).toBe(true);
|
||||
const sshRes = updateHostEntry("h1", { target: "somewhere" }, regPath);
|
||||
expect(sshRes.ok).toBe(false);
|
||||
if (!sshRes.ok) expect(sshRes.error).toContain("target");
|
||||
});
|
||||
|
||||
it("edit clears an optional field with an empty string", () => {
|
||||
expect(addHostEntry({ id: "vm-a", transport: "ssh", target: "vm-a.local", user: "admin", notes: "old" }, regPath).ok).toBe(true);
|
||||
const res = updateHostEntry("vm-a", { user: "", notes: "" }, regPath);
|
||||
expect(res.ok).toBe(true);
|
||||
if (!res.ok) throw new Error(res.error);
|
||||
const loaded = loadHostRegistry(regPath);
|
||||
expect(loaded.ok).toBe(true);
|
||||
if (!loaded.ok) throw new Error(loaded.error);
|
||||
const entry = loaded.registry.hosts[0]!;
|
||||
expect(entry.user).toBeUndefined();
|
||||
expect(entry.notes).toBeUndefined();
|
||||
expect(entry.target).toBe("vm-a.local");
|
||||
});
|
||||
|
||||
it("edit refuses an invalid required value and leaves the registry untouched", () => {
|
||||
expect(addHostEntry({ id: "vm-a", transport: "ssh", target: "vm-a.local" }, regPath).ok).toBe(true);
|
||||
const before = fs.readFileSync(regPath, "utf8");
|
||||
const res = updateHostEntry("vm-a", { target: "" }, regPath);
|
||||
expect(res.ok).toBe(false);
|
||||
expect(fs.readFileSync(regPath, "utf8")).toBe(before);
|
||||
});
|
||||
|
||||
it("edit on a MISSING registry file is an error (nothing to edit)", () => {
|
||||
const res = updateHostEntry("vm-a", { target: "x" }, regPath);
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("edit refuses to touch an INVALID registry (never clobber)", () => {
|
||||
fs.writeFileSync(regPath, "hosts: [not-valid-yaml");
|
||||
const res = updateHostEntry("vm-a", { target: "x" }, regPath);
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) expect(res.error).toContain("refusing to modify an invalid registry");
|
||||
expect(fs.readFileSync(regPath, "utf8")).toBe("hosts: [not-valid-yaml");
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeHostEntry (rig host remove write path)", () => {
|
||||
let dir: string;
|
||||
let regPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "hostreg-remove-"));
|
||||
regPath = path.join(dir, "hosts.yaml");
|
||||
});
|
||||
afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
it("round-trip: add two hosts, remove one; loader reads back only the other", () => {
|
||||
expect(addHostEntry({ id: "vm-a", transport: "ssh", target: "vm-a.local" }, regPath).ok).toBe(true);
|
||||
expect(addHostEntry({ id: "vm-b", transport: "http", url: "http://vm-b" }, regPath).ok).toBe(true);
|
||||
const res = removeHostEntry("vm-a", regPath);
|
||||
expect(res.ok).toBe(true);
|
||||
if (!res.ok) throw new Error(res.error);
|
||||
expect(res.entry).toMatchObject({ id: "vm-a", transport: "ssh", target: "vm-a.local" });
|
||||
const loaded = loadHostRegistry(regPath);
|
||||
expect(loaded.ok).toBe(true);
|
||||
if (!loaded.ok) throw new Error(loaded.error);
|
||||
expect(loaded.registry.hosts).toHaveLength(1);
|
||||
expect(loaded.registry.hosts[0]!.id).toBe("vm-b");
|
||||
});
|
||||
|
||||
it("remove an unknown id names it and lists known ids", () => {
|
||||
expect(addHostEntry({ id: "vm-a", transport: "ssh", target: "vm-a.local" }, regPath).ok).toBe(true);
|
||||
const res = removeHostEntry("nope", regPath);
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.error).toContain("unknown host id 'nope'");
|
||||
expect(res.error).toContain("Known host ids");
|
||||
}
|
||||
});
|
||||
|
||||
it("remove on a MISSING registry file is an error", () => {
|
||||
const res = removeHostEntry("vm-a", regPath);
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("remove refuses to touch an INVALID registry (never clobber)", () => {
|
||||
fs.writeFileSync(regPath, "hosts: [not-valid-yaml");
|
||||
const res = removeHostEntry("vm-a", regPath);
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) expect(res.error).toContain("refusing to modify an invalid registry");
|
||||
expect(fs.readFileSync(regPath, "utf8")).toBe("hosts: [not-valid-yaml");
|
||||
});
|
||||
|
||||
it("remove of the last entry leaves a valid empty registry", () => {
|
||||
expect(addHostEntry({ id: "vm-a", transport: "ssh", target: "vm-a.local" }, regPath).ok).toBe(true);
|
||||
const res = removeHostEntry("vm-a", regPath);
|
||||
expect(res.ok).toBe(true);
|
||||
const loaded = loadHostRegistry(regPath);
|
||||
expect(loaded.ok).toBe(true);
|
||||
if (!loaded.ok) throw new Error(loaded.error);
|
||||
expect(loaded.registry.hosts).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -64,3 +64,123 @@ export function addHostEntry(rawEntry: Record<string, unknown>, path: string = d
|
||||
}
|
||||
return { ok: true, path, entry };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OPR.0.4.6.MH1 FR-1 extension (issue #2128, maintainer boundary) — the
|
||||
// edit/remove write paths, a VERBATIM MIRROR of the CLI twins in
|
||||
// packages/cli/src/host-registry.ts (byte-parity-pinned by
|
||||
// packages/daemon/test/hosts-registry-parity.test.ts). Same ONE write
|
||||
// contract as add: load-validate (a present-but-invalid registry is NEVER
|
||||
// overwritten), candidate through validateHostRegistry, atomic tmp+rename.
|
||||
//
|
||||
// Boundary pinned here: an edit keeps the host's id and transport FIXED;
|
||||
// unknown fields fail loud with the allowed set; an empty string clears an
|
||||
// OPTIONAL field (user, notes) while required fields (target, url) keep the
|
||||
// validator's non-empty rule; remove deletes ONLY the registry entry.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Fields an edit may touch, per transport. id/transport never appear here. */
|
||||
const EDITABLE_FIELDS: Record<"ssh" | "http", readonly string[]> = {
|
||||
ssh: ["target", "user", "notes"],
|
||||
http: ["url", "notes"],
|
||||
};
|
||||
|
||||
/** Optional editable fields an empty string CLEARS (drops the key). */
|
||||
const CLEARABLE_FIELDS = new Set(["user", "notes"]);
|
||||
|
||||
export type UpdateHostResult =
|
||||
| { ok: true; path: string; entry: HostEntry; previous: HostEntry }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type RemoveHostResult =
|
||||
| { ok: true; path: string; entry: HostEntry }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/** The write-path load: MISSING is an error here (nothing to edit/remove) with
|
||||
* the loader's canonical message; INVALID is a loud refusal, never clobbered. */
|
||||
function loadExistingForWrite(path: string): { ok: true; existing: HostEntry[] } | { ok: false; error: string } {
|
||||
const loaded = loadHostRegistry(path);
|
||||
if (loaded.ok) return { ok: true, existing: loaded.registry.hosts };
|
||||
const prefix = existsSync(path) ? "refusing to modify an invalid registry: " : "";
|
||||
return { ok: false, error: prefix + loaded.error };
|
||||
}
|
||||
|
||||
/** Unknown-id error for a WRITE: names the id, lists known ids (resolveHost's shape). */
|
||||
function unknownHostIdError(existing: HostEntry[], id: string): string {
|
||||
const knownIds = existing.map(h => h.id).slice(0, 10);
|
||||
const hint = knownIds.length > 0
|
||||
? ` Known host ids: ${knownIds.join(", ")}${existing.length > knownIds.length ? ` (+${existing.length - knownIds.length} more)` : ""}.`
|
||||
: " (registry is empty)";
|
||||
return `unknown host id '${id}'.${hint}`;
|
||||
}
|
||||
|
||||
/** The atomic registry write the write contract shares (tmp+rename, 0600). */
|
||||
function writeRegistryFile(path: string, hosts: HostEntry[]): string | null {
|
||||
try {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tmp = join(dirname(path), `.hosts.yaml.tmp-${process.pid}`);
|
||||
writeFileSync(tmp, stringifyYaml({ hosts }), { mode: 0o600 });
|
||||
renameSync(tmp, path);
|
||||
return null;
|
||||
} catch (err) {
|
||||
return `failed to write host registry at ${path}: ${(err as Error).message}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply an edit to ONE entry: id/transport fixed, unknown fields refused,
|
||||
* empty string clears an optional field, then the WHOLE registry validates. */
|
||||
export function updateHostEntry(id: string, changes: Record<string, unknown>, path: string = defaultHostRegistryPath()): UpdateHostResult {
|
||||
const loaded = loadExistingForWrite(path);
|
||||
if (!loaded.ok) return loaded;
|
||||
const idx = loaded.existing.findIndex(h => h.id === id);
|
||||
if (idx === -1) return { ok: false, error: unknownHostIdError(loaded.existing, id) };
|
||||
const previous = loaded.existing[idx]!;
|
||||
|
||||
if (changes["id"] !== undefined && changes["id"] !== id) {
|
||||
return { ok: false, error: `an edit keeps the host's id fixed — '${id}' cannot become '${String(changes["id"])}'` };
|
||||
}
|
||||
if (changes["transport"] !== undefined) {
|
||||
return { ok: false, error: `an edit keeps the host's transport fixed — '${id}' stays '${previous.transport}'` };
|
||||
}
|
||||
const allowed = EDITABLE_FIELDS[previous.transport];
|
||||
const effective: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (key === "id") continue; // an id echo naming the same host is a no-op
|
||||
if (!allowed.includes(key)) {
|
||||
return { ok: false, error: `field '${key}' is not editable on an ${previous.transport} host (editable fields: ${allowed.join(", ")})` };
|
||||
}
|
||||
effective[key] = value;
|
||||
}
|
||||
if (Object.keys(effective).length === 0) {
|
||||
return { ok: false, error: "no changes given" };
|
||||
}
|
||||
|
||||
const updated = { ...previous } as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(effective)) {
|
||||
if (CLEARABLE_FIELDS.has(key) && value === "") delete updated[key];
|
||||
else updated[key] = value;
|
||||
}
|
||||
const candidate = loaded.existing.map((h, i) => (i === idx ? updated as unknown as HostEntry : h));
|
||||
const validated = validateHostRegistry({ hosts: candidate }, path);
|
||||
if (!validated.ok) return { ok: false, error: validated.error };
|
||||
const entry = validated.registry.hosts.find(h => h.id === id);
|
||||
if (!entry) return { ok: false, error: `host '${id}' did not survive validation` };
|
||||
const writeError = writeRegistryFile(path, validated.registry.hosts);
|
||||
if (writeError) return { ok: false, error: writeError };
|
||||
return { ok: true, path, entry, previous };
|
||||
}
|
||||
|
||||
/** Remove ONE entry — only the registry entry; nothing else is touched. */
|
||||
export function removeHostEntry(id: string, path: string = defaultHostRegistryPath()): RemoveHostResult {
|
||||
const loaded = loadExistingForWrite(path);
|
||||
if (!loaded.ok) return loaded;
|
||||
const idx = loaded.existing.findIndex(h => h.id === id);
|
||||
if (idx === -1) return { ok: false, error: unknownHostIdError(loaded.existing, id) };
|
||||
const entry = loaded.existing[idx]!;
|
||||
const remaining = loaded.existing.filter((_, i) => i !== idx);
|
||||
const validated = validateHostRegistry({ hosts: remaining }, path);
|
||||
if (!validated.ok) return { ok: false, error: validated.error };
|
||||
const writeError = writeRegistryFile(path, validated.registry.hosts);
|
||||
if (writeError) return { ok: false, error: writeError };
|
||||
return { ok: true, path, entry };
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
// OPR.0.4.6.MH1 FR-5/FR-6 — THE narrow named host add/pair route family
|
||||
// (arch B1 ruling, pin P1: host add + the pair handshake ONLY — no
|
||||
// generic registry-write route exists, remove/edit stay out of scope;
|
||||
// the daemon reader module stays read-only-forever).
|
||||
// (arch B1 ruling, pin P1: named verbs ONLY — no generic REST registry-write
|
||||
// route exists; edit/remove joined the named family as the bounded #2128
|
||||
// extension (maintainer-approved boundary: id+transport fixed on edit,
|
||||
// secret VALUES refused, invalid registry never clobbered, remove deletes
|
||||
// only the registry entry); the daemon reader module stays read-only-forever).
|
||||
//
|
||||
// Surface map:
|
||||
// POST /api/hosts/pair-request TARGET side, OPEN (pre-token
|
||||
@@ -22,6 +24,15 @@
|
||||
// delegates to the parity-pinned
|
||||
// writer twin (P3); never accepts
|
||||
// secret VALUES (P2).
|
||||
// POST /api/hosts/update LOCAL side, WRITE (bearer-gated like
|
||||
// /add). Edit ONE entry through the
|
||||
// same writer twin: id+transport FIXED,
|
||||
// unknown fields fail loud, empty string
|
||||
// clears an optional field.
|
||||
// POST /api/hosts/remove LOCAL side, WRITE (bearer-gated like
|
||||
// /add). Deletes ONLY the registry entry
|
||||
// — no credential files, no learned
|
||||
// identity, no selection changes.
|
||||
// POST /api/hosts/pair LOCAL side, WRITE. The browser's
|
||||
// pair-client seam (B1: the UI's
|
||||
// write seam is its local daemon).
|
||||
@@ -41,7 +52,7 @@ import { randomUUID, randomInt } from "node:crypto";
|
||||
import { connect } from "node:net";
|
||||
import { authBearerTokenMiddleware } from "../middleware/auth-bearer-token.js";
|
||||
import { getOpenRigHome } from "../openrig-compat.js";
|
||||
import { addHostEntry } from "../domain/hosts/hosts-registry-writer.js";
|
||||
import { addHostEntry, updateHostEntry, removeHostEntry } from "../domain/hosts/hosts-registry-writer.js";
|
||||
import { defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, type HostEntry } from "../domain/hosts/hosts-registry-reader.js";
|
||||
import type { SettingsStore } from "../domain/user-settings/settings-store.js";
|
||||
import type { QueueRepository } from "../domain/queue-repository.js";
|
||||
@@ -89,6 +100,8 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
|
||||
// bearer is configured, pass-through on loopback/tailnet-trust daemons.
|
||||
const requireAuth = authBearerTokenMiddleware({ expectedToken: bearerToken });
|
||||
router.use("/add", requireAuth);
|
||||
router.use("/update", requireAuth);
|
||||
router.use("/remove", requireAuth);
|
||||
router.use("/pair", requireAuth);
|
||||
router.use("/pair/:pairId", requireAuth);
|
||||
|
||||
@@ -265,6 +278,73 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
|
||||
return c.json({ ok: true, entry: res.entry, path: res.path });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// OPR.0.4.6.MH1 FR-1 extension (issue #2128): the two named write verbs
|
||||
// beside /add — POST /update and POST /remove, both bearer-gated exactly
|
||||
// like /add (no new auth surface) and both refusing secret-shaped fields
|
||||
// with add's own no_secret_values check BEFORE any write. Update keeps the
|
||||
// host's id and transport FIXED (the boundary's first rule), refuses
|
||||
// unknown fields and invalid values through the SAME writer+validator as
|
||||
// add, and never clobbers an invalid registry. Remove deletes ONLY the
|
||||
// registry entry — no credential files, no learned identity, no selection
|
||||
// changes. Both responses report wasSelected, read exactly like the GET
|
||||
// route reads it, so a dashboard can tell its user when the selection now
|
||||
// names a removed or just-edited entry; the pointer itself is never
|
||||
// rewritten here.
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
function selectedHost(c: { get: (key: string) => unknown }): string {
|
||||
const store = c.get("settingsStore" as never) as SettingsStore | undefined;
|
||||
return (store?.resolveOne("host.selected").value as string | undefined) ?? "local";
|
||||
}
|
||||
|
||||
router.post("/update", async (c) => {
|
||||
const body = (await c.req.json<Record<string, unknown>>().catch(() => null));
|
||||
if (!body || typeof body !== "object") {
|
||||
return c.json({ error: "invalid_host_entry", message: "body must be a host entry object with at least { id }" }, 400);
|
||||
}
|
||||
const secretField = SECRET_SHAPED_FIELDS.find((f) => f in body);
|
||||
if (secretField) {
|
||||
return c.json({
|
||||
error: "no_secret_values",
|
||||
message: `field '${secretField}' looks like a secret VALUE — hosts.yaml carries bearer POINTERS only (bearer_env / bearer_file). Nothing was written.`,
|
||||
}, 400);
|
||||
}
|
||||
const id = body["id"];
|
||||
if (typeof id !== "string" || !id.trim()) {
|
||||
return c.json({ error: "host_id_required", message: "body.id must be a non-empty host id to update" }, 400);
|
||||
}
|
||||
const { id: _id, ...changes } = body;
|
||||
const res = updateHostEntry(id.trim(), changes);
|
||||
if (!res.ok) {
|
||||
return c.json({ error: "invalid_host_entry", message: res.error }, 400);
|
||||
}
|
||||
return c.json({ ok: true, entry: res.entry, previous: res.previous, path: res.path, wasSelected: id.trim() === selectedHost(c) });
|
||||
});
|
||||
|
||||
router.post("/remove", async (c) => {
|
||||
const body = (await c.req.json<Record<string, unknown>>().catch(() => null));
|
||||
if (!body || typeof body !== "object") {
|
||||
return c.json({ error: "invalid_host_entry", message: "body must be an object with { id }" }, 400);
|
||||
}
|
||||
const secretField = SECRET_SHAPED_FIELDS.find((f) => f in body);
|
||||
if (secretField) {
|
||||
return c.json({
|
||||
error: "no_secret_values",
|
||||
message: `field '${secretField}' looks like a secret VALUE — hosts.yaml carries bearer POINTERS only (bearer_env / bearer_file). Nothing was written.`,
|
||||
}, 400);
|
||||
}
|
||||
const id = body.id;
|
||||
if (typeof id !== "string" || !id.trim()) {
|
||||
return c.json({ error: "host_id_required", message: "body.id must be a non-empty host id to remove" }, 400);
|
||||
}
|
||||
const res = removeHostEntry(id.trim());
|
||||
if (!res.ok) {
|
||||
return c.json({ error: "invalid_host_entry", message: res.error }, 400);
|
||||
}
|
||||
return c.json({ ok: true, entry: res.entry, path: res.path, wasSelected: id.trim() === selectedHost(c) });
|
||||
});
|
||||
|
||||
router.post("/pair", async (c) => {
|
||||
const body = (await c.req.json<{ url?: string; id?: string; requester?: string; human?: string }>().catch(() => ({}))) as { url?: string; id?: string; requester?: string; human?: string };
|
||||
const rawUrl = (body.url ?? "").trim();
|
||||
|
||||
@@ -34,16 +34,20 @@ import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { isHumanSeatSessionRef, parseSessionName } from "../src/domain/session-name.js";
|
||||
import { addHostEntry as daemonAddHostEntry } from "../src/domain/hosts/hosts-registry-writer.js";
|
||||
import { addHostEntry as cliAddHostEntry } from "../../cli/src/host-registry.js";
|
||||
import { updateHostEntry as daemonUpdateHostEntry } from "../src/domain/hosts/hosts-registry-writer.js";
|
||||
import { removeHostEntry as daemonRemoveHostEntry } from "../src/domain/hosts/hosts-registry-writer.js";
|
||||
import type { HumanFragment, LoadResult } from "../src/domain/gateway/human-registry.js";
|
||||
import type { SettingsStore } from "../src/domain/user-settings/settings-store.js";
|
||||
import { hostsRoutes } from "../src/routes/hosts.js";
|
||||
|
||||
const BEARER = "test-bearer-token-fixture";
|
||||
|
||||
const human = { entityId: "alex", address: "alex@external", class: "human" } as HumanFragment;
|
||||
function buildApp(queueRepo: QueueRepository, bearerToken: string | null, humanRegistry: () => LoadResult = () => ({ ok: true, entities: [human] })): Hono {
|
||||
function buildApp(queueRepo: QueueRepository, bearerToken: string | null, humanRegistry: () => LoadResult = () => ({ ok: true, entities: [human] }), settingsStore?: { resolveOne: (k: string) => { value?: unknown } }): Hono {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("queueRepo" as never, queueRepo);
|
||||
c.set("settingsStore" as never, settingsStore);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/hosts", hostsRoutes({ bearerToken, humanRegistry }));
|
||||
@@ -181,6 +185,179 @@ describe("POST /api/hosts/add — the narrow named add seam", () => {
|
||||
expect(existsSync(join(home, "hosts.yaml"))).toBe(false);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// issue #2128 (maintainer-approved boundary): POST /api/hosts/update + /remove
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
it("POST /api/hosts/update edits fields keeping id+transport fixed, returns previous+entry", async () => {
|
||||
const add = await app.request("/api/hosts/add", {
|
||||
method: "POST",
|
||||
headers: auth,
|
||||
body: JSON.stringify({ id: "vm-a", transport: "ssh", target: "vm-a.local", user: "admin", notes: "old" }),
|
||||
});
|
||||
expect(add.status).toBe(200);
|
||||
|
||||
const res = await app.request("/api/hosts/update", {
|
||||
method: "POST",
|
||||
headers: auth,
|
||||
body: JSON.stringify({ id: "vm-a", target: "vm-b.local", user: "bob", notes: "moved" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entry: { id: string; transport: string; target: string; user: string; notes: string }; previous: { id: string; transport: string } };
|
||||
expect(body.entry).toMatchObject({ id: "vm-a", transport: "ssh", target: "vm-b.local", user: "bob", notes: "moved" });
|
||||
expect(body.previous).toMatchObject({ id: "vm-a", transport: "ssh" });
|
||||
const yaml = readFileSync(join(home, "hosts.yaml"), "utf8");
|
||||
expect(yaml).toContain("vm-b.local");
|
||||
expect(yaml).toContain("user: bob");
|
||||
expect(yaml).toContain("notes: moved");
|
||||
});
|
||||
|
||||
it("POST /api/hosts/update cannot change the id (the body id SELECTS the entry; a rename attempt names it unknown)", async () => {
|
||||
await app.request("/api/hosts/add", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", transport: "ssh", target: "vm-a.local" }) });
|
||||
// body.id is the SELECTOR — sending a different id targets a host that
|
||||
// does not exist; vm-a is untouched (id+transport fixed by construction).
|
||||
const res = await app.request("/api/hosts/update", { method: "POST", headers: auth, body: JSON.stringify({ id: "renamed", target: "x" }) });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string; message: string };
|
||||
expect(body.message).toContain("unknown host id 'renamed'");
|
||||
const yaml = readFileSync(join(home, "hosts.yaml"), "utf8");
|
||||
expect(yaml).toContain("id: vm-a");
|
||||
expect(yaml).not.toContain("renamed");
|
||||
});
|
||||
|
||||
it("POST /api/hosts/update refuses fixed transport change", async () => {
|
||||
await app.request("/api/hosts/add", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", transport: "ssh", target: "vm-a.local" }) });
|
||||
const res = await app.request("/api/hosts/update", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", transport: "http" }) });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string; message: string };
|
||||
expect(body.message).toContain("transport fixed");
|
||||
});
|
||||
|
||||
it("POST /api/hosts/update refuses unknown field for the transport", async () => {
|
||||
await app.request("/api/hosts/add", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", transport: "ssh", target: "vm-a.local" }) });
|
||||
const res = await app.request("/api/hosts/update", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", url: "http://x" }) });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string; message: string };
|
||||
expect(body.message).toContain("not editable");
|
||||
});
|
||||
|
||||
it("POST /api/hosts/update with no changes is refused", async () => {
|
||||
await app.request("/api/hosts/add", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", transport: "ssh", target: "vm-a.local" }) });
|
||||
const res = await app.request("/api/hosts/update", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a" }) });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string; message: string };
|
||||
expect(body.message).toMatch(/no changes/i);
|
||||
});
|
||||
|
||||
it("POST /api/hosts/update clears optional fields with empty strings", async () => {
|
||||
await app.request("/api/hosts/add", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", transport: "ssh", target: "vm-a.local", user: "admin", notes: "old" }) });
|
||||
const res = await app.request("/api/hosts/update", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", user: "", notes: "" }) });
|
||||
expect(res.status).toBe(200);
|
||||
const yaml = readFileSync(join(home, "hosts.yaml"), "utf8");
|
||||
expect(yaml).not.toMatch(/user:/);
|
||||
expect(yaml).not.toMatch(/notes:/);
|
||||
expect(yaml).toContain("target: vm-a.local");
|
||||
});
|
||||
|
||||
it("POST /api/hosts/update refuses an invalid required value and leaves bytes untouched", async () => {
|
||||
await app.request("/api/hosts/add", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", transport: "ssh", target: "vm-a.local" }) });
|
||||
const before = readFileSync(join(home, "hosts.yaml"), "utf8");
|
||||
const res = await app.request("/api/hosts/update", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", target: "" }) });
|
||||
expect(res.status).toBe(400);
|
||||
expect(readFileSync(join(home, "hosts.yaml"), "utf8")).toBe(before);
|
||||
});
|
||||
|
||||
it("POST /api/hosts/update on an unknown host id names it and lists known ids", async () => {
|
||||
await app.request("/api/hosts/add", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", transport: "ssh", target: "vm-a.local" }) });
|
||||
const res = await app.request("/api/hosts/update", { method: "POST", headers: auth, body: JSON.stringify({ id: "nope", target: "x" }) });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string; message: string };
|
||||
expect(body.message).toContain("unknown host id 'nope'");
|
||||
expect(body.message).toContain("Known host ids");
|
||||
});
|
||||
|
||||
it("POST /api/hosts/update refuses to touch an INVALID registry (never clobber)", async () => {
|
||||
writeFileSync(join(home, "hosts.yaml"), "hosts: [not-valid-yaml");
|
||||
const res = await app.request("/api/hosts/update", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", target: "x" }) });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string; message: string };
|
||||
expect(body.message).toContain("refusing to modify an invalid registry");
|
||||
expect(readFileSync(join(home, "hosts.yaml"), "utf8")).toBe("hosts: [not-valid-yaml");
|
||||
});
|
||||
|
||||
it("POST /api/hosts/remove deletes only the registry entry, returns previous entry", async () => {
|
||||
await app.request("/api/hosts/add", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", transport: "ssh", target: "vm-a.local" }) });
|
||||
await app.request("/api/hosts/add", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-b", transport: "http", url: "http://vm-b" }) });
|
||||
|
||||
const res = await app.request("/api/hosts/remove", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a" }) });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { entry: { id: string } };
|
||||
expect(body.entry).toMatchObject({ id: "vm-a", transport: "ssh" });
|
||||
|
||||
const loaded = await app.request("/api/hosts", { headers: auth });
|
||||
const body2 = await loaded.json() as { hosts: Array<{ id: string }> };
|
||||
expect(body2.hosts.map(h => h.id)).toEqual(["vm-b"]);
|
||||
});
|
||||
|
||||
it("POST /api/hosts/remove refuses an unknown id", async () => {
|
||||
await app.request("/api/hosts/add", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", transport: "ssh", target: "vm-a.local" }) });
|
||||
const res = await app.request("/api/hosts/remove", { method: "POST", headers: auth, body: JSON.stringify({ id: "nope" }) });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string; message: string };
|
||||
expect(body.message).toContain("unknown host id 'nope'");
|
||||
});
|
||||
|
||||
it("POST /api/hosts/remove on an INVALID registry refuses and clobbers nothing", async () => {
|
||||
await app.request("/api/hosts/add", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", transport: "ssh", target: "vm-a.local" }) });
|
||||
writeFileSync(join(home, "hosts.yaml"), "hosts: [not-valid-yaml");
|
||||
const res = await app.request("/api/hosts/remove", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a" }) });
|
||||
expect(res.status).toBe(400);
|
||||
expect(readFileSync(join(home, "hosts.yaml"), "utf8")).toBe("hosts: [not-valid-yaml");
|
||||
});
|
||||
|
||||
it("POST /api/hosts/update and /remove are bearer-gated (401 without the token)", async () => {
|
||||
await app.request("/api/hosts/add", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", transport: "ssh", target: "vm-a.local" }) });
|
||||
const bare = { "Content-Type": "application/json" };
|
||||
const r1 = await app.request("/api/hosts/update", { method: "POST", headers: bare, body: JSON.stringify({ id: "vm-a", target: "x" }) });
|
||||
const r2 = await app.request("/api/hosts/remove", { method: "POST", headers: bare, body: JSON.stringify({ id: "vm-a" }) });
|
||||
expect(r1.status).toBe(401);
|
||||
expect(r2.status).toBe(401);
|
||||
});
|
||||
|
||||
it("POST /api/hosts/update and /remove work without a bearer when the daemon runs tokenless", async () => {
|
||||
const tdb = createDb();
|
||||
migrate(tdb, [coreSchema, eventsSchema, queueItemsSchema, queueTransitionsSchema, queueItemSummarySchema, queueItemEvidenceRefSchema]);
|
||||
const tokenlessApp = buildApp(new QueueRepository(tdb, new EventBus(tdb), { validateRig: () => true }), null);
|
||||
await tokenlessApp.request("/api/hosts/add", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: "vm-a", transport: "ssh", target: "vm-a.local" }) });
|
||||
const up = await tokenlessApp.request("/api/hosts/update", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: "vm-a", target: "vm-b.local" }) });
|
||||
const rm = await tokenlessApp.request("/api/hosts/remove", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: "vm-a" }) });
|
||||
expect(up.status).toBe(200);
|
||||
expect(rm.status).toBe(200);
|
||||
tdb.close();
|
||||
});
|
||||
|
||||
it("POST /api/hosts/update / /remove are idempotent against re-running against the same registry", async () => {
|
||||
await app.request("/api/hosts/add", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", transport: "ssh", target: "vm-a.local" }) });
|
||||
const u1 = await app.request("/api/hosts/update", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", notes: "once" }) });
|
||||
const u2 = await app.request("/api/hosts/update", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", notes: "once" }) });
|
||||
expect(u1.status).toBe(200);
|
||||
expect(u2.status).toBe(200);
|
||||
const r1 = await app.request("/api/hosts/remove", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a" }) });
|
||||
const r2 = await app.request("/api/hosts/remove", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a" }) });
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r2.status).toBe(400); // idempotent-ish: removing again names the unknown id
|
||||
});
|
||||
|
||||
// rev1-r1 D (updated for #2128 boundary): POST /add + pair handshake still
|
||||
// remain the named family; update/remove were added as a deliberately
|
||||
// bounded extension — their routes exist with the same bearer gating.
|
||||
it("update/remove routes exist beside add + pair (the family now: add, update, remove, pair-request x2)", async () => {
|
||||
const update = await app.request("/api/hosts/update", { method: "POST", headers: auth, body: JSON.stringify({ id: "none", target: "x" }) });
|
||||
expect(update.status).toBe(400); // route exists → not 404
|
||||
const remove = await app.request("/api/hosts/remove", { method: "POST", headers: auth, body: JSON.stringify({ id: "none" }) });
|
||||
expect(remove.status).toBe(400); // route exists → not 404
|
||||
});
|
||||
|
||||
it("the write seam is bearer-gated (401 without the token when one is configured)", async () => {
|
||||
const res = await app.request("/api/hosts/add", {
|
||||
method: "POST",
|
||||
@@ -190,12 +367,13 @@ describe("POST /api/hosts/add — the narrow named add seam", () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
// rev1-r1 D: P1's "no generic registry-write route" pinned as a
|
||||
// NEGATIVE — the surface is exactly add + the pair handshake; anything
|
||||
// remove/edit-shaped does not exist.
|
||||
it("R1-D: no generic registry-write route exists (remove/edit/PUT/DELETE all 404)", async () => {
|
||||
// rev1-r1 D (updated for #2128 boundary): P1's "no generic registry-write
|
||||
// route" still holds for GENERIC verbs — PUT/DELETE/PATCH keep 404. The
|
||||
// surface is now add + update + remove + the pair handshake (the issue's
|
||||
// maintainer-approved boundary), still bearer-gated; there is no
|
||||
// REST-generic resource route and no /edit alias.
|
||||
it("R1-D: no GENERIC registry-write route exists (PUT/DELETE/PATCH all 404; only named add/update/remove verbs)", async () => {
|
||||
for (const [method, url] of [
|
||||
["POST", "/api/hosts/remove"],
|
||||
["POST", "/api/hosts/edit"],
|
||||
["PUT", "/api/hosts"],
|
||||
["PUT", "/api/hosts/vps-a"],
|
||||
@@ -410,6 +588,85 @@ describe("pair-request — the target-side issuance handshake (FR-6)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue #2128 boundary — selection reporting and secret-field refusal at the daemon door", () => {
|
||||
let db: Database.Database;
|
||||
let home: string;
|
||||
let savedHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), "mh1-hosts-edit-remove-"));
|
||||
savedHome = process.env["OPENRIG_HOME"];
|
||||
process.env["OPENRIG_HOME"] = home;
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema, eventsSchema, queueItemsSchema, queueTransitionsSchema, queueItemSummarySchema, queueItemEvidenceRefSchema]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
if (savedHome === undefined) delete process.env["OPENRIG_HOME"];
|
||||
else process.env["OPENRIG_HOME"] = savedHome;
|
||||
});
|
||||
|
||||
const auth = { Authorization: `Bearer ${BEARER}`, "Content-Type": "application/json" };
|
||||
|
||||
function makeApp(selected: string): Hono {
|
||||
const repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true });
|
||||
const fakeStore: SettingsStore = { resolveOne: (k) => ({ value: k === "host.selected" ? selected : undefined }) };
|
||||
return buildApp(repo, BEARER, () => ({ ok: true, entities: [human] }), fakeStore);
|
||||
}
|
||||
|
||||
function seed(app: Hono) {
|
||||
return app.request("/api/hosts/add", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", transport: "ssh", target: "vm-a.local" }) });
|
||||
}
|
||||
|
||||
it("POST /api/hosts/update reports wasSelected true for the selected host", async () => {
|
||||
const app = makeApp("vm-a");
|
||||
await seed(app);
|
||||
const res = await app.request("/api/hosts/update", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", target: "vm-b.local" }) });
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json() as { wasSelected: boolean }).wasSelected).toBe(true);
|
||||
});
|
||||
|
||||
it("POST /api/hosts/update reports wasSelected false for an unselected host", async () => {
|
||||
const app = makeApp("other-host");
|
||||
await seed(app);
|
||||
const res = await app.request("/api/hosts/update", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", target: "vm-b.local" }) });
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json() as { wasSelected: boolean }).wasSelected).toBe(false);
|
||||
});
|
||||
|
||||
it("POST /api/hosts/remove reports wasSelected true for the selected host and leaves the pointer", async () => {
|
||||
const app = makeApp("vm-a");
|
||||
await seed(app);
|
||||
const res = await app.request("/api/hosts/remove", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a" }) });
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json() as { wasSelected: boolean }).wasSelected).toBe(true);
|
||||
const listed = await app.request("/api/hosts", { headers: auth });
|
||||
expect((await listed.json() as { selected: string }).selected).toBe("vm-a"); // unchanged: the route never rewrites the pointer
|
||||
});
|
||||
|
||||
it("POST /api/hosts/update refuses secret-shaped fields with no_secret_values and writes nothing", async () => {
|
||||
const app = makeApp("other-host");
|
||||
await seed(app);
|
||||
const before = readFileSync(join(home, "hosts.yaml"), "utf8");
|
||||
const res = await app.request("/api/hosts/update", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", target: "vm-b.local", token: "s3cr3t-value" }) });
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json() as { error: string }).error).toBe("no_secret_values");
|
||||
expect(readFileSync(join(home, "hosts.yaml"), "utf8")).toBe(before);
|
||||
});
|
||||
|
||||
it("POST /api/hosts/remove refuses secret-shaped fields with no_secret_values and writes nothing", async () => {
|
||||
const app = makeApp("other-host");
|
||||
await seed(app);
|
||||
const before = readFileSync(join(home, "hosts.yaml"), "utf8");
|
||||
const res = await app.request("/api/hosts/remove", { method: "POST", headers: auth, body: JSON.stringify({ id: "vm-a", token: "s3cr3t-value" }) });
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json() as { error: string }).error).toBe("no_secret_values");
|
||||
expect(readFileSync(join(home, "hosts.yaml"), "utf8")).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("local pair-client seam — POST /pair + GET /pair/:id (the browser's write seam, B1)", () => {
|
||||
let db: Database.Database;
|
||||
let app: Hono;
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
// through BOTH validators and must agree verdict-for-verdict; a divergence
|
||||
// here means one twin drifted.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
resolveHost,
|
||||
resolvePlacementHost,
|
||||
} from "../src/domain/hosts/hosts-registry-reader.js";
|
||||
import { validateHostRegistry as cliValidate } from "../../cli/src/host-registry.js";
|
||||
import { validateHostRegistry as cliValidate, addHostEntry as cliAddHostEntry, updateHostEntry as cliUpdateHostEntry, removeHostEntry as cliRemoveHostEntry } from "../../cli/src/host-registry.js";
|
||||
import { addHostEntry as daemonAddHostEntry, updateHostEntry as daemonUpdateHostEntry, removeHostEntry as daemonRemoveHostEntry } from "../src/domain/hosts/hosts-registry-writer.js";
|
||||
|
||||
const SRC = "/fixture/hosts.yaml";
|
||||
|
||||
@@ -86,6 +87,75 @@ describe("hosts-registry reader — CLI/daemon validator parity (shared fixtures
|
||||
}
|
||||
});
|
||||
|
||||
describe("hosts-registry writer twins — update/remove byte parity (issue #2128)", () => {
|
||||
let dirA: string; // CLI twin
|
||||
let dirB: string; // daemon twin
|
||||
|
||||
beforeEach(() => {
|
||||
dirA = mkdtempSync(join(tmpdir(), "mw-parity-cli-"));
|
||||
dirB = mkdtempSync(join(tmpdir(), "mw-parity-daemon-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dirA, { recursive: true, force: true });
|
||||
rmSync(dirB, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const SEED: Array<Record<string, unknown>> = [
|
||||
{ id: "vm-a", transport: "ssh", target: "vm-a.local", user: "admin", notes: "old" },
|
||||
{ id: "h1", transport: "http", url: "http://h1:7433", bearer_env: "H1_TOKEN" },
|
||||
];
|
||||
|
||||
function seedBoth(pathA: string, pathB: string) {
|
||||
for (const entry of SEED) {
|
||||
expect(cliAddHostEntry(entry, pathA).ok).toBe(true);
|
||||
expect(daemonAddHostEntry(entry, pathB).ok).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
it("same edit/remove sequence in → identical yaml bytes out", () => {
|
||||
const pathA = join(dirA, "hosts.yaml");
|
||||
const pathB = join(dirB, "hosts.yaml");
|
||||
seedBoth(pathA, pathB);
|
||||
|
||||
expect(cliUpdateHostEntry("vm-a", { target: "vm-b.local", user: "bob", notes: "moved" }, pathA).ok).toBe(true);
|
||||
expect(daemonUpdateHostEntry("vm-a", { target: "vm-b.local", user: "bob", notes: "moved" }, pathB).ok).toBe(true);
|
||||
expect(readFileSync(pathB, "utf8")).toBe(readFileSync(pathA, "utf8"));
|
||||
|
||||
expect(cliUpdateHostEntry("h1", { url: "http://h2:7433" }, pathA).ok).toBe(true);
|
||||
expect(daemonUpdateHostEntry("h1", { url: "http://h2:7433" }, pathB).ok).toBe(true);
|
||||
expect(readFileSync(pathB, "utf8")).toBe(readFileSync(pathA, "utf8"));
|
||||
|
||||
expect(cliRemoveHostEntry("vm-a", pathA).ok).toBe(true);
|
||||
expect(daemonRemoveHostEntry("vm-a", pathB).ok).toBe(true);
|
||||
expect(readFileSync(pathB, "utf8")).toBe(readFileSync(pathA, "utf8"));
|
||||
});
|
||||
|
||||
it("identical refusal verdicts: unknown id / fixed id / fixed transport / unknown field / no changes — same error text", () => {
|
||||
const pathA = join(dirA, "hosts.yaml");
|
||||
const pathB = join(dirB, "hosts.yaml");
|
||||
seedBoth(pathA, pathB);
|
||||
|
||||
const PAIRS: Array<[string, () => { ok: boolean; error?: string }, () => { ok: boolean; error?: string }]> = [
|
||||
["unknown id", () => cliUpdateHostEntry("nope", { target: "x" }, pathA), () => daemonUpdateHostEntry("nope", { target: "x" }, pathB)],
|
||||
["fixed id", () => cliUpdateHostEntry("vm-a", { id: "other" }, pathA), () => daemonUpdateHostEntry("vm-a", { id: "other" }, pathB)],
|
||||
["fixed transport", () => cliUpdateHostEntry("vm-a", { transport: "http" }, pathA), () => daemonUpdateHostEntry("vm-a", { transport: "http" }, pathB)],
|
||||
["unknown field", () => cliUpdateHostEntry("vm-a", { targets: "x" }, pathA), () => daemonUpdateHostEntry("vm-a", { targets: "x" }, pathB)],
|
||||
["no changes", () => cliUpdateHostEntry("vm-a", {}, pathA), () => daemonUpdateHostEntry("vm-a", {}, pathB)],
|
||||
["remove unknown", () => cliRemoveHostEntry("nope", pathA), () => daemonRemoveHostEntry("nope", pathB)],
|
||||
];
|
||||
for (const [label, cli, daemon] of PAIRS) {
|
||||
const a = cli();
|
||||
const b = daemon();
|
||||
expect(a.ok, label).toBe(false);
|
||||
expect(b.ok, label).toBe(false);
|
||||
expect((b as { error: string }).error.replaceAll(pathB, "<path>"), label)
|
||||
.toBe((a as { error: string }).error.replaceAll(pathA, "<path>"));
|
||||
}
|
||||
// nothing was written by any refusal
|
||||
expect(readFileSync(pathB, "utf8")).toBe(readFileSync(pathA, "utf8"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("hosts-registry reader — daemon behaviors", () => {
|
||||
it("missing file returns the canonical what/why/fix error, never a throw", () => {
|
||||
const res = loadHostRegistry("/nonexistent/hosts.yaml");
|
||||
|
||||
Reference in new issue
Block a user