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:
7
.changeset/embedded-pg-utf8-initdb.md
Normal file
7
.changeset/embedded-pg-utf8-initdb.md
Normal 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."
|
||||
7
.changeset/windows-no-helper-account.md
Normal file
7
.changeset/windows-no-helper-account.md
Normal 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."
|
||||
86
.github/workflows/verify-elevated-restricted.yml
vendored
Normal file
86
.github/workflows/verify-elevated-restricted.yml
vendored
Normal 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
|
||||
@@ -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>[] = [];
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
394
packages/core/src/postgres/embedded-windows-elevated.ts
Normal file
394
packages/core/src/postgres/embedded-windows-elevated.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
/*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
137
scripts/verify-windows-elevated-restricted.mjs
Normal file
137
scripts/verify-windows-elevated-restricted.mjs
Normal 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");
|
||||
Reference in New Issue
Block a user