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
+16 -1
View File
@@ -72,10 +72,25 @@ export interface MeResponse {
* request, since it depends on the host the caller is using.
*/
previewIsolated: boolean;
/**
* Whether this server runs in desktop mode (spawned by the desktop shell with
* PENGUIN_DESKTOP_TOKEN). The web app then hides the logout entry, the
* initial-password banner and the self-update entry, and omits the old-password
* field when changing the password. See design § "桌面端原型".
*/
desktopMode: boolean;
/**
* How THIS session was established. Distinct from desktopMode: a browser signed into a
* desktop-mode server holds a "password" session and must still provide the old
* password when changing it — only "desktop" sessions (opened by the shell's one-shot
* token) may omit it.
*/
sessionVia: "password" | "desktop";
}
export interface PasswordChangeRequest {
oldPassword: string;
/** Omitted only by desktop-established sessions (desktop mode); required otherwise. */
oldPassword?: string;
/** At least 8 characters. */
newPassword: string;
}
+10
View File
@@ -60,6 +60,8 @@ import { TitleGenerator } from "./runtime/title-generator.js";
import type { TitleNotifier } from "./runtime/title-generator.js";
import { UsageRecorder } from "./runtime/usage-recorder.js";
import { AdminService } from "./services/admin-service.js";
import { DesktopService } from "./services/desktop-service.js";
import { desktopRoutes } from "./http/routes/desktop.js";
import { AgentConfigService } from "./services/agent-config-service.js";
import { AgentService } from "./services/agent-service.js";
import { BenchmarkService } from "./services/benchmark-service.js";
@@ -114,6 +116,8 @@ export interface AppDeps {
sessionSources: SessionSources;
/** Error persistence (shared by app.onError and various background capture points; the process-level fallback is in index.ts). */
errors: ErrorRecorder;
/** Desktop mode (PENGUIN_DESKTOP_TOKEN): one-shot login + shutdown token holder; null outside desktop mode. */
desktop: DesktopService | null;
/** Request log output (minimal one-liner); tests inject a noop. */
log: (line: string) => void;
}
@@ -276,6 +280,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
manager,
sessionSources,
errors,
desktop: config.desktopToken !== null ? new DesktopService(config.desktopToken) : null,
log,
};
}
@@ -355,6 +360,11 @@ export function createApp(deps: AppDeps): Hono<AppEnv> {
// Public routes (no login required).
app.route("/api/auth", authRoutes(deps));
// Desktop shutdown authenticates with the shell's Bearer token, not the cookie
// session, so it mounts outside authMiddleware (and only in desktop mode).
if (deps.desktop) {
app.route("/api/desktop", desktopRoutes(deps));
}
// Protected routes: cookie -> auth_session -> user.
const auth = authMiddleware(deps.authService);
+7 -4
View File
@@ -11,7 +11,7 @@ import type { MiddlewareHandler } from "hono";
import { getCookie } from "hono/cookie";
import { HttpError } from "../http/errors.js";
import type { UserRow } from "../db/repos/users.js";
import type { AuthService } from "./service.js";
import type { AuthService, SessionVia } from "./service.js";
/** Session cookie name. */
export const SESSION_COOKIE = "penguin_session";
@@ -20,6 +20,8 @@ export const SESSION_COOKIE = "penguin_session";
export type AppEnv = {
Variables: {
user: UserRow;
/** How the current session was established ("password" | "desktop"); legacy rows read as "password". */
sessionVia: SessionVia;
};
};
@@ -31,11 +33,12 @@ export function currentUser(c: { var: { user: UserRow } }): UserRow {
export function authMiddleware(auth: AuthService): MiddlewareHandler<AppEnv> {
return async (c, next) => {
const token = getCookie(c, SESSION_COOKIE);
const user = token ? auth.authenticate(token) : null;
if (!user) {
const authed = token ? auth.authenticateWithMeta(token) : null;
if (!authed) {
throw new HttpError(401, "unauthorized", "Not signed in or the sign-in has expired.");
}
c.set("user", user);
c.set("user", authed.user);
c.set("sessionVia", authed.via);
await next();
};
}
+43 -4
View File
@@ -57,6 +57,14 @@ function sha256Hex(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
/**
* How a session was established: "password" via the login form, "desktop" via the
* desktop shell's one-shot token (see design § "桌面端原型 · 桌面登录"). Persisted per
* session so desktop-specific allowances (password change without the old password)
* apply only to sessions the shell itself opened. Legacy rows (NULL) read as "password".
*/
export type SessionVia = "password" | "desktop";
export function toUserInfo(row: UserRow): UserInfo {
return {
userId: row.userId,
@@ -159,7 +167,22 @@ export class AuthService {
}
this.loginFailures.delete(userId);
this.deps.authSessions.deleteExpired(this.now().toISOString());
return { user: toUserInfo(row), token: this.issueSession(row.userId) };
return { user: toUserInfo(row), token: this.issueSession(row.userId, "password") };
}
/**
* Desktop-mode sign-in: issues an admin session WITHOUT a password check — the caller
* (the desktop-login route) has already redeemed the shell's one-shot token, which is
* the credential here. Throws if the admin has not been seeded yet (desktop-login runs
* after startup seeding, so this only trips on a broken deployment).
*/
loginDesktop(): { user: UserInfo; token: string } {
const row = this.deps.users.findById(ADMIN_USER_ID);
if (!row) {
throw new HttpError(500, "internal", "Built-in admin has not been seeded.");
}
this.deps.authSessions.deleteExpired(this.now().toISOString());
return { user: toUserInfo(row), token: this.issueSession(row.userId, "desktop") };
}
/** Self password change (user settings): validates the old password, and on success clears the initial-password flag; the current session remains valid. */
@@ -174,12 +197,25 @@ export class AuthService {
this.deps.users.updatePassword(userId, await hashPassword(newPassword), false);
}
/**
* Desktop-session password set: no old-password check. Only reachable for sessions
* established via desktop-login (the me route gates on sessionVia) — the seed password
* of a desktop-created root is random and never shown, so its holder has nothing to
* type into an old-password field; the shell's token already proved machine ownership.
*/
async setPasswordDesktop(userId: string, newPassword: string): Promise<void> {
if (newPassword.length < MIN_PASSWORD_LENGTH) {
throw new HttpError(400, "invalid_password", "Password must be at least 8 characters.");
}
this.deps.users.updatePassword(userId, await hashPassword(newPassword), false);
}
logout(token: string): void {
this.deps.authSessions.delete(sha256Hex(token));
}
/** Validates the cookie token: returns null if expired/unknown; sliding renewal once less than 6 days remain. */
authenticate(token: string): UserRow | null {
authenticateWithMeta(token: string): { user: UserRow; via: SessionVia } | null {
const tokenHash = sha256Hex(token);
const session = this.deps.authSessions.findByTokenHash(tokenHash);
if (!session) return null;
@@ -195,10 +231,12 @@ export class AuthService {
new Date(now.getTime() + this.deps.sessionTtlMs).toISOString(),
);
}
return this.deps.users.findById(session.userId);
const user = this.deps.users.findById(session.userId);
if (!user) return null;
return { user, via: session.via === "desktop" ? "desktop" : "password" };
}
private issueSession(userId: string): string {
private issueSession(userId: string, via: SessionVia): string {
const token = randomBytes(32).toString("base64url");
const now = this.now();
this.deps.authSessions.insert({
@@ -206,6 +244,7 @@ export class AuthService {
userId,
createdAt: now.toISOString(),
expiresAt: new Date(now.getTime() + this.deps.sessionTtlMs).toISOString(),
via,
});
return token;
}
+35 -5
View File
@@ -9,6 +9,7 @@
* detected to exist.
* Docs: /docs/configuration § "Environment variables".
*/
import { randomBytes } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -35,13 +36,29 @@ export interface ServerConfig {
/**
* Fixed initial password for the seeded built-in admin (PENGUIN_SEED_ADMIN_PASSWORD),
* used by automated tests and e2e; null (the norm) makes the seed generate a random
* `penguin-<4 digits>` password, printed once to the server console.
* `penguin-<4 digits>` password, printed once to the server console. In desktop mode
* an unpinned value resolves to a FULLY random password instead (never printed):
* sign-in there goes through the shell's one-shot token, so nobody needs to read the
* seed. See design § "桌面端原型 · 桌面登录".
*/
seedAdminPassword: string | null;
/** Login session validity period (7 days). */
authSessionTtlMs: number;
/** Sliding renewal threshold: if the remaining validity is below this value when validation succeeds, it's renewed to the full TTL (renews under 6 days). */
authSessionRenewMs: number;
/**
* Desktop mode (PENGUIN_DESKTOP_TOKEN): the per-launch token minted by the desktop
* shell. Non-null enables the one-shot desktop-login and Bearer-token shutdown
* endpoints and requires a loopback HOST — desktop mode passes the token through a
* URL, which must never leave the machine. See design § "桌面端原型".
*/
desktopToken: string | null;
/**
* Port announcement file (PENGUIN_PORT_FILE): after the listener is up, the actual
* bound port is written here — the supervising process's way to learn the port when
* it starts the server with PORT=0.
*/
portFile: string | null;
}
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -79,7 +96,7 @@ function normalizePreviewOrigin(raw: string | undefined): string | null {
return url.origin;
}
/** Parses server config from environment variables (PORT / HOST / PENGUIN_HOME / PENGUIN_WEB_DIST / PENGUIN_WEB_DB / PENGUIN_PREVIEW_ORIGIN / PENGUIN_SEED_ADMIN_PASSWORD). */
/** Parses server config from environment variables (PORT / HOST / PENGUIN_HOME / PENGUIN_WEB_DIST / PENGUIN_WEB_DB / PENGUIN_PREVIEW_ORIGIN / PENGUIN_SEED_ADMIN_PASSWORD / PENGUIN_DESKTOP_TOKEN / PENGUIN_PORT_FILE). */
export function resolveServerConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {
const root = env.PENGUIN_HOME ?? resolveRoot();
// An empty PORT string is treated as unset (the common `.env` case of an empty
@@ -89,16 +106,29 @@ export function resolveServerConfig(env: NodeJS.ProcessEnv = process.env): Serve
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error(`Invalid port configuration PORT=${env.PORT}`);
}
const host = env.HOST ?? "127.0.0.1";
const desktopToken = env.PENGUIN_DESKTOP_TOKEN?.trim() || null;
// Desktop mode redeems its token through a URL: never allow it off loopback.
if (desktopToken !== null && host !== "127.0.0.1" && host !== "localhost") {
throw new Error(`Desktop mode requires a loopback HOST (got HOST=${host})`);
}
return {
root,
host: env.HOST ?? "127.0.0.1",
host,
port,
dbPath: env.PENGUIN_WEB_DB ?? path.join(root, "web.db"),
webDist: env.PENGUIN_WEB_DIST ?? defaultWebDist(),
previewOrigin: normalizePreviewOrigin(env.PENGUIN_PREVIEW_ORIGIN),
// An empty/whitespace value is treated as unset (→ random seed password).
seedAdminPassword: env.PENGUIN_SEED_ADMIN_PASSWORD?.trim() || null,
// An empty/whitespace value is treated as unset (→ random seed password). Desktop
// mode without a pinned value seeds a FULLY random password rather than the
// printable penguin-<4 digits>: desktop sign-in goes through the shell's token, so
// the seed never needs to be read — and index.ts deliberately does not print it.
seedAdminPassword:
env.PENGUIN_SEED_ADMIN_PASSWORD?.trim() ||
(desktopToken !== null ? randomBytes(24).toString("base64url") : null),
authSessionTtlMs: 7 * DAY_MS,
authSessionRenewMs: 6 * DAY_MS,
desktopToken,
portFile: env.PENGUIN_PORT_FILE?.trim() || null,
};
}
+1
View File
@@ -29,6 +29,7 @@ export function openDatabase(dbPath: string): DatabaseSync {
// schema.ts; drop entries only in a release allowed to break existing web.db files.
ensureColumn(db, "sessions", "client", "TEXT");
ensureColumn(db, "sessions", "has_trace", "INTEGER NOT NULL DEFAULT 0");
ensureColumn(db, "auth_sessions", "via", "TEXT");
return db;
}
@@ -10,6 +10,8 @@ export interface AuthSessionRow {
userId: string;
createdAt: string;
expiresAt: string;
/** How the session was established ("password" | "desktop"); null on rows formed before the column existed (treated as "password"). */
via: string | null;
}
export class AuthSessionsRepo {
@@ -18,9 +20,9 @@ export class AuthSessionsRepo {
insert(row: AuthSessionRow): void {
this.db
.prepare(
"INSERT INTO auth_sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
"INSERT INTO auth_sessions (token_hash, user_id, created_at, expires_at, via) VALUES (?, ?, ?, ?, ?)",
)
.run(row.tokenHash, row.userId, row.createdAt, row.expiresAt);
.run(row.tokenHash, row.userId, row.createdAt, row.expiresAt, row.via);
}
findByTokenHash(tokenHash: string): AuthSessionRow | null {
@@ -31,6 +33,7 @@ export class AuthSessionsRepo {
userId: r.user_id as string,
createdAt: r.created_at as string,
expiresAt: r.expires_at as string,
via: (r.via as string | null) ?? null,
};
}
+2 -1
View File
@@ -22,7 +22,8 @@ CREATE TABLE IF NOT EXISTS auth_sessions (
token_hash TEXT PRIMARY KEY, -- sha256(token) hex; the cookie stores only the raw token
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL -- 7-day sliding renewal (topped up when <6 days remain)
expires_at TEXT NOT NULL, -- 7-day sliding renewal (topped up when <6 days remain)
via TEXT -- 'password' | 'desktop'; NULL = legacy row (password)
);
CREATE TABLE IF NOT EXISTS projects (
project_id TEXT PRIMARY KEY, -- directory name doubles as id; display name lives in project_config.toml
+19 -1
View File
@@ -1,11 +1,12 @@
/**
* Auth routes: POST /api/auth/login | logout.
* Auth routes: POST /api/auth/login | logout, GET /api/auth/desktop-login (desktop mode).
* No self-registration: users are created by an admin in the user backend (/api/admin/users).
* Login issues a cookie session; logout deletes the server-side session and clears the cookie.
*/
import { Hono } from "hono";
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
import type { AuthResponse } from "../../api/types.js";
import { HttpError } from "../errors.js";
import { SESSION_COOKIE } from "../../auth/middleware.js";
import type { AppEnv } from "../../auth/middleware.js";
import { readJson, requireString } from "../validate.js";
@@ -42,5 +43,22 @@ export function authRoutes(deps: AppDeps): Hono<AppEnv> {
return c.body(null, 204);
});
// Desktop-mode sign-in: the window's FIRST navigation redeems the shell's one-shot
// token for a standard admin cookie session and lands on the app — the desktop user
// never sees the login page. 404 outside desktop mode (the route "doesn't exist");
// a wrong or already-used token is a plain 401 with no distinction, so a leaked URL
// reveals nothing and cannot be replayed. See design § "桌面端原型 · 桌面登录".
app.get("/desktop-login", (c) => {
const desktop = deps.desktop;
if (!desktop) throw new HttpError(404, "not_found", "Desktop mode is not enabled.");
const token = c.req.query("token") ?? "";
if (token === "" || !desktop.redeemLoginToken(token)) {
throw new HttpError(401, "unauthorized", "Invalid or already-used desktop token.");
}
const { token: session } = deps.authService.loginDesktop();
setCookie(c, SESSION_COOKIE, session, cookieOptions(c));
return c.redirect("/", 302);
});
return app;
}
@@ -0,0 +1,32 @@
/**
* Desktop-mode routes: POST /api/desktop/shutdown.
*
* Authenticated by the shell's Bearer token, not the cookie session (the shell holds no
* cookie), so this mounts OUTSIDE authMiddleware and only when desktop mode is enabled.
* Responds 202 first, then triggers the graceful shutdown a beat later so the response
* isn't cut off by the closing listener.
*/
import { Hono } from "hono";
import { HttpError } from "../errors.js";
import type { AppDeps } from "../../app.js";
/** Delay between answering 202 and starting shutdown: lets the response flush. */
const SHUTDOWN_DELAY_MS = 50;
export function desktopRoutes(deps: AppDeps): Hono {
const app = new Hono();
app.post("/shutdown", (c) => {
const desktop = deps.desktop;
if (!desktop) throw new HttpError(404, "not_found", "Desktop mode is not enabled.");
const header = c.req.header("authorization") ?? "";
const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "";
if (token === "" || !desktop.verifyToken(token)) {
throw new HttpError(401, "unauthorized", "Invalid desktop token.");
}
setTimeout(() => desktop.requestShutdown(), SHUTDOWN_DELAY_MS).unref();
return c.body(null, 202);
});
return app;
}
+12 -2
View File
@@ -28,15 +28,25 @@ export function meRoutes(deps: AppDeps): Hono<AppEnv> {
return c.json({
user: toUserInfo(c.var.user),
previewIsolated: target !== null,
desktopMode: deps.desktop !== null,
sessionVia: c.var.sessionVia,
} satisfies MeResponse);
});
// Self-service password change (user settings): validates the old password; on success, the initial-password prompt disappears from GET /api/me.
// Desktop sessions may omit oldPassword: the seed password of a desktop-created root is
// random and never shown, so its holder has nothing to type — the shell's redeemed
// token already proved machine ownership (see design § "桌面端原型 · 桌面登录").
app.put("/password", async (c) => {
const body = await readJson(c);
const oldPassword = requireString(body, "oldPassword", { label: "oldPassword" });
const newPassword = requireString(body, "newPassword", { label: "newPassword" });
await deps.authService.changePassword(c.var.user.userId, oldPassword, newPassword);
const desktopSession = deps.desktop !== null && c.var.sessionVia === "desktop";
if (desktopSession && body.oldPassword === undefined) {
await deps.authService.setPasswordDesktop(c.var.user.userId, newPassword);
} else {
const oldPassword = requireString(body, "oldPassword", { label: "oldPassword" });
await deps.authService.changePassword(c.var.user.userId, oldPassword, newPassword);
}
return c.body(null, 204);
});
+81 -15
View File
@@ -9,15 +9,35 @@
* persist + log, with the fatal one still shutting down per existing semantics (see the
* comment below).
*/
import fs from "node:fs";
import path from "node:path";
import { config as loadDotenv } from "dotenv";
import { serve } from "@hono/node-server";
import { buildAppDeps, createApp } from "./app.js";
import { resolveServerConfig } from "./config.js";
import { loopbackHostRoles } from "./services/preview-token.js";
import { acquireServerLock, liveServerLock, releaseServerLock } from "./lock.js";
loadDotenv({ quiet: true });
/** Exit code for "another server already owns this data root" (see lock.ts). */
const EXIT_ALREADY_RUNNING = 3;
const config = resolveServerConfig();
// Single instance per data root: web.db is single-writer and the scheduler must not run
// twice, so refuse to start when a live server already owns this root — BEFORE opening
// the database. The CLI and the desktop shell pre-check the same lock for a friendlier
// path (open / attach to the existing instance); this is the in-process backstop.
const existingLock = await liveServerLock(config.root);
if (existingLock) {
console.error(
`Another PenguinHarness server is already running on this data root (pid ${existingLock.pid}).`,
);
console.error(`Existing instance: http://localhost:${existingLock.port}/`);
process.exit(EXIT_ALREADY_RUNNING);
}
const deps = buildAppDeps(config);
const app = createApp(deps);
@@ -25,7 +45,10 @@ const app = createApp(deps);
// users table is empty. The returned initial password (random unless pinned via
// PENGUIN_SEED_ADMIN_PASSWORD) is printed here once — the only place it is ever shown.
const seededAdminPassword = await deps.authService.seedAdmin();
if (seededAdminPassword !== null) {
// Never printed in desktop mode: the seed there is fully random by design (config.ts)
// and sign-in goes through the shell's one-shot token, so showing it would only leak a
// credential into a log nobody needs.
if (seededAdminPassword !== null && config.desktopToken === null) {
console.log(
`Seeded built-in admin "admin" — initial password: ${seededAdminPassword} (change it after first sign-in)`,
);
@@ -44,11 +67,6 @@ deps.goalsRepo.abortOrphanedActive();
// counterpart is reserved for previews, so advertise the canonical name — the other one
// only 302s back here for App routes (see the canonical-host guard in app.ts).
const appHost = loopbackHostRoles(config.host)?.app ?? config.host;
const server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
console.log(`penguin-server started: http://${appHost}:${info.port}`);
console.log(`Data root: ${config.root}`);
console.log(`SQLite: ${config.dbPath}`);
});
/**
* Second loopback listener so the preview origin is actually reachable.
@@ -58,17 +76,56 @@ const server = serve({ fetch: app.fetch, hostname: config.host, port: config.por
* systems `localhost` resolves to `::1` first, so a server bound only to `127.0.0.1`
* would leave every preview URL refusing connections. Binding `::1` as well closes that
* gap. Failure is non-fatal — the App keeps working, previews just fall back.
*
* Created inside the main listener's callback so it reuses the ACTUAL bound port: with
* PORT=0 both listeners resolving 0 independently would land on two different ports and
* every preview URL (same port, counterpart host) would refuse connections.
*/
const ipv6Loopback =
config.host === "127.0.0.1" || config.host === "localhost"
? serve({ fetch: app.fetch, hostname: "::1", port: config.port })
: null;
ipv6Loopback?.on("error", (err: NodeJS.ErrnoException) => {
console.warn(
`[server] IPv6 loopback listener unavailable (${err.code ?? err.message}); previews via localhost may not resolve.`,
);
let ipv6Loopback: ReturnType<typeof serve> | null = null;
/** Port announcement (PENGUIN_PORT_FILE): tmp + rename, so a polling reader never sees a partial write. */
function writePortFile(file: string, port: number): void {
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.${process.pid}.tmp`;
fs.writeFileSync(tmp, `${port}\n`);
fs.renameSync(tmp, file);
}
const server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
console.log(`penguin-server started: http://${appHost}:${info.port}`);
console.log(`Data root: ${config.root}`);
console.log(`SQLite: ${config.dbPath}`);
if (config.desktopToken !== null) console.log("Desktop mode: enabled");
// The root exists by now (openDatabase created it), and the pre-start check found no
// live owner — record ourselves as this root's server.
acquireServerLock(config.root, {
pid: process.pid,
port: info.port,
startedAt: new Date().toISOString(),
});
if (config.portFile !== null) writePortFile(config.portFile, info.port);
if (config.host === "127.0.0.1" || config.host === "localhost") {
ipv6Loopback = serve({ fetch: app.fetch, hostname: "::1", port: info.port });
ipv6Loopback.on("error", (err: NodeJS.ErrnoException) => {
console.warn(
`[server] IPv6 loopback listener unavailable (${err.code ?? err.message}); previews via localhost may not resolve.`,
);
});
}
});
/** Removes the instance lock and port file (best-effort; runs on both exit paths). */
function cleanupInstanceFiles(): void {
releaseServerLock(config.root);
if (config.portFile !== null) {
try {
fs.rmSync(config.portFile, { force: true });
} catch {
// Best-effort: a stale port file is rewritten by the next server.
}
}
}
let shuttingDown = false;
async function shutdown(signal: string, exitCode = 0): Promise<void> {
if (shuttingDown) return;
@@ -80,15 +137,24 @@ async function shutdown(signal: string, exitCode = 0): Promise<void> {
ipv6Loopback?.close();
server.close(() => {
deps.db.close();
cleanupInstanceFiles();
process.exit(exitCode);
});
// Fallback: a long-lived SSE connection may block the close callback, so force exit after 1s.
setTimeout(() => process.exit(exitCode), 1000).unref();
setTimeout(() => {
cleanupInstanceFiles();
process.exit(exitCode);
}, 1000).unref();
}
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));
// Desktop shell quit path: POST /api/desktop/shutdown lands here — the same graceful
// shutdown as the signals, reachable over HTTP because a Windows child kill is a hard
// TerminateProcess with no signal delivery.
deps.desktop?.onShutdownRequest(() => void shutdown("desktop-shutdown"));
// Process-level error fallback: once a background
// fire-and-forget promise (title generation, Session drive, etc.) throws, the error
// reaches the process without passing through any catch — persist it first for a
+110
View File
@@ -0,0 +1,110 @@
/**
* Root-level server instance lock (`<root>/server.lock`).
*
* web.db is single-process / single-writer (see db/database.ts), and two servers on one
* data root would also double-run the schedule scheduler — so a data root admits one
* server at a time. The lock records {pid, port, startedAt}; liveness requires BOTH the
* pid to be alive AND the recorded port to accept a TCP connection, because either signal
* alone false-positives (pids get recycled, ports get taken by unrelated processes). A
* lock that fails the liveness check is stale and is simply overwritten by the next
* server.
*
* Published as `@prismshadow/penguin-server/lock` (side-effect-free) so the CLI and the
* desktop shell can pre-check a root without importing the package entry, which starts
* listening. Docs: design § "桌面端原型 · 数据根与实例互斥".
*/
import fs from "node:fs";
import net from "node:net";
import path from "node:path";
export interface ServerLock {
pid: number;
port: number;
startedAt: string;
}
/** TCP probe budget: loopback either connects immediately or the port is dead. */
const PROBE_TIMEOUT_MS = 500;
export function serverLockPath(root: string): string {
return path.join(root, "server.lock");
}
/** Reads the lock file; a missing or malformed file reads as "no lock". */
export function readServerLock(root: string): ServerLock | null {
let raw: string;
try {
raw = fs.readFileSync(serverLockPath(root), "utf8");
} catch {
return null;
}
try {
const parsed = JSON.parse(raw) as Partial<ServerLock>;
if (
typeof parsed.pid !== "number" ||
!Number.isInteger(parsed.pid) ||
typeof parsed.port !== "number" ||
!Number.isInteger(parsed.port)
) {
return null;
}
return { pid: parsed.pid, port: parsed.port, startedAt: String(parsed.startedAt ?? "") };
} catch {
return null;
}
}
function pidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (err) {
// EPERM = the process exists but belongs to another user — still alive.
return (err as NodeJS.ErrnoException).code === "EPERM";
}
}
function portAccepts(port: number): Promise<boolean> {
return new Promise((resolve) => {
// 127.0.0.1 rather than localhost: this is a raw TCP liveness probe, not an App
// request, and the server binds 127.0.0.1 (plus ::1) on loopback setups.
const socket = net.connect({ host: "127.0.0.1", port, timeout: PROBE_TIMEOUT_MS });
const done = (ok: boolean) => {
socket.destroy();
resolve(ok);
};
socket.once("connect", () => done(true));
socket.once("timeout", () => done(false));
socket.once("error", () => done(false));
});
}
/** True when the lock's process is alive AND its port accepts connections. */
export async function isServerLockAlive(lock: ServerLock): Promise<boolean> {
return pidAlive(lock.pid) && (await portAccepts(lock.port));
}
/** Convenience for pre-checks: the live lock on this root, or null (absent or stale). */
export async function liveServerLock(root: string): Promise<ServerLock | null> {
const lock = readServerLock(root);
if (!lock) return null;
return (await isServerLockAlive(lock)) ? lock : null;
}
/** Writes the lock atomically (tmp + rename; the parent directory must already exist). */
export function acquireServerLock(root: string, lock: ServerLock): void {
const target = serverLockPath(root);
const tmp = `${target}.${process.pid}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(lock) + "\n");
fs.renameSync(tmp, target);
}
/** Removes the lock if it is still ours (best-effort; never throws on shutdown paths). */
export function releaseServerLock(root: string): void {
try {
const lock = readServerLock(root);
if (lock && lock.pid === process.pid) fs.rmSync(serverLockPath(root));
} catch {
// Best-effort: a stale leftover is overwritten by the next server anyway.
}
}
@@ -0,0 +1,55 @@
/**
* Desktop mode (PENGUIN_DESKTOP_TOKEN): the shell that spawned this server proves itself
* with a per-launch random token, which backs two endpoints with different consumption
* rules:
*
* - `GET /api/auth/desktop-login?token=…` — ONE-SHOT: the window's first navigation
* redeems the token for a standard admin cookie session; every later attempt fails,
* so a leaked URL cannot be replayed.
* - `POST /api/desktop/shutdown` (Authorization: Bearer <token>) — REUSABLE for the
* process lifetime: the token here identifies the supervising shell, which may need
* the endpoint at any point (POSIX quit, and the only graceful path on Windows,
* where killing a child is a hard TerminateProcess).
*
* Comparisons hash both sides first so timingSafeEqual gets equal-length buffers.
* Docs: design § "桌面端原型 · 桌面登录".
*/
import { createHash, timingSafeEqual } from "node:crypto";
function digest(value: string): Buffer {
return createHash("sha256").update(value).digest();
}
export class DesktopService {
private readonly tokenDigest: Buffer;
private loginConsumed = false;
private shutdownHandler: (() => void) | null = null;
constructor(token: string) {
this.tokenDigest = digest(token);
}
/** Constant-time token check (no consumption). */
verifyToken(candidate: string): boolean {
return timingSafeEqual(digest(candidate), this.tokenDigest);
}
/** One-shot login redemption: true exactly once, for the correct token. */
redeemLoginToken(candidate: string): boolean {
if (this.loginConsumed || !this.verifyToken(candidate)) return false;
this.loginConsumed = true;
return true;
}
/** index.ts registers the actual graceful-shutdown trigger after assembly. */
onShutdownRequest(handler: () => void): void {
this.shutdownHandler = handler;
}
/** Invoked by the shutdown route; false when no handler is registered (tests). */
requestShutdown(): boolean {
if (!this.shutdownHandler) return false;
this.shutdownHandler();
return true;
}
}