Compare commits

...
Author SHA1 Message Date
review50-r3 6084770fd0 fix(daemon): a failed replace names where each key is, even when cleanup fails
replace_failed stays the error when removing the staged key fails too,
and its message no longer says the new key is held nowhere when a key
file still holds it. It says the enrollment's record and bindings are
unchanged, then names each key's location from the moves that finished
(the earlier key at key.json.prev, the new one in key.json or the staged
key.json.next) and the step to take by hand. The ordinary failure message
is unchanged.

The put-back now runs only when the earlier key was actually moved, so a
stale key.json.prev from an older failure isn't moved over the current
key.
2026-11-13 20:11:58 +00:00
review50-r3 ef06cc0eb0 fix: clearer help at a model picker, a stranded key, hub URLs, setup and seats without pods
A send refused at a program's own /model picker now says a model picker
is open and to close it (Esc) in that pane, instead of offering
--dangerously-interact, which would type into the picker and pick a model.
The refusal keeps its "(selection_prompt)" reason, and every other
prompt's refusal is unchanged.

When replacing a network enrollment fails and the earlier key can't be
put back, replace_failed now names the file to move back instead of
saying nothing changed, and the staged key is still cleaned up.

rig network status hides a hub URL's query string, which can carry a
pasted credential. rig setup prints the harness login line on every text
run, as rig doctor does. An assistant connected to a team names a seat of
a rig without pods by its recorded session, and leaves out one with no
session yet instead of failing for the whole team.

No new refusal.
2026-11-13 19:57:09 +00:00
11 changed files with 190 additions and 26 deletions

No files matched your search

