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:
Yaowei Zheng
2026-08-04 16:57:28 +08:00
committed by GitHub
parent 045ac250e0
commit 7015fb0153
35 changed files with 1477 additions and 112 deletions
+26
View File
@@ -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();
+170
View File
@@ -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();
}
});
});
+2
View File
@@ -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,
};
}
+107
View File
@@ -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();
});
});