diff --git a/.changeset/embedded-pg-utf8-initdb.md b/.changeset/embedded-pg-utf8-initdb.md new file mode 100644 index 0000000000..df8ebefc1b --- /dev/null +++ b/.changeset/embedded-pg-utf8-initdb.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Embedded PostgreSQL clusters are now always created UTF-8, fixing dashboard crash-loops on non-UTF-8 Windows locales. +category: fix +dev: "GitHub issue #2286: initdb inherited the OS locale encoding (e.g. Turkish WIN1254, English WIN1252), so the UTF-8 schema SQL failed with 'character has no equivalent in encoding'. DEFAULT_EMBEDDED_INITDB_FLAGS now forces --encoding=UTF8 --locale=C on every platform (caller flags appended after, so overridable). Not retroactive: existing non-UTF-8 clusters must delete ~/.fusion/embedded-postgres/default; the schema-apply failure now says exactly that, and boot errors include the full error cause chain instead of dropping it." diff --git a/.changeset/windows-no-helper-account.md b/.changeset/windows-no-helper-account.md new file mode 100644 index 0000000000..5e4d63c71e --- /dev/null +++ b/.changeset/windows-no-helper-account.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Elevated Windows no longer creates a local 'fusion-pg' account to boot embedded PostgreSQL; leftover accounts are removed. +category: fix +dev: "Replaces the Start-Process -Credential non-admin-user launcher with pg_ctl's built-in restricted-token re-exec (embedded-windows-elevated.ts). Removes user creation, icacls grants, and the cmd/PowerShell wrapper — also eliminating the 'directory name is invalid' launch failure and the EBUSY on wrapper-held postgres.log. The elevated path now best-effort deletes a legacy fusion-pg account on start." diff --git a/.github/workflows/verify-elevated-restricted.yml b/.github/workflows/verify-elevated-restricted.yml new file mode 100644 index 0000000000..9f56b64755 --- /dev/null +++ b/.github/workflows/verify-elevated-restricted.yml @@ -0,0 +1,86 @@ +# FNXC:WindowsDesktopPackaging 2026-07-17-22:30: +# Branch verification for the restricted-token elevated postgres launch. +# Proves on the elevated runner that (1) postgres boots via pg_ctl's +# restricted-token re-exec with NO helper account, (2) a pre-created legacy +# 'fusion-pg' account is deleted by the launch path, and (3) a stop + restart +# cycle on the same data dir works (EBUSY log regression). +name: Verify Elevated Restricted-Token Postgres + +on: + workflow_dispatch: + push: + branches: [feature/win-elevated-no-user] + +jobs: + verify-elevated-restricted: + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build @fusion/core + run: pnpm --filter @fusion/core build + + # Simulate a machine polluted by the previous release: the launch path + # must delete this account (verified inside the script). + - name: Pre-create legacy fusion-pg account + shell: pwsh + run: | + $pass = "Fx9!" + ([guid]::NewGuid().ToString("N")) + "#kP" + net user fusion-pg $pass /add /y + if ($LASTEXITCODE -ne 0) { throw "could not pre-create legacy account" } + Write-Host "legacy fusion-pg account pre-created" + + - name: "Verify: elevated boot via restricted token, no account" + run: node scripts/verify-windows-elevated-restricted.mjs + + # Full-app proof: the real CLI boot smoke (fn --help + fn serve with a live + # /api/health) on the ELEVATED runner, driving startup-factory through the + # restricted-token embedded-PG path end to end — the desktop scenario. + boot-smoke-elevated: + runs-on: windows-latest + timeout-minutes: 40 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build workspace + run: pnpm build + + - name: Boot smoke (fn --help + serve /api/health, elevated) + run: pnpm smoke:boot + + - name: Assert no fusion-pg account was created + shell: pwsh + run: | + net user fusion-pg 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { Write-Error "boot smoke created a fusion-pg account"; exit 1 } + Write-Host "no fusion-pg account exists after full app boot" + # pwsh propagates the last external command's exit code (net.exe = 2 + # when the account is absent, which is the PASS condition) — force 0. + exit 0 diff --git a/packages/core/src/__tests__/postgres/embedded-lifecycle.test.ts b/packages/core/src/__tests__/postgres/embedded-lifecycle.test.ts index 9412f1e938..f28a156583 100644 --- a/packages/core/src/__tests__/postgres/embedded-lifecycle.test.ts +++ b/packages/core/src/__tests__/postgres/embedded-lifecycle.test.ts @@ -1167,6 +1167,37 @@ describe("embedded-lifecycle: shared-memory-safe postgres flags", () => { } }); + /* + * FNXC:PostgresEmbedded 2026-07-18-00:20: + * Issue #2286: initdb without --encoding inherits the OS locale encoding; on + * non-UTF-8 Windows locales (WIN1254/WIN1252) the cluster cannot store the + * UTF-8 schema SQL and the dashboard crash-loops. The lifecycle must force a + * UTF-8 cluster on EVERY platform, with caller flags appended after (initdb + * takes the last occurrence of a repeated option, so callers can override). + */ + it("forces a UTF-8 initdb (issue #2286) and appends caller initdb flags after the defaults", async () => { + const dataDir = makeDataDir(); + const records: Record[] = []; + installCtorRecorder(records); + try { + const lifecycle = new EmbeddedPostgresLifecycle({ + ...baseOptions(dataDir), + initdbFlags: ["--data-checksums"], + }); + + await expect(lifecycle.start()).rejects.toBe(sentinel); + expect(records).toHaveLength(1); + expect(records[0]?.initdbFlags).toEqual([ + "--encoding=UTF8", + "--locale=C", + "--data-checksums", + ]); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } + }); + + it("passes the ordered defaults and caller override through the elevated Windows launcher", async () => { const dataDir = makeDataDir(); const records: Record[] = []; diff --git a/packages/core/src/__tests__/postgres/embedded-windows-admin.test.ts b/packages/core/src/__tests__/postgres/embedded-windows-admin.test.ts deleted file mode 100644 index bf8d94c33a..0000000000 --- a/packages/core/src/__tests__/postgres/embedded-windows-admin.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { DEFAULT_EMBEDDED_POSTGRES_FLAGS } from "../../postgres/embedded-lifecycle.js"; -import { - buildNonAdminLauncherPs1, - sanitizePostgresFlags, -} from "../../postgres/embedded-windows-admin.js"; - -/* - * FNXC:PostgresEmbedded 2026-07-16-12:45: - * The constrained-host shared-memory default must retain its exact `-c` form - * through the Windows cmd.exe launcher sanitizer. This is pure validation - * coverage; it does not require an elevated process or a Windows binary. - */ -describe("sanitizePostgresFlags", () => { - it("preserves the shared-memory default and a caller override unchanged", () => { - const flags = [...DEFAULT_EMBEDDED_POSTGRES_FLAGS, "-c", "shared_memory_type=sysv"]; - - expect(sanitizePostgresFlags(flags)).toEqual(flags); - }); -}); - -/* - * FNXC:WindowsDesktopPackaging 2026-07-17-21:20: - * Start-Process -Credential (CreateProcessWithLogonW) validates the working - * directory as the TARGET user. Without an explicit -WorkingDirectory it - * inherits the desktop app's cwd (the admin user's profile / install dir), - * which 'fusion-pg' cannot access, and the launch dies with "The directory - * name is invalid". The launcher must pin -WorkingDirectory to the granted - * .pgrunner run dir, passed as a discrete -File param. - */ -describe("buildNonAdminLauncherPs1", () => { - it("pins Start-Process to the granted run dir via -WorkingDirectory", () => { - const script = buildNonAdminLauncherPs1(); - - expect(script).toContain("[string]$RunDir"); - const startProcessLine = script - .split("\r\n") - .find((line) => line.includes("Start-Process")); - expect(startProcessLine).toContain("-WorkingDirectory $RunDir"); - expect(startProcessLine).toContain("-Credential $c"); - }); -}); diff --git a/packages/core/src/__tests__/postgres/embedded-windows-elevated.test.ts b/packages/core/src/__tests__/postgres/embedded-windows-elevated.test.ts new file mode 100644 index 0000000000..5f924e0812 --- /dev/null +++ b/packages/core/src/__tests__/postgres/embedded-windows-elevated.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_EMBEDDED_POSTGRES_FLAGS } from "../../postgres/embedded-lifecycle.js"; +import { + buildPgCtlOptionsString, + buildPgCtlStartArgs, + sanitizePostgresFlags, +} from "../../postgres/embedded-windows-elevated.js"; + +/* + * FNXC:PostgresEmbedded 2026-07-16-12:45 (retained): + * The constrained-host shared-memory default must retain its exact `-c` form + * through the elevated launcher sanitizer. Pure validation coverage; no + * elevated process or Windows binary required. + */ +describe("sanitizePostgresFlags", () => { + it("preserves the shared-memory default and a caller override unchanged", () => { + const flags = [...DEFAULT_EMBEDDED_POSTGRES_FLAGS, "-c", "shared_memory_type=sysv"]; + + expect(sanitizePostgresFlags(flags)).toEqual(flags); + }); + + it("rejects flags with quoting-sensitive characters", () => { + expect(() => sanitizePostgresFlags(['-c "evil"'])).toThrow(/quoting-sensitive/); + expect(() => sanitizePostgresFlags([""])).toThrow(/invalid postgresFlags/); + }); +}); + +/* + * FNXC:WindowsDesktopPackaging 2026-07-17-22:30: + * The elevated path must boot postgres via pg_ctl's restricted-token re-exec — + * NOT via a created local user account (operator complaint: Fusion created a + * 'fusion-pg' account) and NOT via Start-Process -Credential (which failed on + * end-user boxes with "The directory name is invalid" and held wrapper logs + * open, causing EBUSY). These assertions pin the pg_ctl invariants: no-wait + * launch, per-launch log file, port + flags carried in the -o option string. + */ +describe("pg_ctl elevated launch composition", () => { + it("carries the port and sanitized flags in the -o option string", () => { + expect(buildPgCtlOptionsString(55499, ["-c", "shared_memory_type=sysv"])).toBe( + "-p 55499 -c shared_memory_type=sysv", + ); + }); + + it("double-quotes option tokens containing spaces", () => { + expect(buildPgCtlOptionsString(5432, ["-c", "work_mem=64 MB"])).toBe( + '-p 5432 -c "work_mem=64 MB"', + ); + }); + + it("builds a no-wait start with a dedicated log file", () => { + const args = buildPgCtlStartArgs("C:\\data", "C:\\data\\.pgrunner\\pgctl-1.log", "-p 5432"); + + expect(args).toEqual([ + "-D", + "C:\\data", + "-o", + "-p 5432", + "-l", + "C:\\data\\.pgrunner\\pgctl-1.log", + "-W", + "start", + ]); + }); +}); diff --git a/packages/core/src/postgres/embedded-lifecycle.ts b/packages/core/src/postgres/embedded-lifecycle.ts index 40a2ee05a8..8d325e2f69 100644 --- a/packages/core/src/postgres/embedded-lifecycle.ts +++ b/packages/core/src/postgres/embedded-lifecycle.ts @@ -69,10 +69,10 @@ import { redactConnectionString } from "./credential-redact.js"; import type { ResolvedBackend } from "./backend-resolver.js"; import { isWindowsElevatedAdmin, - startServerAsNonAdminUser, - type NonAdminServerHandle, - type NonAdminStartOptions, -} from "./embedded-windows-admin.js"; + startServerElevatedRestricted, + type ElevatedServerHandle, + type ElevatedStartOptions, +} from "./embedded-windows-elevated.js"; // FNXC:WindowsDesktopPackaging 2026-07-14-22:53: // Static import so tsup/esbuild bundles postgres.js into packages/cli/dist/bin.js. // A runtime require("postgres") resolved via the CLI createRequire banner against @@ -80,7 +80,7 @@ import { // because @runfusion/fusion does not list postgres as a direct dependency. import postgres from "postgres"; -export { isWindowsElevatedAdmin } from "./embedded-windows-admin.js"; +export { isWindowsElevatedAdmin } from "./embedded-windows-elevated.js"; const require = createRequire(import.meta.url); @@ -581,7 +581,7 @@ export function __setEmbeddedPostgresCtorForTests(ctor: EmbeddedPostgresCtor | n let windowsElevatedAdminForTests: boolean | null = null; let windowsNativeRootForTests: string | null = null; let windowsLauncherForTests: - | ((opts: NonAdminStartOptions) => Promise) + | ((opts: ElevatedStartOptions) => Promise) | null = null; export function __setWindowsElevatedAdminForTests(value: boolean | null): void { @@ -593,7 +593,7 @@ export function __setWindowsEmbeddedPostgresNativeRootForTests(value: string | n } export function __setWindowsLauncherForTests( - launcher: ((opts: NonAdminStartOptions) => Promise) | null, + launcher: ((opts: ElevatedStartOptions) => Promise) | null, ): void { windowsLauncherForTests = launcher; } @@ -646,6 +646,29 @@ export function defaultEmbeddedPostgresFlagsFor(platform: NodeJS.Platform): read export const DEFAULT_EMBEDDED_POSTGRES_FLAGS = defaultEmbeddedPostgresFlagsFor(process.platform); +/* +FNXC:PostgresEmbedded 2026-07-18-00:20: +GitHub issue #2286: initdb without --encoding inherits the OS locale's +encoding. On non-UTF-8 Windows locales (Turkish WIN1254 in the report; the +elevated CI runner's own English WIN1252 reproduces it) the cluster is +created non-UTF-8 while Fusion always connects with client_encoding=UTF8 and +ships UTF-8 characters (→, U+2192) in the schema SQL — schema apply then +fails with `character ... has no equivalent in encoding "WIN12xx"` and the +dashboard crash-loops. Force a UTF-8 cluster at creation, unconditionally on +every platform (client_encoding is UTF8 everywhere; on already-UTF-8 systems +this is a no-op). --locale=C keeps initdb from deriving the encoding from a +non-UTF-8 inherited locale and gives deterministic collation. Caller-supplied +--encoding/--locale flags win: initdb takes the LAST occurrence of a +repeated option, and callers' flags are appended after these defaults. +NOT retroactive: an existing non-UTF-8 cluster cannot be converted in place; +affected installs must delete the embedded data dir and let Fusion recreate +it (see the actionable schema-apply error hint in startup-factory). +*/ +export const DEFAULT_EMBEDDED_INITDB_FLAGS: readonly string[] = [ + "--encoding=UTF8", + "--locale=C", +]; + /** * FNXC:PostgresEmbedded 2026-06-24-09:05: * Default data directory location for the embedded cluster. Mirrors the @@ -1052,12 +1075,13 @@ export class EmbeddedPostgresLifecycle { private ownsProcess = true; private shutdownHookInstalled = false; /** - * FNXC:WindowsDesktopPackaging 2026-07-14-21:40: - * When the process is an elevated Windows admin, the server is booted under a - * dedicated non-admin user (see embedded-windows-admin.ts) and this holds the - * stop handle. Null for normal (non-elevated / non-Windows) launches. + * FNXC:WindowsDesktopPackaging 2026-07-17-22:30: + * When the process is an elevated Windows admin, the server is booted via + * pg_ctl's restricted-token re-exec (see embedded-windows-elevated.ts — no + * helper account is created) and this holds the stop handle. Null for normal + * (non-elevated / non-Windows) launches. */ - private nonAdminHandle: NonAdminServerHandle | null = null; + private nonAdminHandle: ElevatedServerHandle | null = null; /** * FNXC:PostgresEmbedded 2026-06-26-16:20 (fix migration-review P1 #24): * Active start() timeout timer, retained so it can be cleared on success or @@ -1072,7 +1096,7 @@ export class EmbeddedPostgresLifecycle { port: opts.port, user: opts.user ?? DEFAULT_EMBEDDED_USER, password: opts.password ?? DEFAULT_EMBEDDED_PASSWORD, - initdbFlags: opts.initdbFlags ?? [], + initdbFlags: [...DEFAULT_EMBEDDED_INITDB_FLAGS, ...(opts.initdbFlags ?? [])], postgresFlags: [...DEFAULT_EMBEDDED_POSTGRES_FLAGS, ...(opts.postgresFlags ?? [])], startTimeoutMs: opts.startTimeoutMs ?? DEFAULT_START_TIMEOUT_MS, onLog: opts.onLog ?? ((msg: string) => log.log(msg)), @@ -1306,7 +1330,7 @@ export class EmbeddedPostgresLifecycle { "non-elevated, or ensure the embedded-postgres platform package is installed.", ); } - this.nonAdminHandle = await (windowsLauncherForTests ?? startServerAsNonAdminUser)({ + this.nonAdminHandle = await (windowsLauncherForTests ?? startServerElevatedRestricted)({ nativeRoot, dataDir: this.options.dataDir, port, @@ -1330,7 +1354,7 @@ export class EmbeddedPostgresLifecycle { // this process starts Postgres. Re-read that lock and join its instance // rather than surfacing the expected lock-file collision to the TUI. // FNXC:PostgresStartupRace 2026-07-15-20:06: A cancelled start must never - // be rescued into a success. `startServerAsNonAdminUser` rejects on abort + // be rescued into a success. `startServerElevatedRestricted` rejects on abort // from inside this try, so without this guard a timeout-cancelled launch // that happens to see a postmaster.pid would publish a joined instance // instead of the EmbeddedStartCancelledError the post-start phases raise. diff --git a/packages/core/src/postgres/embedded-windows-admin.ts b/packages/core/src/postgres/embedded-windows-admin.ts deleted file mode 100644 index cd4da90333..0000000000 --- a/packages/core/src/postgres/embedded-windows-admin.ts +++ /dev/null @@ -1,626 +0,0 @@ -// FNXC:WindowsDesktopPackaging 2026-07-14-21:30: -// Embedded PostgreSQL refuses to start under a Windows process token whose -// Administrators group is ENABLED (a high-integrity / elevated token). It exits -// immediately with "Execution of PostgreSQL by a user with administrative -// permissions is not permitted." The bundled embedded-postgres server is -// spawned as a DIRECT child of the Node process (see embedded-postgres -// dist/index.js start()), so it inherits that elevated token and cannot boot. -// This only affects ELEVATED launches: GitHub windows-latest runners execute -// jobs as `runneradmin` with a fully elevated token (the smoke build fails -// here), and an end user who explicitly "Run as administrator" hits the same -// refusal. A normal Electron asInvoker launch uses a filtered/medium token -// which Postgres accepts. -// -// Fix: when the current process is elevated, boot the postgres SERVER process -// (only) under a freshly-created NON-ADMIN local user via Start-Process -// -Credential (CreateProcessWithLogonW). That user's token has no enabled -// Administrators group, so Postgres accepts it. initdb / the pg client / -// createDatabase still run as the (admin) launching process and work unchanged; -// only the server start is re-homed. Proven on the windows-2025-vs2026 runner: -// postgres reached "database system is ready to accept connections" under the -// dedicated non-admin user (broker diagnostic run 29382479266, job 87248898326). -// -// Access model: Windows grants "Bypass traverse checking" to Everyone by -// default, so the non-admin user does NOT need permission on parent dirs — only -// on the target dirs themselves. We grant the user RX on the native binary root -// and full control on the data dir. - -import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { createConnection } from "node:net"; -import { join, dirname } from "node:path"; - -/** Handle returned by {@link startServerAsNonAdminUser}; call stop() to kill it. */ -export interface NonAdminServerHandle { - /** - * Best-effort OS pid of the running postgres server (from postmaster.pid when - * available). May be the cmd wrapper pid until postmaster.pid appears. - */ - readonly postgresPid: number; - /** - * Stop the non-admin postgres process (taskkill). Safe to call once. - * - * FNXC:PostgresStartupRace 2026-07-15-21:10: - * Resolves its target through the data dir's `postmaster.pid`, so it kills whichever - * postmaster currently owns that dir — NOT necessarily the one this handle launched. Only - * call it when this process is the sole starter. A caller that lost a startup race to - * another process must use {@link stopWrapperOnly}, or it will kill the winner. - */ - stop(): Promise; - /** - * Kill only the wrapper this handle launched (and its children), never the postmaster named - * by the shared `postmaster.pid`. - * - * FNXC:PostgresStartupRace 2026-07-15-21:10: - * Exists for the lost-startup-race path: our postgres refused the lock and exited, so the - * wrapper is dead or dying, but `postmaster.pid` now belongs to the process we are about to - * join. Dropping the handle would leak the wrapper; calling {@link stop} would kill the - * winner. This kills our side only, and is a harmless no-op once the wrapper has exited. - */ - stopWrapperOnly(): Promise; -} - -export interface NonAdminStartOptions { - /** .../native dir containing bin/postgres.exe + lib + share. */ - readonly nativeRoot: string; - /** The initialized PG data directory. */ - readonly dataDir: string; - /** TCP port postgres should listen on. */ - readonly port: number; - /** Extra flags forwarded to postgres.exe (same semantics as embedded-postgres). */ - readonly postgresFlags: readonly string[]; - readonly onLog: (message: string) => void; - readonly onError: (messageOrError: string | Error | unknown) => void; - /** Hard timeout (ms) on reaching "ready to accept connections". */ - readonly startTimeoutMs: number; - /** - * FNXC:WindowsDesktopPackaging 2026-07-15-05:20: - * Cooperative cancellation from EmbeddedPostgresLifecycle.start()'s AbortController. - * When aborted during readiness polling, kill the wrapper/postmaster immediately. - */ - readonly signal?: AbortSignal; - /** - * Invoked as soon as the cmd wrapper PID is known (before readiness) so the - * lifecycle can stop orphans if the outer start() timeout wins the race. - */ - readonly onLaunched?: (handle: NonAdminServerHandle) => void; -} - -let elevatedCache: boolean | null = null; - -/** - * True only on Windows when the current process holds an elevated admin token. - * `net session` succeeds (exit 0) exclusively under an elevated admin token, so - * it is a reliable elevation probe that does not depend on UAC EnableLUA. - */ -export function isWindowsElevatedAdmin(): boolean { - if (process.platform !== "win32") return false; - if (elevatedCache !== null) return elevatedCache; - const r = spawnSync("net", ["session"], { encoding: "utf8", shell: true }); - elevatedCache = r.status === 0; - return elevatedCache; -} - -const DEDICATED_USER = "fusion-pg"; -let dedicatedPassword: string | null = null; - -/** - * FNXC:WindowsDesktopPackaging 2026-07-15-05:25: - * Fully randomized password with all four complexity classes and no fixed - * prefix/suffix (review feedback: constant frames reduce entropy). Avoids the - * account-name token so Windows complexity policy accepts it. - */ -function generatePassword(): string { - const upper = "ABCDEFGHJKLMNPQRSTUVWXYZ"; - const lower = "abcdefghijkmnopqrstuvwxyz"; - const digits = "23456789"; - const symbols = "!@#$%^*_-+=?"; - const all = upper + lower + digits + symbols; - const seed = - spawnSync( - "powershell", - ["-NoProfile", "-Command", "[BitConverter]::ToString([guid]::NewGuid().ToByteArray()) + [BitConverter]::ToString([guid]::NewGuid().ToByteArray())"], - { encoding: "utf8" }, - ).stdout ?? Math.random().toString(36) + Math.random().toString(36); - - const required = [ - upper[Math.floor(Math.random() * upper.length)]!, - lower[Math.floor(Math.random() * lower.length)]!, - digits[Math.floor(Math.random() * digits.length)]!, - symbols[Math.floor(Math.random() * symbols.length)]!, - ]; - let body = ""; - for (const ch of seed) { - if (/[a-zA-Z0-9]/.test(ch)) { - const idx = parseInt(ch.toLowerCase(), 16); - if (Number.isFinite(idx)) body += all[idx % all.length]!; - } - if (body.length >= 20) break; - } - while (body.length < 20) body += all[Math.floor(Math.random() * all.length)]!; - const chars = [...required, ...body.split("")]; - for (let i = chars.length - 1; i > 0; i -= 1) { - const j = Math.floor(Math.random() * (i + 1)); - const tmp = chars[i]!; - chars[i] = chars[j]!; - chars[j] = tmp; - } - return chars.join(""); -} - -/** - * Ensure the dedicated non-admin local user exists and we know its password. - * Idempotent: creates the user if absent, or resets its password if present - * (so a leftover account from a prior run still works). Always strips - * Administrators membership so a reused account cannot stay elevated. - */ -function ensureNonAdminUser(): { user: string; password: string } { - if (dedicatedPassword) return { user: DEDICATED_USER, password: dedicatedPassword }; - const password = generatePassword(); - const add = spawnSync("net", ["user", DEDICATED_USER, password, "/add", "/y"], { - encoding: "utf8", - }); - if (add.status !== 0) { - // Likely already exists from a prior run: reset its password so we can log on. - const reset = spawnSync("net", ["user", DEDICATED_USER, password, "/y"], { - encoding: "utf8", - }); - if (reset.status !== 0) { - throw new Error( - `embedded postgres: could not create/reset non-admin user '${DEDICATED_USER}' ` + - `(net user add status=${add.status}: ${(add.stderr || "").trim()}; ` + - `reset status=${reset.status}: ${(reset.stderr || "").trim()}).`, - ); - } - } - // FNXC:WindowsDesktopPackaging 2026-07-15-05:25: - // A leftover fusion-pg that was manually promoted to Administrators would - // still be refused by postgres. Demote and fail closed unless the account is - // already not a member (review: ignore silent demote failures). - // FNXC:WindowsDesktopPackaging 2026-07-14-22:53: - // net localgroup /delete status 0 = removed; non-zero is OK only when the - // account was already not in Administrators ("not a member" / "could not find"). - const demote = spawnSync( - "net", - ["localgroup", "Administrators", DEDICATED_USER, "/delete"], - { encoding: "utf8" }, - ); - if (demote.status !== 0) { - const demoteOut = `${demote.stdout || ""}\n${demote.stderr || ""}`.toLowerCase(); - const alreadyNotMember = - demoteOut.includes("not a member") || - demoteOut.includes("could not find") || - demoteOut.includes("no such") || - demoteOut.includes("does not exist"); - if (!alreadyNotMember) { - throw new Error( - `embedded postgres: failed to remove '${DEDICATED_USER}' from Administrators ` + - `(net localgroup status=${demote.status}): ` + - `${(demote.stderr || demote.stdout || "").trim().slice(0, 400)}. ` + - "PostgreSQL refuses to start under an administrative token; demote the " + - "account or run Fusion non-elevated.", - ); - } - } - dedicatedPassword = password; - return { user: DEDICATED_USER, password }; -} - -/** - * FNXC:WindowsDesktopPackaging 2026-07-14-22:30: - * Grant traverse (RX) on each ancestor dir of `leaf` up to the drive root, so - * the non-admin user can reach `leaf` even when "Bypass traverse checking" is - * restricted (the windows-2025 runner) or a profile ACL would deny traversal. - * RX is applied folder-by-folder as a non-inheriting ACE so sibling contents - * are not over-granted. Best-effort: ancestors that already allow traverse - * (e.g. C:\) reject harmlessly, and a blocked path surfaces later via the - * Start-Process error (which carries the full stderr). - */ -function grantTraverseChain(user: string, leaf: string): void { - let dir = dirname(leaf); - for (let depth = 0; depth < 16; depth += 1) { - const parent = dirname(dir); - if (parent === dir) break; // drive root reached - if (existsSync(dir)) { - spawnSync("icacls", [dir, "/grant", `${user}:(RX)`, "/C"], { encoding: "utf8" }); - } - dir = parent; - } -} - -/** - * Grant the non-admin user full control on the data dir (postgres writes - * there), read+execute on the native binary root, and traverse on each parent - * ancestor of both so the user can reach them. F/RX grants fail fast; the - * traverse walk is best-effort. /T applies the F/RX grants recursively; /C - * keeps going on non-fatal errors (e.g. unreadable sibling files). - */ -function grantNonAdminAccess(user: string, nativeRoot: string, dataDir: string): void { - for (const [target, perm] of [ - [dataDir, "(OI)(CI)F"], - [nativeRoot, "(OI)(CI)RX"], - ] as const) { - if (!existsSync(target)) { - throw new Error(`embedded postgres: non-admin grant target does not exist: ${target}`); - } - const r = spawnSync("icacls", [target, "/grant", `${user}:${perm}`, "/T", "/C"], { - encoding: "utf8", - }); - if (r.status !== 0) { - throw new Error( - `embedded postgres: failed to grant '${user}' ${perm} on ${target} ` + - `(icacls status=${r.status}): ${(r.stderr || "").trim().slice(0, 400)}`, - ); - } - } - grantTraverseChain(user, dataDir); - grantTraverseChain(user, nativeRoot); -} - -function readPostgresPid(dataDir: string): number | null { - try { - const lines = readFileSync(join(dataDir, "postmaster.pid"), "utf-8").split("\n"); - const pid = parseInt((lines[0] ?? "").trim(), 10); - return Number.isFinite(pid) && pid > 0 ? pid : null; - } catch { - return null; - } -} - -function readTail(file: string, max: number): string { - try { - const content = readFileSync(file, "utf-8"); - return content.length > max ? "…" + content.slice(-max) : content; - } catch { - return "(no log file)"; - } -} - -/** - * FNXC:WindowsDesktopPackaging 2026-07-15-05:25: - * Reject postgresFlags that would break cmd.exe quoting or enable injection - * when embedded into launch.bat (review: arbitrary flags with % " & | etc.). - */ -export function sanitizePostgresFlags(flags: readonly string[]): string[] { - const safe: string[] = []; - for (const flag of flags) { - if (typeof flag !== "string" || flag.length === 0) { - throw new Error(`embedded postgres: invalid postgresFlags entry (empty/non-string)`); - } - if (/[\r\n"%&|<>^!]/.test(flag)) { - throw new Error( - `embedded postgres: postgresFlags entry contains cmd.exe-sensitive characters: ${JSON.stringify(flag)}`, - ); - } - safe.push(flag); - } - return safe; -} - -/** Quote a path for cmd.exe double-quoted args (escape embedded quotes). */ -function cmdQuote(value: string): string { - return `"${value.replace(/"/g, '""')}"`; -} - -let pwshCache: string | null | undefined; -/** - * FNXC:WindowsDesktopPackaging 2026-07-14-22:10: - * Resolve the PowerShell binary used to launch the non-admin server. Prefer - * PowerShell 7 (`pwsh`): the windows-2025 runner runs Windows PowerShell 5.1 - * (`powershell.exe`) in Constrained Language Mode, where the - * Microsoft.PowerShell.Security module cannot load (ConvertTo-SecureString - * fails). pwsh runs unconstrained and is what the proven broker diagnostic - * used. Fall back to powershell.exe for end-user boxes that only have 5.1 in - * Full Language Mode. - */ -function resolvePowerShell(): string { - if (pwshCache !== undefined) return pwshCache as string; - const pf = process.env.PROGRAMFILES; - const pf86 = process.env["ProgramFiles(x86)"]; - const candidates = [ - pf ? join(pf, "PowerShell", "7", "pwsh.exe") : null, - pf86 ? join(pf86, "PowerShell", "7", "pwsh.exe") : null, - ].filter((v): v is string => v !== null); - for (const c of candidates) { - if (existsSync(c)) { - pwshCache = c; - return c; - } - } - const where = spawnSync("where", ["pwsh"], { encoding: "utf8", shell: true }); - if (where.status === 0) { - const found = (where.stdout || "") - .split(/\r?\n/) - .map((s) => s.trim()) - .find(Boolean); - if (found) { - pwshCache = found; - return found; - } - } - pwshCache = "powershell.exe"; - return pwshCache; -} - -/** - * Content of the parametrized launcher .ps1 that boots the wrapper bat under - * the non-admin credential. Exported for tests (win32-only at runtime). - * - * FNXC:WindowsDesktopPackaging 2026-07-17-21:20: - * Start-Process -Credential goes through CreateProcessWithLogonW, which - * validates the working directory AS THE TARGET USER. Without an explicit - * -WorkingDirectory it inherits the launching process's cwd — for the desktop - * app that is under the admin user's profile (or the install dir), which - * 'fusion-pg' cannot read, and Windows fails the launch with "The directory - * name is invalid" (GitHub issue with desktop boot on end-user boxes; CI - * runners masked it because their cwd was world-traversable). Pass the - * .pgrunner run dir explicitly: it lives inside the data dir the user was just - * granted (OI)(CI)F on, so it is always accessible to the credential. - */ -export function buildNonAdminLauncherPs1(): string { - return [ - "param([string]$User,[string]$Password,[string]$DomainUser,[string]$Bat,[string]$RunDir)", - "$ErrorActionPreference='Stop'", - // FNXC:WindowsDesktopPackaging 2026-07-14-22:15: - // Build the SecureString char-by-char instead of ConvertTo-SecureString, - // which lives in Microsoft.PowerShell.Security — a module that fails to - // load under Windows PowerShell 5.1 Constrained Language Mode. System. - // Security.SecureString + PSCredential are core SMA/.NET types available - // without that module. - "$s = New-Object System.Security.SecureString", - "foreach ($ch in $Password.ToCharArray()) { [void]$s.AppendChar($ch) }", - "$s.MakeReadOnly()", - "$c = New-Object System.Management.Automation.PSCredential($DomainUser,$s)", - "$p = Start-Process -FilePath 'cmd.exe' -ArgumentList '/c',$Bat -Credential $c -WorkingDirectory $RunDir -WindowStyle Hidden -PassThru", - "Write-Output $p.Id", - "", - ].join("\r\n"); -} - -/** - * Start postgres.exe under the dedicated non-admin user and resolve once it is - * accepting connections. Rejects with a clear error (including the postgres log - * tail) on timeout or early exit. The returned handle's stop() kills the server. - */ -export async function startServerAsNonAdminUser( - opts: NonAdminStartOptions, -): Promise { - const { user, password } = ensureNonAdminUser(); - grantNonAdminAccess(user, opts.nativeRoot, opts.dataDir); - - const pgExe = join(opts.nativeRoot, "bin", "postgres.exe"); - const runDir = join(opts.dataDir, ".pgrunner"); - mkdirSync(runDir, { recursive: true }); - const logFile = join(runDir, "postgres.log"); - // FNXC:WindowsDesktopPackaging 2026-07-14-22:50: - // Separate wrapper log: the bat echoes whoami / cwd / the exact postgres - // command / the exit code here (cmd's own output), while postgres's output - // goes to logFile. This distinguishes "bat never ran", "postgres exited", - // and "postgres running but not listening" — postgres.log alone can be empty - // when the bat never reaches the postgres command. - const wrapperLog = join(runDir, "wrapper.log"); - // FNXC:WindowsDesktopPackaging 2026-07-15-05:05: - // Truncate logs each launch. The bat appends (>>) so a prior stop that wrote - // `exit=1` would make the readiness poll throw "exited before becoming ready" - // on the next start against a reused data directory (VAL-CONN-006). - writeFileSync(logFile, "", "utf8"); - writeFileSync(wrapperLog, "", "utf8"); - const bat = join(runDir, "launch.bat"); - const safeFlags = sanitizePostgresFlags(opts.postgresFlags); - const args = ["-D", opts.dataDir, "-p", String(opts.port), ...safeFlags]; - // Set TMP/TEMP inside the granted data dir so the non-admin postgres process - // never writes outside an accessible location. - const argStr = args.map((a) => cmdQuote(a)).join(" "); - // FNXC:WindowsDesktopPackaging 2026-07-15-05:25: - // UTF-8 + chcp 65001 so non-ASCII profile paths (e.g. C:\Users\José) are not - // corrupted when cmd.exe reads the bat (review: ASCII encoding broke paths). - writeFileSync( - bat, - [ - "@echo off", - "chcp 65001 >nul", - `set "TMP=${runDir}"`, - `set "TEMP=${runDir}"`, - `call :main >> ${cmdQuote(wrapperLog)} 2>&1`, - "exit /b", - ":main", - "echo launch-start", - "whoami", - "cd", - `echo cmd: ${cmdQuote(pgExe)} ${argStr}`, - `${cmdQuote(pgExe)} ${argStr} > ${cmdQuote(logFile)} 2>&1`, - "echo exit=%ERRORLEVEL%", - "", - ].join("\r\n"), - "utf8", - ); - - const computerName = process.env.COMPUTERNAME ?? ""; - const domainUser = computerName ? `${computerName}\\${user}` : user; - // Launch detached under the non-admin credential. Start-Process returns at - // once with a process object (postgres keeps running in the background). - // - // FNXC:WindowsDesktopPackaging 2026-07-14-21:50: - // Use a parametrized launcher .ps1 invoked with -File + params, NOT an inline - // -Command string. The bat path contains backslashes (literal in a PS - // single-quoted string — doubling them would corrupt it to C:\\...) and the - // password contains ! and #; passing each as a discrete argv token via -File - // params is robust across Node's Windows arg escaping and PowerShell parsing. - const launcherPs1 = join(runDir, "launch.ps1"); - writeFileSync(launcherPs1, buildNonAdminLauncherPs1(), "utf8"); - const powerShell = resolvePowerShell(); - const launch = spawnSync( - powerShell, - [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - launcherPs1, - "-User", - user, - "-Password", - password, - "-DomainUser", - domainUser, - "-Bat", - bat, - "-RunDir", - runDir, - ], - { encoding: "utf8" }, - ); - const wrapperPid = parseInt((launch.stdout || "").trim(), 10); - if (!Number.isFinite(wrapperPid)) { - throw new Error( - `embedded postgres: failed to launch non-admin postgres ` + - `(${powerShell} status=${launch.status} ` + - `stdout=${(launch.stdout || "").trim().slice(0, 500)} ` + - `stderr=${(launch.stderr || "").trim().slice(0, 2000)}).`, - ); - } - - let stopped = false; - const killAll = (): void => { - if (stopped) return; - stopped = true; - const pid = readPostgresPid(opts.dataDir); - if (pid) spawnSync("taskkill", ["/pid", String(pid), "/f", "/t"], { encoding: "utf8" }); - spawnSync("taskkill", ["/pid", String(wrapperPid), "/f", "/t"], { encoding: "utf8" }); - }; - - // FNXC:WindowsDesktopPackaging 2026-07-15-05:20: - // Publish a stop handle immediately so lifecycle timeout cleanup can kill the - // wrapper even while readiness is still polling (review: orphan on timeout). - const handle: NonAdminServerHandle = { - get postgresPid() { - return readPostgresPid(opts.dataDir) ?? wrapperPid; - }, - async stop() { - killAll(); - }, - async stopWrapperOnly() { - if (stopped) return; - stopped = true; - // /t takes our wrapper's own children (our postgres.exe, if it ever came up). It cannot - // reach a racing winner: that postmaster is another process's child, not ours. - spawnSync("taskkill", ["/pid", String(wrapperPid), "/f", "/t"], { encoding: "utf8" }); - }, - }; - opts.onLaunched?.(handle); - - opts.onLog( - `embedded postgres: launched postgres as non-admin user '${user}' (wrapper pid ${wrapperPid}); ` + - `waiting for port ${opts.port}`, - ); - - // Poll for readiness until the server accepts connections or the timeout hits. - // FNXC:WindowsDesktopPackaging 2026-07-14-22:53: - // startTimeoutMs <= 0 means unbounded (matches outer lifecycle: 0 disables - // the start timeout). Math.max(..., 1000) previously forced a 1s deadline and - // killed elevated boots when callers disabled the timeout (review feedback). - const hasDeadline = - opts.startTimeoutMs > 0 && Number.isFinite(opts.startTimeoutMs); - const deadline = hasDeadline - ? Date.now() + opts.startTimeoutMs - : Number.POSITIVE_INFINITY; - let ready = false; - let lastSnapshot = ""; - while (Date.now() < deadline) { - if (opts.signal?.aborted) { - killAll(); - throw new Error( - `embedded postgres: non-admin launch cancelled before ready (wrapper pid ${wrapperPid}).`, - ); - } - // FNXC:WindowsDesktopPackaging 2026-07-14-23:05: - // Lightweight poll: readFileSync only. Do NOT spawn tasklist/probePort in - // the hot loop — a synchronous tasklist per iteration blocked ~16s between - // polls on windows-2025, blowing the test's 15s budget before postgres's - // "ready" marker was observed (and orphaning servers when start() never - // returned). Readiness = the postgres log "ready to accept connections" - // marker (the same one embedded-postgres watches). Exit = the wrapper bat's - // "exit=" line (written only once postgres returns). Errors = a FATAL in - // the postgres log. Logs are emitted only on change to avoid per-poll spam. - const tail = readTail(logFile, 3000); - const wrapperTail = readTail(wrapperLog, 1500); - const snapshot = `${wrapperTail}\u0000${tail.slice(-400)}`; - if (snapshot !== lastSnapshot) { - lastSnapshot = snapshot; - opts.onLog(`non-admin poll wrapper={${wrapperTail}} pg={${tail.slice(-400)}}`); - } - if (/database system is ready to accept connections/.test(tail)) { - // FNXC:WindowsDesktopPackaging 2026-07-15-05:00: - // Log readiness alone is not enough: confirm TCP accept on 127.0.0.1 so - // ensureDatabase cannot hang on a connect that never completes (IPv6 / - // cross-session loopback quirks). Probe is async and cheap. - if (await probeTcpPort(opts.port, 500)) { - ready = true; - break; - } - } - if (/\bFATAL\b|\bPANIC\b|could not (bind|start|create|access|connect|load)|not permitted|Permission denied|is not the owner/i.test(tail)) { - killAll(); - throw new Error( - `embedded postgres: non-admin postgres reported a startup error before opening the port.\n${tail}`, - ); - } - if (/^exit=/m.test(wrapperTail)) { - killAll(); - throw new Error( - `embedded postgres: non-admin postgres exited before becoming ready.\nwrapper={${wrapperTail}}\npg={${tail}}`, - ); - } - // Avoid Promise.withResolvers (needs lib es2024); package tsconfig stays on es2022. - await new Promise((resolve) => { - setTimeout(resolve, 200); - }); - } - - if (!ready) { - const tail = readTail(logFile, 1500); - killAll(); - throw new Error( - `embedded postgres: non-admin postgres did not become ready` + - (hasDeadline ? ` within ${opts.startTimeoutMs}ms` : "") + - `.\n${tail}`, - ); - } - - if (opts.signal?.aborted) { - killAll(); - throw new Error( - `embedded postgres: non-admin launch cancelled after ready (wrapper pid ${wrapperPid}).`, - ); - } - - const postgresPid = readPostgresPid(opts.dataDir); - if (!postgresPid) { - opts.onError("embedded postgres: started but could not read postmaster.pid"); - } - - const resolvedPid = postgresPid ?? wrapperPid; - opts.onLog( - `embedded postgres: non-admin server ready on 127.0.0.1:${opts.port} (pid ${resolvedPid})`, - ); - return handle; -} - -/** True when a TCP accept is available on 127.0.0.1:port within timeoutMs. */ -function probeTcpPort(port: number, timeoutMs: number): Promise { - return new Promise((resolve) => { - const socket = createConnection({ host: "127.0.0.1", port }); - let settled = false; - const finish = (ok: boolean) => { - if (settled) return; - settled = true; - socket.removeAllListeners(); - socket.destroy(); - resolve(ok); - }; - socket.setTimeout(timeoutMs); - socket.once("connect", () => finish(true)); - socket.once("timeout", () => finish(false)); - socket.once("error", () => finish(false)); - }); -} diff --git a/packages/core/src/postgres/embedded-windows-elevated.ts b/packages/core/src/postgres/embedded-windows-elevated.ts new file mode 100644 index 0000000000..b1f572ed5f --- /dev/null +++ b/packages/core/src/postgres/embedded-windows-elevated.ts @@ -0,0 +1,394 @@ +// FNXC:WindowsDesktopPackaging 2026-07-17-22:30: +// Embedded PostgreSQL refuses to start under a Windows process token whose +// Administrators group is ENABLED (elevated / "Run as administrator" launches +// and GitHub windows runners). The first fix booted the server under a +// freshly-created non-admin local account ('fusion-pg') via Start-Process +// -Credential, but that approach created a real user account on operator +// machines (explicit operator complaint: Fusion must never create local +// accounts), and its cmd/PowerShell wrapper machinery produced two field +// failures: CreateProcessWithLogonW rejecting the inherited cwd ("The +// directory name is invalid") and EBUSY on the wrapper-held postgres.log. +// +// This module replaces all of that with PostgreSQL's own built-in mechanism: +// pg_ctl.exe (bundled next to postgres.exe) detects an elevated token and +// re-executes itself under a RESTRICTED token (Administrators SID disabled via +// CreateRestrictedToken; see src/common/restricted_token.c in PostgreSQL). +// The postmaster inherits that restricted token and accepts it — the same +// mechanism that already lets initdb run elevated in our boot path. No user +// account, no password, no icacls grants, no credential launch, no wrapper +// bat. The restricted token keeps the operator's own identity, so the data +// dir under the user profile stays accessible without ACL changes. + +import { spawn, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { createConnection } from "node:net"; +import { join } from "node:path"; + +/** Handle returned by {@link startServerElevatedRestricted}; call stop() to kill it. */ +export interface ElevatedServerHandle { + /** + * Best-effort OS pid of the running postgres server (from postmaster.pid when + * available). 0 until postmaster.pid appears. + */ + readonly postgresPid: number; + /** + * Stop the postgres server (pg_ctl stop -m fast, taskkill fallback). Safe to + * call once. + * + * FNXC:PostgresStartupRace 2026-07-15-21:10 (semantics preserved): + * Resolves its target through the data dir (`pg_ctl -D` / postmaster.pid), so + * it stops whichever postmaster currently owns that dir — NOT necessarily the + * one this handle launched. Only call it when this process is the sole + * starter; a caller that lost a startup race must use {@link stopWrapperOnly}. + */ + stop(): Promise; + /** + * Reap only what this handle launched, never the postmaster named by the + * shared data dir. + * + * FNXC:WindowsDesktopPackaging 2026-07-17-22:30: + * With pg_ctl there is no wrapper process to reap: pg_ctl -W exits + * immediately after spawning the postmaster, and a postmaster that lost the + * postmaster.pid lock race exits on its own. This is therefore a no-op that + * only marks the handle stopped, kept so the lifecycle's lost-race path + * (which must never kill the race winner) stays shape-compatible. + */ + stopWrapperOnly(): Promise; +} + +export interface ElevatedStartOptions { + /** .../native dir containing bin/postgres.exe + bin/pg_ctl.exe + lib + share. */ + readonly nativeRoot: string; + /** The initialized PG data directory. */ + readonly dataDir: string; + /** TCP port postgres should listen on. */ + readonly port: number; + /** Extra flags forwarded to postgres.exe (same semantics as embedded-postgres). */ + readonly postgresFlags: readonly string[]; + readonly onLog: (message: string) => void; + readonly onError: (messageOrError: string | Error | unknown) => void; + /** Hard timeout (ms) on reaching "ready to accept connections". <=0 disables. */ + readonly startTimeoutMs: number; + /** Cooperative cancellation from EmbeddedPostgresLifecycle.start(). */ + readonly signal?: AbortSignal; + /** + * Invoked as soon as the postmaster launch is issued (before readiness) so + * the lifecycle can stop orphans if the outer start() timeout wins the race. + */ + readonly onLaunched?: (handle: ElevatedServerHandle) => void; +} + +let elevatedCache: boolean | null = null; + +/** + * True only on Windows when the current process holds an elevated admin token. + * `net session` succeeds (exit 0) exclusively under an elevated admin token, so + * it is a reliable elevation probe that does not depend on UAC EnableLUA. + */ +export function isWindowsElevatedAdmin(): boolean { + if (process.platform !== "win32") return false; + if (elevatedCache !== null) return elevatedCache; + const r = spawnSync("net", ["session"], { encoding: "utf8", shell: true }); + elevatedCache = r.status === 0; + return elevatedCache; +} + +/** + * FNXC:WindowsDesktopPackaging 2026-07-17-22:30: + * Earlier releases created a dedicated 'fusion-pg' local account for the + * credential-based launch. Fusion must not leave accounts it created on + * operator machines, so the replacement path deletes it best-effort on every + * elevated start (idempotent: exits non-zero when the account is absent). + */ +export function removeLegacyNonAdminUser(onLog: (message: string) => void): void { + try { + const r = spawnSync("net", ["user", "fusion-pg", "/delete"], { encoding: "utf8" }); + if (r.status === 0) { + onLog("embedded postgres: removed the legacy 'fusion-pg' local account created by earlier versions"); + } + } catch { + // Cleanup is strictly best-effort; never block startup on it. + } +} + +/** + * FNXC:WindowsDesktopPackaging 2026-07-15-05:25 (retained): + * Reject postgresFlags that could break the pg_ctl -o option-string quoting or + * smuggle extra options (\r\n, ", and shell-sensitive characters). + */ +export function sanitizePostgresFlags(flags: readonly string[]): string[] { + const safe: string[] = []; + for (const flag of flags) { + if (typeof flag !== "string" || flag.length === 0) { + throw new Error(`embedded postgres: invalid postgresFlags entry (empty/non-string)`); + } + if (/[\r\n"%&|<>^!]/.test(flag)) { + throw new Error( + `embedded postgres: postgresFlags entry contains quoting-sensitive characters: ${JSON.stringify(flag)}`, + ); + } + safe.push(flag); + } + return safe; +} + +/** + * Build the pg_ctl `-o` option string: server flags passed through to + * postgres.exe. Tokens containing spaces are double-quoted (sanitize rejects + * embedded quotes, so plain wrapping is safe). + */ +export function buildPgCtlOptionsString(port: number, flags: readonly string[]): string { + const tokens = ["-p", String(port), ...flags]; + return tokens.map((t) => (/\s/.test(t) ? `"${t}"` : t)).join(" "); +} + +/** + * pg_ctl argv for the elevated start. `-W` (no wait) so the call returns as + * soon as the postmaster is spawned; readiness is observed via the server log + * and a TCP probe, matching the previous launcher's cancellable poll loop. + * `-l` routes postmaster output to a per-launch log file. + */ +export function buildPgCtlStartArgs(dataDir: string, logFile: string, optionsString: string): string[] { + return ["-D", dataDir, "-o", optionsString, "-l", logFile, "-W", "start"]; +} + +function readPostgresPid(dataDir: string): number | null { + try { + const lines = readFileSync(join(dataDir, "postmaster.pid"), "utf-8").split("\n"); + const pid = parseInt((lines[0] ?? "").trim(), 10); + return Number.isFinite(pid) && pid > 0 ? pid : null; + } catch { + return null; + } +} + +function readTail(file: string, max: number): string { + try { + const content = readFileSync(file, "utf-8"); + return content.length > max ? "…" + content.slice(-max) : content; + } catch { + return "(no log file)"; + } +} + +/** + * FNXC:WindowsDesktopPackaging 2026-07-17-22:30: + * Per-launch log file names + best-effort pruning replace the old truncate-on + * -launch scheme. Truncating a shared postgres.log raised EBUSY when a live + * wrapper/postmaster from a prior attempt still held it open (field report); + * unique names make a held file harmless, and stale ones are swept next boot. + * Legacy wrapper artifacts (launch.bat/launch.ps1/wrapper.log/postgres.log) + * are swept the same way. + */ +function prepareRunDir(runDir: string): string { + mkdirSync(runDir, { recursive: true }); + const legacy = ["launch.bat", "launch.ps1", "wrapper.log", "postgres.log"]; + let entries: string[] = []; + try { + entries = readdirSync(runDir); + } catch { + entries = []; + } + for (const entry of entries) { + if (legacy.includes(entry) || /^pgctl-\d+\.log$/.test(entry)) { + try { + rmSync(join(runDir, entry), { force: true }); + } catch { + // A file held open by a live process stays; unique naming makes that harmless. + } + } + } + return join(runDir, `pgctl-${Date.now()}.log`); +} + +/** + * Start postgres.exe on an elevated Windows process via pg_ctl's restricted + * token re-exec, and resolve once it is accepting connections. Rejects with a + * clear error (including the postgres log tail — the lifecycle's lock-collision + * classifier depends on seeing the postmaster.pid FATAL text) on timeout, + * cancellation, or early exit. The returned handle's stop() kills the server. + */ +export async function startServerElevatedRestricted( + opts: ElevatedStartOptions, +): Promise { + removeLegacyNonAdminUser(opts.onLog); + + const pgCtl = join(opts.nativeRoot, "bin", "pg_ctl.exe"); + if (!existsSync(pgCtl)) { + throw new Error(`embedded postgres: pg_ctl.exe not found at ${pgCtl}`); + } + const runDir = join(opts.dataDir, ".pgrunner"); + const logFile = prepareRunDir(runDir); + const safeFlags = sanitizePostgresFlags(opts.postgresFlags); + const args = buildPgCtlStartArgs( + opts.dataDir, + logFile, + buildPgCtlOptionsString(opts.port, safeFlags), + ); + + opts.onLog( + `embedded postgres: elevated start via pg_ctl restricted token (no helper account); log ${logFile}`, + ); + + let stopped = false; + const killAll = (): void => { + if (stopped) return; + stopped = true; + const r = spawnSync(pgCtl, ["-D", opts.dataDir, "-m", "fast", "-t", "30", "-w", "stop"], { + encoding: "utf8", + }); + if (r.status !== 0) { + opts.onLog( + `embedded postgres: pg_ctl stop status=${r.status} ` + + `output=${`${r.stdout || ""}${r.stderr || ""}`.trim().slice(0, 400)}; falling back to taskkill`, + ); + const pid = readPostgresPid(opts.dataDir); + if (pid) spawnSync("taskkill", ["/pid", String(pid), "/f", "/t"], { encoding: "utf8" }); + } + }; + + // FNXC:WindowsDesktopPackaging 2026-07-17-23:40: + // stop() must not resolve while the server still accepts connections. The + // taskkill fallback (and TerminateProcess generally) returns before socket + // teardown finishes, and CI observed a probe connecting right after stop() + // resolved. Wait until the port stops accepting AND postmaster.pid is gone. + const waitForDown = async (): Promise => { + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + const pidGone = readPostgresPid(opts.dataDir) === null; + const portClosed = !(await probeTcpPort(opts.port, 250)); + if (pidGone && portClosed) return; + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + } + opts.onError("embedded postgres: server still reachable 15s after stop request"); + }; + + const handle: ElevatedServerHandle = { + get postgresPid() { + return readPostgresPid(opts.dataDir) ?? 0; + }, + async stop() { + killAll(); + await waitForDown(); + }, + async stopWrapperOnly() { + // No wrapper exists on this path; a lock-race loser postmaster exits on + // its own. Only mark stopped so a later stop() cannot kill a race winner. + stopped = true; + }, + }; + + // FNXC:WindowsDesktopPackaging 2026-07-17-23:05: + // Publish the stop handle BEFORE awaiting pg_ctl so the lifecycle's outer + // start() timeout can reap a postmaster that got spawned but never became + // ready (first CI run orphaned one and cleanup hit EBUSY on the data dir). + opts.onLaunched?.(handle); + + // pg_ctl -W exits right after spawning the postmaster; await that exit + // without blocking the event loop (dashboard boot runs on it). + // + // FNXC:WindowsDesktopPackaging 2026-07-17-23:05: + // Resolve on 'exit', NOT 'close': on Windows the spawned postmaster inherits + // pg_ctl's stdout/stderr pipe handles, so the stdio streams stay open for + // the postmaster's lifetime and 'close' never fires (first CI run hung here + // until the outer timeout). Output captured before exit is still reported. + const launch = await new Promise<{ status: number | null; output: () => string }>( + (resolve, reject) => { + const child = spawn(pgCtl, args, { windowsHide: true }); + let output = ""; + child.stdout.on("data", (d: Buffer) => (output += d.toString())); + child.stderr.on("data", (d: Buffer) => (output += d.toString())); + child.on("error", reject); + child.on("exit", (status: number | null) => resolve({ status, output: () => output })); + }, + ); + if (launch.status !== 0) { + killAll(); + throw new Error( + `embedded postgres: pg_ctl start failed (status=${launch.status}) ` + + `output=${launch.output().trim().slice(0, 1000)}\n${readTail(logFile, 2000)}`, + ); + } + opts.onLog(`embedded postgres: pg_ctl start issued (status 0); polling for readiness`); + + // Poll for readiness until the server accepts connections or the timeout + // hits. Same lightweight readFileSync-only loop as the previous launcher + // (spawning tasklist per iteration blew poll budgets on windows-2025). + const hasDeadline = opts.startTimeoutMs > 0 && Number.isFinite(opts.startTimeoutMs); + const deadline = hasDeadline ? Date.now() + opts.startTimeoutMs : Number.POSITIVE_INFINITY; + let ready = false; + let lastSnapshot = ""; + while (Date.now() < deadline) { + if (opts.signal?.aborted) { + killAll(); + throw new Error("embedded postgres: elevated launch cancelled before ready."); + } + const tail = readTail(logFile, 3000); + if (tail !== lastSnapshot) { + lastSnapshot = tail; + opts.onLog(`elevated poll pg={${tail.slice(-400)}}`); + } + if (/database system is ready to accept connections/.test(tail)) { + // Log readiness alone is not enough: confirm TCP accept on 127.0.0.1 so + // ensureDatabase cannot hang on a connect that never completes. + if (await probeTcpPort(opts.port, 500)) { + ready = true; + break; + } + } + if (/\bFATAL\b|\bPANIC\b|could not (bind|start|create|access|connect|load)|not permitted|Permission denied|is not the owner/i.test(tail)) { + killAll(); + throw new Error( + `embedded postgres: elevated postgres reported a startup error before opening the port.\n${tail}`, + ); + } + await new Promise((resolve) => { + setTimeout(resolve, 200); + }); + } + + if (!ready) { + const tail = readTail(logFile, 1500); + killAll(); + throw new Error( + `embedded postgres: elevated postgres did not become ready` + + (hasDeadline ? ` within ${opts.startTimeoutMs}ms` : "") + + `.\n${tail}`, + ); + } + + if (opts.signal?.aborted) { + killAll(); + throw new Error("embedded postgres: elevated launch cancelled after ready."); + } + + const postgresPid = readPostgresPid(opts.dataDir); + if (!postgresPid) { + opts.onError("embedded postgres: started but could not read postmaster.pid"); + } + opts.onLog( + `embedded postgres: elevated server ready on 127.0.0.1:${opts.port} (pid ${postgresPid ?? 0}, restricted token)`, + ); + return handle; +} + +/** True when a TCP accept is available on 127.0.0.1:port within timeoutMs. */ +function probeTcpPort(port: number, timeoutMs: number): Promise { + return new Promise((resolve) => { + const socket = createConnection({ host: "127.0.0.1", port }); + let settled = false; + const finish = (ok: boolean) => { + if (settled) return; + settled = true; + socket.removeAllListeners(); + socket.destroy(); + resolve(ok); + }; + socket.setTimeout(timeoutMs); + socket.once("connect", () => finish(true)); + socket.once("timeout", () => finish(false)); + socket.once("error", () => finish(false)); + }); +} diff --git a/packages/core/src/postgres/startup-factory.ts b/packages/core/src/postgres/startup-factory.ts index 9688271fba..7355dcdaec 100644 --- a/packages/core/src/postgres/startup-factory.ts +++ b/packages/core/src/postgres/startup-factory.ts @@ -87,6 +87,29 @@ type EmbeddedLifecycleLike = { const log = createLogger("startup-factory"); +/* +FNXC:PostgresSchema 2026-07-17-23:55: +Schema-backend failures were reported via err.message alone. Drizzle wraps +query failures in DrizzleQueryError, whose message is "Failed query: params: ..." — for the schema baseline that is thousands of SQL lines +— while the REAL PostgresError lives in err.cause and was dropped. Field +reports (Windows desktop boot failures) were undiagnosable because every +surfaced log was query text with no error message. Walk the cause chain and +truncate giant messages so the actual failure always survives into the log. +*/ +function describeErrorChain(err: unknown): string { + const parts: string[] = []; + let current: unknown = err; + for (let depth = 0; current !== undefined && current !== null && depth < 5; depth += 1) { + const message = current instanceof Error ? current.message : String(current); + parts.push( + message.length > 1200 ? `${message.slice(0, 600)} … [truncated] … ${message.slice(-300)}` : message, + ); + current = current instanceof Error ? current.cause : undefined; + } + return parts.join(" ⇐ caused by: "); +} + /** * FNXC:ProjectDataIsolation 2026-07-14-12:10: * An unregistered project still needs a stable, non-shared PostgreSQL partition. Derive a deterministic identity from its canonical root path so first-boot migration and every later runtime session select the same isolated rows without inventing cross-project ownership. @@ -523,8 +546,19 @@ export async function createTaskStoreForBackend( boot = await bootSchemaBackend(options); log.log(`startup phase backend.schemaBackend: ${Date.now() - schemaT0}ms`); } catch (err) { + const chain = describeErrorChain(err); + /* + FNXC:PostgresEmbedded 2026-07-18-00:20: + Issue #2286: a cluster initdb'd by an earlier version on a non-UTF-8 OS + locale cannot store the UTF-8 schema SQL and cannot be converted in place. + Newly created clusters are forced to UTF-8 (DEFAULT_EMBEDDED_INITDB_FLAGS); + existing ones need a manual re-init, so say exactly that. + */ + const encodingHint = /has no equivalent in encoding/i.test(chain) + ? " HINT: this embedded PostgreSQL cluster was created with a non-UTF-8 encoding inherited from the OS locale by an earlier Fusion version. It cannot be converted in place — stop Fusion, delete the embedded data directory (default: ~/.fusion/embedded-postgres/default), and start again so the cluster is recreated as UTF-8." + : ""; throw new Error( - `startup-factory: failed to initialize PostgreSQL schema backend: ${err instanceof Error ? err.message : String(err)}`, + `startup-factory: failed to initialize PostgreSQL schema backend: ${chain}${encodingHint}`, ); } let { connections } = boot; @@ -765,9 +799,7 @@ export async function createTaskStoreForBackend( embeddedOwnsProcess, ).catch(() => undefined); throw new Error( - `startup-factory: SQLite → PostgreSQL first-boot auto-migration failed (refusing to boot an empty database over existing SQLite data; restore the retained backup and run 'fn db migrate' manually): ${ - err instanceof Error ? err.message : String(err) - }`, + `startup-factory: SQLite → PostgreSQL first-boot auto-migration failed (refusing to boot an empty database over existing SQLite data; restore the retained backup and run 'fn db migrate' manually): ${describeErrorChain(err)}`, ); } } @@ -858,9 +890,7 @@ export async function createTaskStoreForBackend( embeddedOwnsProcess, ).catch(() => undefined); throw new Error( - `startup-factory: failed to construct PostgreSQL-backed TaskStore: ${ - err instanceof Error ? err.message : String(err) - }`, + `startup-factory: failed to construct PostgreSQL-backed TaskStore: ${describeErrorChain(err)}`, ); } /* diff --git a/scripts/boot-smoke.mjs b/scripts/boot-smoke.mjs index df3c3583c8..c4c6f4f829 100644 --- a/scripts/boot-smoke.mjs +++ b/scripts/boot-smoke.mjs @@ -114,7 +114,7 @@ function fail(message, stderr = "") { console.error(`boot-smoke: FAIL — ${message}`); if (stderr.trim()) { console.error("--- child stderr (tail) ---"); - console.error(stderr.split("\n").slice(-40).join("\n")); + console.error(stderr.split("\n").slice(-200).join("\n")); } process.exit(1); } diff --git a/scripts/verify-windows-elevated-restricted.mjs b/scripts/verify-windows-elevated-restricted.mjs new file mode 100644 index 0000000000..5640d6cf7f --- /dev/null +++ b/scripts/verify-windows-elevated-restricted.mjs @@ -0,0 +1,137 @@ +// FNXC:WindowsDesktopPackaging 2026-07-17-22:30: +// End-to-end verification for the elevated restricted-token postgres launch +// (pg_ctl re-exec, no helper account). Runs ONLY on an elevated win32 process +// (GitHub windows runners qualify). Asserts, in order: +// 1. The legacy 'fusion-pg' account (pre-created by the workflow) is DELETED +// by the launch path — Fusion must not leave created accounts behind. +// 2. EmbeddedPostgresLifecycle.start() boots postgres from a cwd that grants +// nothing beyond Administrators/SYSTEM (the hostile-cwd condition that +// broke the credential launcher with "The directory name is invalid"). +// 3. A stop + second start on the same data dir succeeds (the truncate-held +// -log EBUSY regression: prior code died reopening .pgrunner logs). +import { spawnSync } from "node:child_process"; +import { mkdirSync, readFileSync, rmSync } from "node:fs"; +import { createConnection } from "node:net"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const PORT = 55498; +const RESTRICTED = "C:\\fusion-verify-restricted-cwd"; +const DATA_DIR = "C:\\fusion-verify-pgdata"; + +function sh(cmd, args) { + const r = spawnSync(cmd, args, { encoding: "utf8" }); + return { status: r.status, out: `${r.stdout || ""}${r.stderr || ""}`.trim() }; +} + +function probe(port) { + return new Promise((resolve) => { + const socket = createConnection({ host: "127.0.0.1", port }); + socket.setTimeout(3000); + socket.once("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.once("timeout", () => { + socket.destroy(); + resolve(false); + }); + socket.once("error", () => resolve(false)); + }); +} + +if (process.platform !== "win32") { + console.error("verify: must run on win32"); + process.exit(1); +} + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const { isWindowsElevatedAdmin, EmbeddedPostgresLifecycle } = await import( + pathToFileURL(join(scriptDir, "..", "packages", "core", "dist", "postgres", "embedded-lifecycle.js")).href +); + +if (!isWindowsElevatedAdmin()) { + console.error("verify: process is not elevated; the restricted-token path would not be exercised"); + process.exit(1); +} + +// Hostile cwd: inheritance stripped, only Administrators + SYSTEM. +rmSync(RESTRICTED, { recursive: true, force: true }); +mkdirSync(RESTRICTED, { recursive: true }); +for (const args of [ + [RESTRICTED, "/inheritance:r"], + [RESTRICTED, "/grant:r", "Administrators:(OI)(CI)F", "SYSTEM:(OI)(CI)F"], +]) { + const r = sh("icacls", args); + if (r.status !== 0) { + console.error(`verify: icacls ${args.join(" ")} failed: ${r.out}`); + process.exit(1); + } +} +process.chdir(RESTRICTED); +console.log(`verify: cwd is now ${process.cwd()}`); + +rmSync(DATA_DIR, { recursive: true, force: true }); + +function makeLifecycle() { + return new EmbeddedPostgresLifecycle({ + dataDir: DATA_DIR, + database: "fusion", + user: "postgres", + password: "password", + port: PORT, + onLog: (message) => console.log(`[lifecycle] ${message}`), + onError: (messageOrError) => console.error(`[lifecycle:err] ${String(messageOrError)}`), + }); +} + +let failed = false; +try { + for (let round = 1; round <= 2; round += 1) { + console.log(`verify: boot round ${round}`); + const lifecycle = makeLifecycle(); + await lifecycle.start(); + console.log(`verify: round ${round} start() resolved`); + if (!(await probe(PORT))) { + throw new Error(`round ${round}: port ${PORT} did not accept a TCP connection`); + } + console.log(`verify: round ${round} TCP accept confirmed on 127.0.0.1:${PORT}`); + await lifecycle.stop(); + if (await probe(PORT)) { + throw new Error(`round ${round}: port ${PORT} still accepting after stop()`); + } + console.log(`verify: round ${round} stop() confirmed`); + } + + // The launch path must have deleted the pre-created legacy account, and no + // new helper account may exist afterwards. + const account = sh("net", ["user", "fusion-pg"]); + if (account.status === 0) { + throw new Error("legacy 'fusion-pg' account still exists after the elevated launch — cleanup failed"); + } + console.log("verify: 'fusion-pg' account absent after launch (legacy cleanup confirmed, none re-created)"); +} catch (err) { + console.error(`verify: FAIL — ${err instanceof Error ? err.stack : String(err)}`); + failed = true; +} finally { + process.chdir("C:\\"); + rmSync(RESTRICTED, { recursive: true, force: true }); + // A failed round can orphan a postmaster that holds the data dir (EBUSY on + // rmdir). Kill it via postmaster.pid before removing, best-effort. + try { + const pid = parseInt(readFileSync(join(DATA_DIR, "postmaster.pid"), "utf8").split("\n")[0], 10); + if (Number.isFinite(pid) && pid > 0) { + sh("taskkill", ["/pid", String(pid), "/f", "/t"]); + } + } catch { + // no postmaster.pid — nothing to kill + } + try { + rmSync(DATA_DIR, { recursive: true, force: true }); + } catch (cleanupErr) { + console.error(`verify: cleanup warning: ${String(cleanupErr)}`); + } +} + +if (failed) process.exit(1); +console.log("verify: PASS — elevated postgres boots via restricted token, no local account created");