feat(desktop): Electron shell M2 — embedded server, desktop login, instance lock (#173)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,32 @@ describe("resolveServerConfig: PORT parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveServerConfig: desktop-mode seed password", () => {
|
||||
it("desktop mode without a pinned value generates a fully random seed password", () => {
|
||||
const a = resolveServerConfig({ ...base, PENGUIN_DESKTOP_TOKEN: "tok" }).seedAdminPassword;
|
||||
const b = resolveServerConfig({ ...base, PENGUIN_DESKTOP_TOKEN: "tok" }).seedAdminPassword;
|
||||
expect(a).not.toBeNull();
|
||||
// base64url of 24 random bytes: far beyond the printable penguin-<4 digits> space.
|
||||
expect(a!.length).toBeGreaterThanOrEqual(24);
|
||||
expect(a).not.toMatch(/^penguin-\d{4}$/);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("an explicit PENGUIN_SEED_ADMIN_PASSWORD still wins in desktop mode", () => {
|
||||
expect(
|
||||
resolveServerConfig({
|
||||
...base,
|
||||
PENGUIN_DESKTOP_TOKEN: "tok",
|
||||
PENGUIN_SEED_ADMIN_PASSWORD: "penguin-2026",
|
||||
}).seedAdminPassword,
|
||||
).toBe("penguin-2026");
|
||||
});
|
||||
|
||||
it("outside desktop mode the unpinned value stays null (random penguin-<4 digits> at seed time)", () => {
|
||||
expect(resolveServerConfig({ ...base }).seedAdminPassword).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveServerConfig: PENGUIN_SEED_ADMIN_PASSWORD parsing", () => {
|
||||
it("unset/empty/whitespace → null; a value is kept trimmed", () => {
|
||||
expect(resolveServerConfig({ ...base }).seedAdminPassword).toBeNull();
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Desktop mode: one-shot desktop-login, Bearer-token shutdown, desktopMode in /api/me,
|
||||
* and the desktop-session password change without oldPassword.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { apiClient, createTestApp, loginAdmin } from "./helpers.js";
|
||||
import type { MeResponse } from "../src/api/types.js";
|
||||
|
||||
const TOKEN = "test-desktop-token";
|
||||
|
||||
function desktopApp() {
|
||||
return createTestApp({ config: { desktopToken: TOKEN } });
|
||||
}
|
||||
|
||||
describe("desktop-login", () => {
|
||||
it("redeems the token once: cookie session, redirect to /, second attempt 401", async () => {
|
||||
const t = await desktopApp();
|
||||
try {
|
||||
const res = await t.app.request(`/api/auth/desktop-login?token=${TOKEN}`);
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toBe("/");
|
||||
const cookie = res.headers.get("set-cookie");
|
||||
expect(cookie).toContain("penguin_session=");
|
||||
|
||||
const me = await t.app.request("/api/me", {
|
||||
headers: { cookie: cookie!.split(";")[0]! },
|
||||
});
|
||||
expect(me.status).toBe(200);
|
||||
const body = (await me.json()) as MeResponse;
|
||||
expect(body.user.userId).toBe("admin");
|
||||
expect(body.desktopMode).toBe(true);
|
||||
|
||||
const replay = await t.app.request(`/api/auth/desktop-login?token=${TOKEN}`);
|
||||
expect(replay.status).toBe(401);
|
||||
} finally {
|
||||
await t.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a wrong or missing token without consuming the real one", async () => {
|
||||
const t = await desktopApp();
|
||||
try {
|
||||
expect((await t.app.request("/api/auth/desktop-login?token=wrong")).status).toBe(401);
|
||||
expect((await t.app.request("/api/auth/desktop-login")).status).toBe(401);
|
||||
// The real token still works after failed attempts.
|
||||
expect((await t.app.request(`/api/auth/desktop-login?token=${TOKEN}`)).status).toBe(302);
|
||||
} finally {
|
||||
await t.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("is 404 outside desktop mode, and /api/me reports desktopMode false", async () => {
|
||||
const t = await createTestApp();
|
||||
try {
|
||||
expect((await t.app.request("/api/auth/desktop-login?token=x")).status).toBe(404);
|
||||
const admin = await loginAdmin(t.app);
|
||||
const me = await apiClient(t.app, admin.cookie).get("/api/me");
|
||||
expect(((await me.json()) as MeResponse).desktopMode).toBe(false);
|
||||
} finally {
|
||||
await t.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop shutdown endpoint", () => {
|
||||
it("accepts the Bearer token repeatedly and triggers the registered handler", async () => {
|
||||
const t = await desktopApp();
|
||||
try {
|
||||
let requested = 0;
|
||||
t.deps.desktop!.onShutdownRequest(() => {
|
||||
requested += 1;
|
||||
});
|
||||
const res = await t.app.request("/api/desktop/shutdown", {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${TOKEN}` },
|
||||
});
|
||||
expect(res.status).toBe(202);
|
||||
// The route defers the trigger so the 202 can flush first.
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
expect(requested).toBe(1);
|
||||
|
||||
// Unlike the login token, the shutdown credential is NOT one-shot.
|
||||
const again = await t.app.request("/api/desktop/shutdown", {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${TOKEN}` },
|
||||
});
|
||||
expect(again.status).toBe(202);
|
||||
} finally {
|
||||
await t.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects wrong or missing tokens, and does not exist outside desktop mode", async () => {
|
||||
const t = await desktopApp();
|
||||
try {
|
||||
const wrong = await t.app.request("/api/desktop/shutdown", {
|
||||
method: "POST",
|
||||
headers: { authorization: "Bearer nope" },
|
||||
});
|
||||
expect(wrong.status).toBe(401);
|
||||
const missing = await t.app.request("/api/desktop/shutdown", { method: "POST" });
|
||||
expect(missing.status).toBe(401);
|
||||
} finally {
|
||||
await t.cleanup();
|
||||
}
|
||||
|
||||
const plain = await createTestApp();
|
||||
try {
|
||||
// Outside desktop mode the route is not mounted; the request falls through to the
|
||||
// cookie auth middleware, which rejects the cookieless caller with 401.
|
||||
const res = await plain.app.request("/api/desktop/shutdown", {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${TOKEN}` },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
} finally {
|
||||
await plain.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop-session password change", () => {
|
||||
async function desktopCookie(t: Awaited<ReturnType<typeof desktopApp>>): Promise<string> {
|
||||
const res = await t.app.request(`/api/auth/desktop-login?token=${TOKEN}`);
|
||||
return res.headers.get("set-cookie")!.split(";")[0]!;
|
||||
}
|
||||
|
||||
it("allows omitting oldPassword for a desktop session and clears the initial flag", async () => {
|
||||
const t = await desktopApp();
|
||||
try {
|
||||
const cookie = await desktopCookie(t);
|
||||
const res = await apiClient(t.app, cookie).put("/api/me/password", {
|
||||
newPassword: "brand-new-password",
|
||||
});
|
||||
expect(res.status).toBe(204);
|
||||
const me = (await (await apiClient(t.app, cookie).get("/api/me")).json()) as MeResponse;
|
||||
expect(me.user.passwordIsInitial).toBe(false);
|
||||
} finally {
|
||||
await t.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("still validates oldPassword when it is provided by a desktop session", async () => {
|
||||
const t = await desktopApp();
|
||||
try {
|
||||
const cookie = await desktopCookie(t);
|
||||
const res = await apiClient(t.app, cookie).put("/api/me/password", {
|
||||
oldPassword: "wrong-password",
|
||||
newPassword: "brand-new-password",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
} finally {
|
||||
await t.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps requiring oldPassword for password-established sessions in desktop mode", async () => {
|
||||
const t = await desktopApp();
|
||||
try {
|
||||
// Sign in via the regular login form against the same desktop-mode server.
|
||||
const admin = await loginAdmin(t.app);
|
||||
const res = await apiClient(t.app, admin.cookie).put("/api/me/password", {
|
||||
newPassword: "brand-new-password",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
} finally {
|
||||
await t.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,8 @@ export function testConfig(root: string): ServerConfig {
|
||||
seedAdminPassword: TEST_ADMIN_PASSWORD,
|
||||
authSessionTtlMs: 7 * DAY_MS,
|
||||
authSessionRenewMs: 6 * DAY_MS,
|
||||
desktopToken: null,
|
||||
portFile: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Server instance lock: read/acquire/release round-trip, stale detection (dead pid,
|
||||
* dead port), and the live path against a real loopback listener.
|
||||
*/
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
acquireServerLock,
|
||||
isServerLockAlive,
|
||||
liveServerLock,
|
||||
readServerLock,
|
||||
releaseServerLock,
|
||||
serverLockPath,
|
||||
} from "../src/lock.js";
|
||||
import { makeTempRoot } from "./helpers.js";
|
||||
|
||||
/** A pid that is guaranteed dead: a just-exited child of ours. */
|
||||
function deadPid(): number {
|
||||
const child = spawnSync(process.execPath, ["-e", ""]);
|
||||
return child.pid ?? 2 ** 21;
|
||||
}
|
||||
|
||||
function listen(): Promise<{ port: number; close: () => Promise<void> }> {
|
||||
return new Promise((resolve) => {
|
||||
const srv = net.createServer();
|
||||
srv.listen(0, "127.0.0.1", () => {
|
||||
const port = (srv.address() as net.AddressInfo).port;
|
||||
resolve({
|
||||
port,
|
||||
close: () => new Promise((r) => srv.close(() => r())),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("server lock", () => {
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
for (const root of roots.splice(0)) {
|
||||
await fs.promises.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function tempRoot(): Promise<string> {
|
||||
const root = await makeTempRoot();
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
it("reads null on a missing or malformed file, and round-trips acquire/read", async () => {
|
||||
const root = await tempRoot();
|
||||
expect(readServerLock(root)).toBeNull();
|
||||
fs.writeFileSync(serverLockPath(root), "not json");
|
||||
expect(readServerLock(root)).toBeNull();
|
||||
fs.writeFileSync(serverLockPath(root), JSON.stringify({ pid: "x", port: 1 }));
|
||||
expect(readServerLock(root)).toBeNull();
|
||||
|
||||
acquireServerLock(root, { pid: process.pid, port: 12345, startedAt: "2026-01-01T00:00:00Z" });
|
||||
expect(readServerLock(root)).toEqual({
|
||||
pid: process.pid,
|
||||
port: 12345,
|
||||
startedAt: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a dead pid as stale even if the port is live", async () => {
|
||||
const { port, close } = await listen();
|
||||
try {
|
||||
expect(await isServerLockAlive({ pid: deadPid(), port, startedAt: "" })).toBe(false);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
it("treats a dead port as stale even if the pid is live", async () => {
|
||||
const { port, close } = await listen();
|
||||
await close(); // the port is now free — nothing accepts connections
|
||||
expect(await isServerLockAlive({ pid: process.pid, port, startedAt: "" })).toBe(false);
|
||||
});
|
||||
|
||||
it("reports alive when pid and port both check out, and liveServerLock surfaces it", async () => {
|
||||
const root = await tempRoot();
|
||||
const { port, close } = await listen();
|
||||
try {
|
||||
const lock = { pid: process.pid, port, startedAt: "now" };
|
||||
expect(await isServerLockAlive(lock)).toBe(true);
|
||||
acquireServerLock(root, lock);
|
||||
expect(await liveServerLock(root)).toEqual(lock);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
expect(await liveServerLock(root)).toBeNull(); // stale once the listener is gone
|
||||
});
|
||||
|
||||
it("release removes only a lock owned by this process", async () => {
|
||||
const root = await tempRoot();
|
||||
acquireServerLock(root, { pid: deadPid(), port: 1, startedAt: "" });
|
||||
releaseServerLock(root); // foreign pid: left in place
|
||||
expect(readServerLock(root)).not.toBeNull();
|
||||
|
||||
acquireServerLock(root, { pid: process.pid, port: 1, startedAt: "" });
|
||||
releaseServerLock(root);
|
||||
expect(readServerLock(root)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user