Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions changelog.d/link-code-take-back.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
### Fixed
- `link --code` takes its code back when it is ended by SIGTERM or by its terminal closing, not
only by Ctrl+C, and takes back only the code it showed: a pairing started since in Settings or
on another device keeps working.
Comment thread
devswha marked this conversation as resolved.
Resolved
- `link` keeps this PC in the account's list when renewing the listing loses the answer to its
removal.
7 changes: 4 additions & 3 deletions docs/relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,9 +300,10 @@ Both turn the relay on first when it is off, and wait until it is online.
(**Pair with a code**), the relay pairing link as a QR code drawn in the terminal, and this PC's
ID for the other device to compare. Both renew when they run out. When the relay mints no typed
code (a relay that is not Ovis's own, or one that refuses it), it says why and shows the QR code
alone, as Settings does. Ctrl+C, or any error, takes both back
(`POST /api/devices/pair/cancel`, which ends the pending six-digit code and the typed code bound
to it). Redeeming them follows the usual admission rules.
alone, as Settings does. Ctrl+C, SIGTERM, SIGHUP (the terminal closing), or any error, takes both
back (`POST /api/devices/pair/cancel` with `{ "code" }`, the six-digit code it showed, which ends
that pending code and the typed code bound to it; a pairing started since, in Settings or on
another device, stays). Redeeming them follows the usual admission rules.

**Which copy runs.** An in-app update (**Settings → Updates**) installs a new release beside the
plugin checkout and does not replace the checkout. `link` follows the release that runs, as
Expand Down
145 changes: 143 additions & 2 deletions scripts/plugin-link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ describe("ovis link, on the PC itself", () => {
let minted = 0;
let offers = 0;
let cancelled = 0;
const local = localPc((request, path) => {
const cancels: unknown[] = [];
const local = localPc(async (request, path) => {
if (path === "/api/relay" && request.method === "GET") return Response.json({ phase: "online", blocked: null });
if (path === "/api/relay/code") {
const code = codes[minted++]!;
Expand All @@ -88,7 +89,7 @@ describe("ovis link, on the PC itself", () => {
if (++offers === 2) stop.abort();
return Response.json({ url: encodeOffer({ v: 1, server_id: pc.server_id, server_public_key: pc.public_key, relay: RELAY, code: String(100000 + offers) }, "https://app.ovis.sh"), expires_at: new Date(time.now() + 600_000).toISOString() });
}
if (path === "/api/devices/pair/cancel") { cancelled++; return Response.json({ ok: true }); }
if (path === "/api/devices/pair/cancel") { cancelled++; cancels.push(await request.json()); return Response.json({ ok: true }); }
return undefined;
});
const out: string[] = [];
Expand All @@ -103,6 +104,8 @@ describe("ovis link, on the PC itself", () => {
// renewed when the pair ran out, as the Settings view does, and the code taken back on the way out
expect(time.waits).toEqual([600_000]);
expect(cancelled).toBe(1);
// only the code it showed last: a pairing started since in Settings or on another device stays
expect(cancels).toEqual([{ code: "100002" }]);
for (const call of local.calls.filter((entry) => entry.method === "POST")) {
expect(call.headers.get("x-herdr-update")).toBe("1");
expect(call.headers.get("origin")).toBe(ORIGIN);
Expand All @@ -125,6 +128,26 @@ describe("ovis link, on the PC itself", () => {
expect(cancelled).toBe(1);
});

it("--code takes back the pairing a renewal started when it fails before its QR code came", async () => {
const pc = identity();
const time = clock();
let offers = 0;
const cancels: unknown[] = [];
const local = localPc(async (request, path) => {
if (path === "/api/relay" && request.method === "GET") return Response.json({ phase: "online", blocked: null });
if (path === "/api/relay/code") return Response.json({ code: "ABCD-EFGH-JK", expires_at: new Date(time.now() + 600_000).toISOString(), server_id: pc.server_id });
if (path === "/api/relay/offer") {
if (++offers === 2) return Response.json({ error: { code: "internal_error", message: "internal error (x)" } }, { status: 500 });
return Response.json({ url: encodeOffer({ v: 1, server_id: pc.server_id, server_public_key: pc.public_key, relay: RELAY, code: "111111" }, "https://app.ovis.sh"), expires_at: new Date(time.now() + 600_000).toISOString() });
}
if (path === "/api/devices/pair/cancel") { cancels.push(await request.json()); return Response.json({ ok: true }); }
return undefined;
});
expect(await link(["--code"], { origin: ORIGIN, headers: {}, fetch: local.fetch, out: () => undefined, signal: new AbortController().signal, now: time.now, wait: time.wait, lines: answers(), accountService: SERVICE })).toBe(1);
// not the first round's 111111: the renewal's /api/relay/code started a pairing whose code never came back
expect(cancels).toEqual([{}]);
});

it("--code turns the relay on first when it is off, and waits until it is online to ask for a code", async () => {
const pc = identity();
const time = clock();
Expand Down Expand Up @@ -328,6 +351,43 @@ describe("ovis link, on the PC itself", () => {
expect(service.pcs("acct_1").map((entry) => entry.server_id)).toEqual([pc.server_id]);
});

it("puts this PC back in the account's list when a renewal's DELETE went through but its answer was lost", async () => {
const pc = identity();
const time = clock();
const service = new FakeAccountService(time.now);
let polls = 0;
let loseDelete = false;
let account: string | null = null;
let listedAfter: boolean | null = null;
const local = localPc(async (request, path) => {
if (path === "/api/account/status") return Response.json({ account, unlinked: [], removals: [], adds: [], blocked: [] });
if (path === "/api/relay" && request.method === "GET") return Response.json({ phase: "online", blocked: null });
if (path === "/api/relay/link") return Response.json({ url: encodeOffer({ v: 1, server_id: pc.server_id, server_public_key: pc.public_key, relay: RELAY, name: "old-desktop" }, "https://app.ovis.sh") });
if (path === "/api/relay/pairings") {
// the renewal is due after nine minutes of polls (2 s each)
if (++polls === 271) loseDelete = true;
// it keeps waiting, and the listing is back on the next round
if (polls === 273) { listedAfter = service.pcs("acct_1").length === 1; account = "acct_1"; }
return Response.json({ pairings: [] });
}
return undefined;
});
const out: string[] = [];
expect(await link([], {
origin: ORIGIN, headers: {}, fetch: local.fetch, now: time.now, wait: time.wait, signal: new AbortController().signal, lines: answers(), accountService: SERVICE,
serviceFetch: async (input, init) => {
const request = new Request(input, init);
const answer = await service.fetch(request);
if (request.method === "DELETE" && loseDelete) { loseDelete = false; throw new TypeError("connection reset"); }
return answer;
},
out: (text) => { out.push(text); const shown = /code:? ([A-Z0-9-]{4,16})\b/.exec(text); if (shown) service.approveDevice(shown[1]!, "acct_1"); },
})).toBe(0);
expect(listedAfter).toBe(true);
expect(service.pcs("acct_1").map((entry) => entry.server_id)).toEqual([pc.server_id]);
expect(out.join("")).not.toContain("dropped out of your account's list");
});

it("says the Ovis on this PC is too old for link, rather than failing on an unknown route", async () => {
const local = localPc((_request, path) => path === "/api/account/status" ? Response.json({ error: { code: "not_found", message: "unknown endpoint /api/account/status" } }, { status: 404 }) : undefined);
const out: string[] = [];
Expand Down Expand Up @@ -360,4 +420,85 @@ describe("ovis link, on the PC itself", () => {
expect(stdout).toContain("active release link --code");
} finally { server.stop(true); }
});

for (const signal of ["SIGTERM", "SIGHUP"] as const) {
it.skipIf(process.platform === "win32")(`--code takes its code back when ${signal} ends it, not only on Ctrl+C`, async () => {
const scratchDir = mkdtempSync(join(tmpdir(), "ovis-link-signal-"));
scratch.push(scratchDir);
const pc = identity();
const root = join(import.meta.dir, "..");
const cancels: unknown[] = [];
const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: async (request) => {
const path = new URL(request.url).pathname;
if (path === "/api/health") return Response.json({ ok: true, herdr: { version: "0.9.5", protocol: 1 }, auth: { required: false, authenticated: true } });
if (path === "/api/relay") return Response.json({ phase: "online", blocked: null });
if (path === "/api/relay/code") return Response.json({ code: "ABCD-EFGH-JK", expires_at: new Date(Date.now() + 600_000).toISOString(), server_id: pc.server_id });
if (path === "/api/relay/offer") {
return Response.json({ url: encodeOffer({ v: 1, server_id: pc.server_id, server_public_key: pc.public_key, relay: RELAY, code: "123456" }, "https://app.ovis.sh"), expires_at: new Date(Date.now() + 600_000).toISOString() });
}
if (path === "/api/devices/pair/cancel") { cancels.push(await request.json()); return Response.json({ ok: true }); }
return new Response(null, { status: 404 });
} });
try {
mkdirSync(join(scratchDir, "config"));
writeFileSync(join(scratchDir, "config", ".env"), `PORT=${server.port}\n`);
const child = Bun.spawn([process.execPath, "scripts/plugin.ts", "link", "--code"], { cwd: root, stdin: "ignore", stdout: "pipe", stderr: "pipe", env: {
...process.env, HOST: "127.0.0.1", HERDR_PLUGIN_ROOT: root, HERDR_PLUGIN_CONFIG_DIR: join(scratchDir, "config"),
HERDR_PLUGIN_STATE_DIR: join(scratchDir, "plugin-state"), OVIS_STATE_DIR: join(scratchDir, "app-state"), OVIS_TOKEN: "",
} });
// the code and the QR code on the screen, then the signal
const reader = child.stdout.getReader();
const decoder = new TextDecoder();
let stdout = "";
while (!stdout.includes("Press Ctrl+C to stop")) {
const chunk = await reader.read();
if (chunk.done) break;
stdout += decoder.decode(chunk.value, { stream: true });
}
// twice, as a process group and a checkout's link passing it on deliver it: the second must not cut the clean-up short
child.kill(signal);
child.kill(signal);
const [stderr, exit] = await Promise.all([new Response(child.stderr).text(), child.exited]);
expect(cancels, stderr).toEqual([{ code: "123456" }]);
expect(exit).toBe(0);
} finally { server.stop(true); }
});
}

it.skipIf(process.platform === "win32")("passes a SIGTERM on to the copy of link the app updated to, so it can take its code back", async () => {
const scratchDir = mkdtempSync(join(tmpdir(), "ovis-link-release-term-"));
scratch.push(scratchDir);
const root = join(import.meta.dir, "..");
const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response(null, { status: 404 }) });
try {
const updates = updateStateDir(root, server.port!, join(scratchDir, "app-state"));
const release = join(updates, "release-test");
mkdirSync(join(release, "scripts"), { recursive: true });
writeFileSync(join(release, "scripts", "plugin.ts"), 'process.on("SIGTERM", () => { console.log("release took its code back"); process.exit(0); }); console.log("release ready"); setTimeout(() => process.exit(3), 10_000);');
const revision = Bun.spawnSync(["git", "rev-parse", "HEAD"], { cwd: root, windowsHide: true }).stdout.toString().trim();
writeFileSync(join(updates, "current.json"), JSON.stringify({ source_revision: revision, directory: release }));
mkdirSync(join(scratchDir, "config"));
writeFileSync(join(scratchDir, "config", ".env"), `PORT=${server.port}\n`);
const child = Bun.spawn([process.execPath, "scripts/plugin.ts", "link", "--code"], { cwd: root, stdin: "ignore", stdout: "pipe", stderr: "pipe", env: {
...process.env, HOST: "127.0.0.1", HERDR_PLUGIN_ROOT: root, HERDR_PLUGIN_CONFIG_DIR: join(scratchDir, "config"),
HERDR_PLUGIN_STATE_DIR: join(scratchDir, "plugin-state"), OVIS_STATE_DIR: join(scratchDir, "app-state"),
} });
const decoder = new TextDecoder();
let stdout = "";
const reader = child.stdout.getReader();
while (!stdout.includes("release ready")) {
const chunk = await reader.read();
if (chunk.done) break;
stdout += decoder.decode(chunk.value, { stream: true });
}
child.kill("SIGTERM");
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
stdout += decoder.decode(chunk.value, { stream: true });
}
expect(stdout).toContain("release took its code back");
expect(await child.exited).toBe(0);
} finally { server.stop(true); }
});
});
Loading
Loading