+13 -1
View File
@@ -42,12 +42,24 @@ function defaultReadTokenFile(path: string): string {
return readFileSync(path, "utf8").trim();
}
/** A hub URL as status prints it: a query string can carry a credential the person pasted, so its value isn't shown. */
export function shownHubUrl(hubUrl: string): string {
try {
const url = new URL(hubUrl);
if (!url.search) return hubUrl;
url.search = "";
return `${url.toString()} (query hidden)`;
} catch {
return hubUrl;
}
}
function line(b: Binding): string {
const contact = b.lastContactAt ? `last contact ${b.lastContactAt}` : "no contact yet";
const state = b.state === "active" ? contact : `${b.state}${b.stateReason ? ` (${b.stateReason})` : ""}`;
const plain = b.unencrypted ? " UNENCRYPTED (http, allowed for this binding)" : "";
const conflicts = (b.closedElsewhere ?? []).map((c) => `\n hub item ${c.hubItem} was closed elsewhere before its result arrived; the result stays on ${c.localItem}`).join("");
return `${b.id} ${b.localSession} -> ${b.hubSeat} at ${b.hubUrl}${plain} ${state}${b.lastError && b.state === "active" ? ` last error: ${b.lastError}` : ""}${conflicts}${accountNote(b)}`;
return `${b.id} ${b.localSession} -> ${b.hubSeat} at ${shownHubUrl(b.hubUrl)}${plain} ${state}${b.lastError && b.state === "active" ? ` last error: ${b.lastError}` : ""}${conflicts}${accountNote(b)}`;
}
/** `rig network`: connect a selected local seat to a participant of a remote hub (an account's network rig, or
+2 -1
View File
@@ -954,9 +954,10 @@ tmux scrollback:
if (step.fixHint) console.log(` Fix: ${step.fixHint}`);
}
listChecks();
console.log("A harness needs a login only when a seat you start runs on it (rerun with --providers to leave it out). A built-in team can use one you didn't select: starter runs its reviewer on Codex.");
console.log("Run `rig doctor` to recheck the system and provider authentication.");
}
// Every run, as doctor says it: a setup that's ready for the providers chosen can still meet a built-in team's other one.
console.log("A harness needs a login only when a seat you start runs on it (rerun with --providers to leave it out). A built-in team can use one you didn't select: starter runs its reviewer on Codex.");
// Keep the conversation route available even after a dry run or incomplete setup,
// before the optional menu so a short output read still includes the handoff.
console.log("");
+8 -4
View File
@@ -717,10 +717,14 @@ async function namedSeats(client: DaemonClient, rigName: string, name: string, a
const { logicalId, rigName: inRig, canonicalSessionName } = (entry ?? {}) as Record<string, unknown>;
if (typeof logicalId !== "string" || inRig !== rigName) throw new Error(`the node list for ${rigName} has a malformed entry`);
const dot = logicalId.indexOf(".");
if (dot <= 0 || dot === logicalId.length - 1) throw new Error(`the node list for ${rigName} has a malformed entry`);
const podMember = `${logicalId.slice(0, dot)}-${logicalId.slice(dot + 1)}`;
const session = typeof canonicalSessionName === "string" && canonicalSessionName.endsWith(`@${rigName}`)
&& canonicalSessionName.length > rigName.length + 1 ? canonicalSessionName : `${podMember}@${rigName}`;
if (dot === 0 || dot === logicalId.length - 1) throw new Error(`the node list for ${rigName} has a malformed entry`);
const recorded = typeof canonicalSessionName === "string" && canonicalSessionName.endsWith(`@${rigName}`)
&& canonicalSessionName.length > rigName.length + 1 ? canonicalSessionName : null;
// A seat of a rig without pods has no pod in its logical id: it's named only by its recorded session, and one with
// none yet can't be named, so it's left out rather than read as a broken team.
if (dot === -1 && !recorded) continue;
const podMember = dot === -1 ? null : `${logicalId.slice(0, dot)}-${logicalId.slice(dot + 1)}`;
const session = recorded ?? `${podMember}@${rigName}`;
const own = session.slice(0, -(rigName.length + 1));
if (!addressable(session)) continue;
if (name === own) byOwn.add(session);
@@ -279,6 +279,22 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
expect(rows()).toBe(1);
});
it("still reads a team whose older seats have no pod, naming them by their recorded session", async () => {
namedSeat("team-a", "lead", "lead@team-a");
const rig = setup.rigRepo.listRigs().find((r: { name: string }) => r.name === "team-a")!;
const flat = setup.rigRepo.addNode(rig.id, "r01-dev", { role: "worker" });
setup.sessionRegistry.registerSession(flat.id, "r01-dev@team-a");
// No session yet: it can't be named, and it doesn't turn every name into "try again".
setup.rigRepo.addNode(rig.id, "r02-qa", { role: "worker" });
const { url } = await listen();
const ask = await connect(url);
expect((await ask("rig_queue_create", { qitemId: "qitem-pod-seat", destination: "lead", body: "Pod seat" })).isError).toBe(false);
expect((await ask("rig_queue_create", { qitemId: "qitem-flat-seat", destination: "r01-dev", body: "Flat seat" })).isError).toBe(false);
expect(repo.getById("qitem-flat-seat")).toMatchObject({ destinationSession: "r01-dev@team-a" });
expect((await ask("rig_queue_create", { destination: "r02-qa", body: "Unnamed" })).structured.code).toBe("destination_not_found");
expect(rows()).toBe(2);
});
it("an exact name wins over another seat's slug twin, as a name alone and as the configured receiver", async () => {
namedSeat("team-a", "lead", "pod-lead@team-a");
namedSeat("team-a", "other", "Pod.Lead@team-a");
+9 -1
View File
@@ -6,7 +6,7 @@ import Database from "better-sqlite3";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { networkCommand, type NetworkDeps } from "../src/commands/network.js";
import { networkCommand, shownHubUrl, type NetworkDeps } from "../src/commands/network.js";
import { STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
import { migrate } from "../../daemon/src/db/migrate.js";
@@ -178,3 +178,11 @@ it("sends the operator bearer on its writes, so a daemon with one configured tak
expect(process.exitCode).toBeUndefined();
expect(String(out.mock.calls.at(-1)?.[0])).toMatch(/^Disconnected netb-/);
});
// Only a user name or password in a hub URL is refused, so a query string can carry a pasted credential: status doesn't print it.
it("status hides a hub URL's query string and leaves a plain URL as it is", () => {
expect(shownHubUrl("https://hub.example.test/rig?token=s3cret&x=1")).toBe("https://hub.example.test/rig (query hidden)");
expect(shownHubUrl("https://hub.example.test/rig?token=s3cret")).not.toContain("s3cret");
expect(shownHubUrl("https://hub.example.test/rig")).toBe("https://hub.example.test/rig");
expect(shownHubUrl("not a url")).toBe("not a url");
});
+2
View File
@@ -162,6 +162,8 @@ describe("the installer's setup boundary", () => {
expect(r.exitCode).toBeUndefined();
expect(r.output).toContain("Setup complete.");
expect(r.output).toContain("codex_auth: Not selected");
// Ready for Claude alone, and still told that a built-in team's Codex seat needs that login, as doctor says.
expect(r.output).toContain("A built-in team can use one you didn't select: starter runs its reviewer on Codex.");
});
it("an uncertain probe is listed with its own error, not as a confirmed missing login", async () => {
@@ -4,6 +4,13 @@
const CLAUDE_MODEL_FOOTERS = ["Enter to set as default · s to use this session only · Esc to cancel"];
const CODEX_MODEL_FOOTERS = ["enter select · esc back", "enter default · s session · esc back"];
const CLAUDE_MODEL_HEADER = "Select model";
const isCodexModelHeader = (line: string) => line === "Select Model and Effort" || /^Select Reasoning Level for \S.*$/.test(line);
/** A send guard's evidence is one of these pickers' headers, which is what the two readers below return. */
export function isModelPickerHeader(evidence: string | null | undefined): boolean {
return evidence === CLAUDE_MODEL_HEADER || isCodexModelHeader(evidence ?? "");
}
/** The choice rows between a picker's instruction and its footer hold exactly one highlighted, numbered row. */
function oneHighlightedChoice(body: string[], pointer: string): boolean {
@@ -18,7 +25,7 @@ export function currentClaudeModelMenu(pane: string): string | null {
// OpenRig-launched Claude may draw its named seat rule below the menu footer.
if (/^─{3,} [\w.-]+@[\w.-]+ ─+$/.test(lines.at(-1) ?? "")) lines.pop();
if (!CLAUDE_MODEL_FOOTERS.includes(lines.at(-1) ?? "")) return null;
const header = lines.lastIndexOf("Select model");
const header = lines.lastIndexOf(CLAUDE_MODEL_HEADER);
if (header < 1 || !/^─{3,}$/.test(lines[header - 1]!) || !lines[header + 1]?.startsWith("Switch between Claude models.")) return null;
return oneHighlightedChoice(lines.slice(header + 2, -1), "❯") ? lines[header]! : null;
}
@@ -27,7 +34,7 @@ export function currentClaudeModelMenu(pane: string): string | null {
export function currentCodexModelPicker(pane: string): string | null {
const lines = pane.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
if (!CODEX_MODEL_FOOTERS.includes(lines.at(-1) ?? "")) return null;
const header = lines.map(line => line === "Select Model and Effort" || /^Select Reasoning Level for \S.*$/.test(line)).lastIndexOf(true);
const header = lines.map(isCodexModelHeader).lastIndexOf(true);
if (header < 0) return null;
return oneHighlightedChoice(lines.slice(header + 1, -1), "›") ? lines[header]! : null;
}
@@ -163,10 +163,11 @@ export class NetworkInstallationStore {
}
/** The new key goes in beside the old one and takes its place, then the row and the old installation's active bindings
* change in one transaction. A failure before that commits puts the old key back (with no old key, it removes the new
* one), so nothing here changes, though the account has already made the new installation and nothing here holds its
* key. After the commit the replace has happened: the ended bindings' names are returned, since the old installation
* may still list them and this key can't drop them, with any cleanup that didn't finish. */
* change in one transaction. A failure before that commits leaves the row and bindings as they were and puts the old
* key back (with no old key, it removes the new one), though the account has already made the new installation.
* Moving key files happens outside the transaction, so a put-back or cleanup that also fails is named in the error,
* with where each key is. After the commit the replace has happened: the ended bindings' names are returned, since
* the old installation may still list them and this key can't drop them, with any cleanup that didn't finish. */
private replace(bindings: NetworkBindingStore, old: NetworkInstallation, next: { id: string; name: string | null }, key: string, allowHttp: number): EnrollResult {
const file = networkInstallationKeyFile(this.home, old.id);
const staged = `${file}.next`;
@@ -175,19 +176,40 @@ export class NetworkInstallationStore {
rmSync(staged, { force: true });
const cleanup = writePrivateToken(staged, key);
const hadKey = existsSync(file);
let movedOld = false;
let movedNew = false;
try {
if (hadKey) renameSync(file, kept);
if (hadKey) { renameSync(file, kept); movedOld = true; }
renameSync(staged, file);
movedNew = true;
this.db.transaction(() => {
this.db.prepare("UPDATE network_installations SET installation_id = ?, name = ?, allow_http = ? WHERE id = ?").run(next.id, next.name, allowHttp, old.id);
bindings.markDisconnected(ending.map((b) => b.id), REPLACED_REASON);
})();
} catch (error) {
if (!hadKey) rmSync(file, { force: true });
else if (existsSync(kept)) renameSync(kept, file);
cleanup();
throw new NetworkBindingError("replace_failed",
`The account accepted the new code, but this daemon couldn't switch to it (${(error as Error).message.slice(0, 200)}), so nothing here changed. The account now has a new installation whose key nothing here holds: remove it on the account page, then enroll again with a new code.`, 500);
// Putting a key back or removing the staged one can fail too (a full or read-only disk). Then say where each key
// is, from the moves that finished, and say the new key is held nowhere only when that's established.
const byHand: string[] = [];
const newKeyAt: string[] = [];
try {
if (!hadKey) rmSync(file, { force: true });
else if (movedOld) renameSync(kept, file);
} catch (restore) {
const why = (restore as Error).message.slice(0, 200);
if (movedNew) newKeyAt.push(file);
byHand.push(hadKey
? `The earlier key couldn't be put back (${why}); it's at ${kept}${movedNew ? `, and ${file} holds the new installation's key` : ""}. Move ${kept} back to ${file} before you go on.`
: `The new key couldn't be removed (${why}): delete ${file} by hand.`);
}
try { cleanup(); } catch (left) {
if (!movedNew) newKeyAt.push(staged);
byHand.push(`The staged new key couldn't be removed (${(left as Error).message.slice(0, 200)}): delete ${staged} by hand.`);
}
const failed = `The account accepted the new code, but this daemon couldn't switch to it (${(error as Error).message.slice(0, 200)})`;
const held = newKeyAt.length === 0 ? "whose key nothing here holds" : `whose key is in ${newKeyAt.join(" and ")} until then`;
throw new NetworkBindingError("replace_failed", byHand.length === 0
? `${failed}, so nothing here changed. The account now has a new installation ${held}: remove it on the account page, then enroll again with a new code.`
: `${failed}. Its record and bindings here are unchanged, but its key files need a step by hand. ${byHand.join(" ")} The account now has a new installation ${held}: remove it on the account page, then enroll again with a new code.`, 500);
}
const problems: string[] = [];
try {
@@ -4,7 +4,7 @@ import type { ContextUsageStore } from "./context-usage-store.js";
import { physicalSessionName } from "./session-registry.js";
import { currentClaudeMcpMenu } from "./claude-mcp-menu.js";
import { currentClaudeThemeMenu } from "./claude-theme-menu.js";
import { currentClaudeModelMenu, currentCodexModelPicker } from "./native-model-picker.js";
import { currentClaudeModelMenu, currentCodexModelPicker, isModelPickerHeader } from "./native-model-picker.js";
import { currentClaudeResumePicker } from "./native-resume-probe.js";
import stringWidth from "string-width";
import { CODEX_LIVE_WORKING_FRAME_PATTERN, CODEX_TURN_STATUS_PATTERN } from "./codex-pane-patterns.js";
@@ -2086,6 +2086,13 @@ function clearAttentionHint(sessionName: string, reason: string): string {
: "";
}
/** The way past a refused prompt. A program's own /model picker is closed, not driven: text typed there picks a model. */
function promptRoute(sessionName: string, prompt: Pick<AgentActivity, "reason" | "evidence">): string {
return prompt.reason === "selection_prompt" && isModelPickerHeader(prompt.evidence)
? ` A model picker is open (${prompt.evidence}): close it (Esc) in that pane, then send again.`
: ` To deliberately drive the prompt: rig send ${sessionName} "<text>" --dangerously-interact --reason "<why>".`;
}
/** The caller's cancellation signal, or none for sends no caller owns (queue wakes, committed outbox deliveries). */
function callerSignal(opts?: SendOpts): AbortSignal | undefined {
return opts?.queueWake || opts?.committedOutboxIds ? undefined : opts?.signal;
@@ -3477,7 +3484,7 @@ export class SessionTransport {
recheck.state === "needs_input"
? {
reason: "target_needs_input",
error: `Refused: '${sessionName}' is at an interactive prompt (${recheck.reason}). A message must not select or approve it. To deliberately drive the prompt: rig send ${sessionName} "<text>" --dangerously-interact --reason "<why>".${clearAttentionHint(sessionName, recheck.reason)} No text was sent.`,
error: `Refused: '${sessionName}' is at an interactive prompt (${recheck.reason}). A message must not select or approve it.${promptRoute(sessionName, recheck)}${clearAttentionHint(sessionName, recheck.reason)} No text was sent.`,
}
: recheck.state === "unknown"
? {
@@ -3597,7 +3604,7 @@ export class SessionTransport {
reason: "target_needs_input",
sent: false, inputPhase: "not_reached",
activity: readiness,
error: `Refused: '${sessionName}' is at an interactive prompt (${readiness.reason}). A message must not select or approve it. To deliberately drive the prompt: rig send ${sessionName} "<text>" --dangerously-interact --reason "<why>".${clearAttentionHint(sessionName, readiness.reason)} No text was sent.`,
error: `Refused: '${sessionName}' is at an interactive prompt (${readiness.reason}). A message must not select or approve it.${promptRoute(sessionName, readiness)}${clearAttentionHint(sessionName, readiness.reason)} No text was sent.`,
};
}
} else if (readiness.state === "unknown") {
@@ -3,7 +3,7 @@
import { readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest";
import { classifyPaneActivity, SessionTransport } from "../src/domain/session-transport.js";
import { currentClaudeModelMenu, currentCodexModelPicker } from "../src/domain/native-model-picker.js";
import { currentClaudeModelMenu, currentCodexModelPicker, isModelPickerHeader } from "../src/domain/native-model-picker.js";
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
import { EventBus } from "../src/domain/event-bus.js";
import { RigRepository } from "../src/domain/rig-repository.js";
@@ -101,6 +101,17 @@ describe("an ordinary send never types into an open model picker", () => {
expect({ ok: sent.result.ok, reason: (sent.result as { reason?: string }).reason, written: sent.written })
.toEqual({ ok: false, reason: "target_needs_input", written: 0 });
expect(sent.result).toMatchObject({ sent: false });
// It says it's the model picker and to close it; the route that types into a prompt would pick a model here.
const header = runtime === "codex" ? currentCodexModelPicker(pane) : currentClaudeModelMenu(pane);
const error = (sent.result as { error?: string }).error ?? "";
expect(error).toContain("is at an interactive prompt (selection_prompt).");
expect(error).toContain(`A model picker is open (${header}): close it (Esc) in that pane, then send again.`);
expect(error).not.toContain("--dangerously-interact");
});
it("names only these pickers' headers as a model picker", () => {
for (const header of ["Select model", "Select Model and Effort", "Select Reasoning Level for GPT-6.1-Sol"]) expect(isModelPickerHeader(header)).toBe(true);
for (const other of ["Select tools", "Select Reasoning Level for", "Do you want to proceed?", "", null, undefined]) expect(isModelPickerHeader(other)).toBe(false);
});
it.each([
@@ -15,16 +15,22 @@ import { readNetworkCredential, readPrivateToken } from "../src/domain/network/n
import { networkInstallationKeyFile, type AccountFetch } from "../src/domain/network/network-installations.js";
import { networkRoutes } from "../src/routes/network.js";
/** Makes removing a path with this ending fail, the way a busy file does. */
/** Makes removing an existing path with this ending fail, the way a busy file does; and moving one, the way a read-only
* disk does. */
const failRemoval = vi.hoisted(() => ({ suffix: null as string | null }));
const failRename = vi.hoisted(() => ({ suffixes: [] as string[] }));
vi.mock("node:fs", async (importOriginal) => {
const real = await importOriginal<typeof import("node:fs")>();
return {
...real,
rmSync: (...args: Parameters<typeof real.rmSync>) => {
if (failRemoval.suffix && String(args[0]).endsWith(failRemoval.suffix)) throw Object.assign(new Error("EBUSY: resource busy or locked, rm"), { code: "EBUSY" });
if (failRemoval.suffix && String(args[0]).endsWith(failRemoval.suffix) && real.existsSync(String(args[0]))) throw Object.assign(new Error("EBUSY: resource busy or locked, rm"), { code: "EBUSY" });
return real.rmSync(...args);
},
renameSync: (...args: Parameters<typeof real.renameSync>) => {
if (failRename.suffixes.some((suffix) => String(args[0]).endsWith(suffix))) throw Object.assign(new Error("EROFS: read-only file system, rename"), { code: "EROFS" });
return real.renameSync(...args);
},
};
});
@@ -120,7 +126,7 @@ beforeEach(() => {
app.use("*", async (c, next) => { c.set("rigRepo" as never, rigs as never); await next(); });
app.route("/api/network", networkRoutes({ home, accountFetch: account.fetch, selfHostId: () => hostId, accountTimeoutMs: 1000 }));
});
afterEach(() => { failRemoval.suffix = null; db.close(); rmSync(home, { recursive: true, force: true }); });
afterEach(() => { failRemoval.suffix = null; failRename.suffixes = []; db.close(); rmSync(home, { recursive: true, force: true }); });
function seat(session: string): void {
const rig = rigs.listRigs().find((r) => r.name === "lab") ?? rigs.createRig("lab");
@@ -467,6 +473,74 @@ it("a replace whose old binding can't be ended changes nothing here: the switch
expect(now.bindings.find((b) => b.id === alpha.data.id)).toMatchObject({ state: "active" });
});
it("a failed replace whose old key can't be put back names where each key is, not that nothing changed", async () => {
seat("a@lab");
const first = await enroll();
const alpha = await connect("a@lab", "alpha");
const keyFile = networkInstallationKeyFile(home, first.data.id);
const oldKey = readPrivateToken(keyFile, () => "missing");
db.exec("CREATE TRIGGER fail_end BEFORE UPDATE OF state ON network_bindings BEGIN SELECT RAISE(ABORT, 'disk I/O error'); END");
failRename.suffixes = ["key.json.prev"];
const failed = await replace();
failRename.suffixes = [];
expect(failed).toMatchObject({ status: 500, data: { error: "replace_failed" } });
// The new key reached key.json before the transaction failed, so it's there now, beside the earlier one.
expect(failed.data.message).toContain("Its record and bindings here are unchanged, but its key files need a step by hand. The earlier key couldn't be put back (EROFS");
expect(failed.data.message).toContain(`it's at ${keyFile}.prev, and ${keyFile} holds the new installation's key. Move ${keyFile}.prev back to ${keyFile} before you go on.`);
expect(failed.data.message).toContain(`a new installation whose key is in ${keyFile} until then`);
expect(failed.data.message).not.toContain("nothing here");
expect(readdirSync(dirname(keyFile)).sort()).toEqual(["key.json", "key.json.prev"]);
expect(readPrivateToken(keyFile, () => "missing")).toBe(account.key);
expect(readPrivateToken(`${keyFile}.prev`, () => "missing")).toBe(oldKey);
const now = await status();
expect(now.installations.map((i) => i.installationId)).toEqual(["inst-1"]);
expect(now.bindings.find((b) => b.id === alpha.data.id)).toMatchObject({ state: "active" });
});
it("a failed replace keeps its labelled error when the put-back and the staged key's removal both fail", async () => {
seat("a@lab");
const first = await enroll();
const alpha = await connect("a@lab", "alpha");
const keyFile = networkInstallationKeyFile(home, first.data.id);
const oldKey = readPrivateToken(keyFile, () => "missing");
// The staged key can't take key.json's place, the earlier key can't come back, and the staged key can't be removed.
failRename.suffixes = ["key.json.next", "key.json.prev"];
failRemoval.suffix = "key.json.next";
const failed = await replace();
failRename.suffixes = [];
failRemoval.suffix = null;
expect(failed).toMatchObject({ status: 500, data: { error: "replace_failed" } });
expect(failed.data.message).toContain("couldn't switch to it (EROFS: read-only file system, rename)");
expect(failed.data.message).toContain(`The earlier key couldn't be put back (EROFS: read-only file system, rename); it's at ${keyFile}.prev. Move ${keyFile}.prev back to ${keyFile} before you go on.`);
expect(failed.data.message).toContain(`The staged new key couldn't be removed (EBUSY: resource busy or locked, rm): delete ${keyFile}.next by hand.`);
expect(failed.data.message).toContain(`a new installation whose key is in ${keyFile}.next until then`);
expect(readdirSync(dirname(keyFile)).sort()).toEqual(["key.json.next", "key.json.prev"]);
expect(readPrivateToken(`${keyFile}.prev`, () => "missing")).toBe(oldKey);
expect(readPrivateToken(`${keyFile}.next`, () => "missing")).toBe(account.key);
const now = await status();
expect(now.installations.map((i) => i.installationId)).toEqual(["inst-1"]);
expect(now.bindings.find((b) => b.id === alpha.data.id)).toMatchObject({ state: "active" });
});
it("a failed replace with no earlier key whose new key can't be removed says to delete it, and where the new key is", async () => {
seat("a@lab");
const first = await enroll();
const keyFile = networkInstallationKeyFile(home, first.data.id);
rmSync(keyFile);
db.exec("CREATE TRIGGER fail_replace BEFORE UPDATE ON network_installations BEGIN SELECT RAISE(ABORT, 'disk I/O error'); END");
failRemoval.suffix = "/key.json";
const failed = await replace();
failRemoval.suffix = null;
db.exec("DROP TRIGGER fail_replace");
expect(failed).toMatchObject({ status: 500, data: { error: "replace_failed" } });
expect(failed.data.message).toContain(`The new key couldn't be removed (EBUSY: resource busy or locked, rm): delete ${keyFile} by hand.`);
expect(failed.data.message).toContain(`a new installation whose key is in ${keyFile} until then`);
expect(failed.data.message).not.toContain("nothing here");
expect(readdirSync(dirname(keyFile))).toEqual(["key.json"]);
expect(readPrivateToken(keyFile, () => "missing")).toBe(account.key);
expect((await status()).installations.map((i) => i.installationId)).toEqual(["inst-1"]);
});
it("a replace whose old key's backup can't be removed after the switch still ends the old bindings, and reports the cleanup", async () => {
seat("a@lab");
const first = await enroll();