mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-13 18:49:34 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6084770fd0 | ||
|
|
ef06cc0eb0 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user