fix(core): Windows embedded Postgres — no local user account, UTF-8 clusters, diagnosable boot errors

Squash of feature/win-elevated-no-user, verified end-to-end on the
elevated windows-latest runner (restricted-token double boot + full
'fn serve' /api/health smoke, both green).

- Elevated Windows boots embedded PostgreSQL via pg_ctl's built-in
  restricted-token re-exec instead of creating a 'fusion-pg' local user
  (operator requirement: Fusion must never create accounts). Removes
  the credential launcher, icacls grants, and cmd/PowerShell wrapper —
  and with them the 'directory name is invalid' and wrapper-log EBUSY
  field failures. Leftover fusion-pg accounts are deleted on start.
- Embedded clusters are always initdb'd --encoding=UTF8 --locale=C
  (GitHub issue #2286: OS-locale WIN1252/WIN1254 clusters could not
  store the UTF-8 schema and crash-looped the dashboard). Existing
  non-UTF-8 clusters get an actionable re-init hint at boot.
- Schema-backend boot failures now surface the full error cause chain
  (DrizzleQueryError hid the real PostgresError behind the SQL text).
- Elevated stop() waits until the port closes and postmaster.pid is
  gone before resolving.
- CI: branch verification workflow (restricted-token proof + elevated
  boot smoke + account-absence assertions); boot-smoke stderr tail
  widened for diagnosability.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-17 22:45:57 -07:00
parent fd87c3f23f
commit d4ee80a818
12 changed files with 803 additions and 691 deletions

View File

@@ -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."

View File

@@ -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."

View File

@@ -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

View File

@@ -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<string, unknown>[] = [];
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<string, unknown>[] = [];

View File

@@ -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");
});
});

View File

@@ -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",
]);
});
});

View File

@@ -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<NonAdminServerHandle>)
| ((opts: ElevatedStartOptions) => Promise<ElevatedServerHandle>)
| 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<NonAdminServerHandle>) | null,
launcher: ((opts: ElevatedStartOptions) => Promise<ElevatedServerHandle>) | 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.

View File

@@ -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<void>;
/**
* 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<void>;
}
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<NonAdminServerHandle> {
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<void>((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<boolean> {
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));
});
}

View File

@@ -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<void>;
/**
* 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<void>;
}
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<ElevatedServerHandle> {
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<void> => {
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<void>((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<void>((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<boolean> {
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));
});
}

View File

@@ -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: <full
SQL> 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)}`,
);
}
/*

View File

@@ -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);
}

View File

@@ -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");