fix(core): boot embedded Postgres under non-admin user on elevated Windows (#2117)
## Summary Windows embedded Postgres verification (CI `windows-latest` and elevated desktop) fails because PostgreSQL refuses to run under an administrative token: > Execution of PostgreSQL by a user with administrative permissions is not permitted. GitHub Actions runners execute as `runneradmin` elevated, so the existing `test:embedded-postgres` smoke (and any elevated Local-mode desktop launch) cannot start the server. ### Fix - When `isWindowsElevatedAdmin()` is true, **initdb / clients stay as the launcher**, but the **postgres server** is started as a dedicated non-admin local user (`fusion-pg`) via PowerShell `Start-Process -Credential`. - Readiness waits on the postgres log line `database system is ready to accept connections` with a lightweight poll (no per-iteration `tasklist`). - Real-process vitest cases use a **180s** timeout on Windows (package default is 15s, which killed healthy boots mid-start). - Builds on top of the packaged-desktop asar materialization work already on main (#2106). ## Test plan - [x] `pnpm --filter @fusion/core test:embedded-postgres` on macOS (33/33) - [ ] `desktop-windows.yml` on `feature/win-pg-verify`: - [ ] Smoke embedded Postgres on Windows - [ ] Build + package Windows EXE - [ ] Verify app.asar assets - [ ] Optional: download portable EXE and manual Local mode smoke on a Windows host <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Bug Fixes** - Improved embedded PostgreSQL startup on Windows when Fusion runs with elevated administrator privileges. - When elevated, the embedded database now boots under a dedicated non-administrator local account, with more reliable readiness detection, logging, and shutdown cleanup. - Enhanced database provisioning and now prefers `127.0.0.1` for Windows connection addressing. - **Tests** - Added coverage for Windows elevation detection without starting embedded PostgreSQL. - Increased platform-dependent timeouts for embedded real-process tests to avoid premature failures on Windows. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
7
.changeset/win-embedded-pg-admin.md
Normal file
7
.changeset/win-embedded-pg-admin.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Embedded Postgres now boots on Windows when Fusion runs elevated, fixing the Windows installer build.
|
||||
category: fix
|
||||
dev: On elevated Windows the postgres server is booted under a dedicated non-admin local user via Start-Process -Credential (packages/core embedded-lifecycle.ts + embedded-windows-admin.ts); initdb and the pg client still run as the launching process.
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
EmbeddedStartTimeoutError,
|
||||
DEFAULT_START_TIMEOUT_MS,
|
||||
isDataDirInitialized,
|
||||
isWindowsElevatedAdmin,
|
||||
normalizeMacosEmbeddedPostgresDylibSymlinks,
|
||||
readPortFromPostmasterPid,
|
||||
__setEmbeddedPostgresCtorForTests,
|
||||
@@ -108,6 +109,18 @@ describe("embedded-lifecycle: isDataDirInitialized (PG_VERSION marker)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("embedded-lifecycle: Windows elevation probe (no process)", () => {
|
||||
it("isWindowsElevatedAdmin is false on non-Windows platforms", () => {
|
||||
// FNXC:WindowsDesktopPackaging 2026-07-15-04:55:
|
||||
// The non-admin boot path is Windows-only; other OSes must never claim elevation.
|
||||
if (process.platform !== "win32") {
|
||||
expect(isWindowsElevatedAdmin()).toBe(false);
|
||||
} else {
|
||||
expect(typeof isWindowsElevatedAdmin()).toBe("boolean");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("embedded-lifecycle: constructor + URL helpers (no process)", () => {
|
||||
it("builds a connection URL with credentials for the configured database", () => {
|
||||
const lifecycle = new EmbeddedPostgresLifecycle({
|
||||
@@ -510,134 +523,172 @@ describe("embedded-lifecycle: macOS dylib compatibility links", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* FNXC:WindowsDesktopPackaging 2026-07-15-04:55:
|
||||
* Package default testTimeout is 15s (packages/core/vitest.config.ts). On
|
||||
* elevated Windows (GitHub windows-latest = runneradmin) the non-admin boot
|
||||
* path + initdb regularly takes 60–90s before "ready to accept connections".
|
||||
* The lifecycle startTimeout is 120s; the vitest wrapper must not kill earlier
|
||||
* or CI reports false timeouts while postgres is still healthy (and orphans the
|
||||
* non-admin postmaster). Use a per-test budget that covers elevated CI.
|
||||
*
|
||||
* FNXC:WindowsDesktopPackaging 2026-07-14-23:10:
|
||||
* Double-start tests (VAL-CONN-006 reuse, already-initialized log) need room
|
||||
* for two elevated boots. A 180s wall was tight: first start ~90s left the
|
||||
* second start racing the vitest budget, which timed out mid-readiness and
|
||||
* left EBUSY orphans on the data dir. 6 minutes covers 2×120s startTimeout
|
||||
* plus stop/teardown margin under loaded windows-latest runners.
|
||||
*/
|
||||
const REAL_PROCESS_TEST_TIMEOUT_MS = process.platform === "win32" ? 360_000 : 60_000;
|
||||
|
||||
embeddedDescribe("embedded-lifecycle: real process (VAL-CONN-001, VAL-CONN-006, VAL-CONN-007)", () => {
|
||||
it("first start runs initdb, ensures DB exists, and serves traffic (VAL-CONN-001)", async () => {
|
||||
const dataDir = makeDataDir();
|
||||
const lifecycle = new EmbeddedPostgresLifecycle(baseOptions(dataDir));
|
||||
tracked.push({ lifecycle, dataDir });
|
||||
it(
|
||||
"first start runs initdb, ensures DB exists, and serves traffic (VAL-CONN-001)",
|
||||
async () => {
|
||||
const dataDir = makeDataDir();
|
||||
const lifecycle = new EmbeddedPostgresLifecycle(baseOptions(dataDir));
|
||||
tracked.push({ lifecycle, dataDir });
|
||||
|
||||
// Before start, the dir is not initialized.
|
||||
expect(isDataDirInitialized(dataDir)).toBe(false);
|
||||
// Before start, the dir is not initialized.
|
||||
expect(isDataDirInitialized(dataDir)).toBe(false);
|
||||
|
||||
const backend = await lifecycle.start();
|
||||
const backend = await lifecycle.start();
|
||||
|
||||
// After start, PG_VERSION exists (initdb ran).
|
||||
expect(isDataDirInitialized(dataDir)).toBe(true);
|
||||
// After start, PG_VERSION exists (initdb ran).
|
||||
expect(isDataDirInitialized(dataDir)).toBe(true);
|
||||
|
||||
// Backend is embedded mode with a resolved runtime URL.
|
||||
expect(backend.mode).toBe("embedded");
|
||||
expect(backend.runtimeUrl).not.toBeNull();
|
||||
expect(backend.runtimeUrl).toContain("/fusion");
|
||||
// Backend is embedded mode with a resolved runtime URL.
|
||||
expect(backend.mode).toBe("embedded");
|
||||
expect(backend.runtimeUrl).not.toBeNull();
|
||||
expect(backend.runtimeUrl).toContain("/fusion");
|
||||
|
||||
// The port was assigned (free-port discovery).
|
||||
expect(lifecycle.getPort()).toBeGreaterThan(0);
|
||||
// The port was assigned (free-port discovery).
|
||||
expect(lifecycle.getPort()).toBeGreaterThan(0);
|
||||
|
||||
// Traffic is served: connect via postgres.js and query.
|
||||
const sql = postgres(lifecycle.getConnectionUrl(), { max: 1 });
|
||||
try {
|
||||
const rows = await sql`SELECT current_database() AS db`;
|
||||
expect(rows[0].db).toBe("fusion");
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 });
|
||||
}
|
||||
});
|
||||
// Traffic is served: connect via postgres.js and query.
|
||||
const sql = postgres(lifecycle.getConnectionUrl(), { max: 1 });
|
||||
try {
|
||||
const rows = await sql`SELECT current_database() AS db`;
|
||||
expect(rows[0].db).toBe("fusion");
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 });
|
||||
}
|
||||
},
|
||||
REAL_PROCESS_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it("second start reuses the existing data directory without re-initdb (VAL-CONN-006)", async () => {
|
||||
const dataDir = makeDataDir();
|
||||
it(
|
||||
"second start reuses the existing data directory without re-initdb (VAL-CONN-006)",
|
||||
async () => {
|
||||
const dataDir = makeDataDir();
|
||||
|
||||
// First lifecycle: start, write a marker row, stop.
|
||||
const first = new EmbeddedPostgresLifecycle(baseOptions(dataDir));
|
||||
await first.start();
|
||||
const sql1 = postgres(first.getConnectionUrl(), { max: 1 });
|
||||
try {
|
||||
await sql1`CREATE TABLE persistence_marker (id int PRIMARY KEY, note text)`;
|
||||
await sql1`INSERT INTO persistence_marker (id, note) VALUES (1, 'persisted')`;
|
||||
} finally {
|
||||
await sql1.end({ timeout: 5 });
|
||||
}
|
||||
await first.stop();
|
||||
// First lifecycle: start, write a marker row, stop.
|
||||
const first = new EmbeddedPostgresLifecycle(baseOptions(dataDir));
|
||||
await first.start();
|
||||
const sql1 = postgres(first.getConnectionUrl(), { max: 1 });
|
||||
try {
|
||||
await sql1`CREATE TABLE persistence_marker (id int PRIMARY KEY, note text)`;
|
||||
await sql1`INSERT INTO persistence_marker (id, note) VALUES (1, 'persisted')`;
|
||||
} finally {
|
||||
await sql1.end({ timeout: 5 });
|
||||
}
|
||||
await first.stop();
|
||||
|
||||
// The data dir is still initialized after stop (persistent).
|
||||
expect(isDataDirInitialized(dataDir)).toBe(true);
|
||||
// The data dir is still initialized after stop (persistent).
|
||||
expect(isDataDirInitialized(dataDir)).toBe(true);
|
||||
|
||||
// Second lifecycle: start against the SAME dir.
|
||||
const second = new EmbeddedPostgresLifecycle(baseOptions(dataDir));
|
||||
tracked.push({ lifecycle: second, dataDir });
|
||||
// Second lifecycle: start against the SAME dir.
|
||||
const second = new EmbeddedPostgresLifecycle(baseOptions(dataDir));
|
||||
tracked.push({ lifecycle: second, dataDir });
|
||||
|
||||
await second.start();
|
||||
const sql2 = postgres(second.getConnectionUrl(), { max: 1 });
|
||||
try {
|
||||
const rows = await sql2`SELECT note FROM persistence_marker WHERE id = 1`;
|
||||
expect(rows[0].note).toBe("persisted");
|
||||
} finally {
|
||||
await sql2.end({ timeout: 5 });
|
||||
}
|
||||
});
|
||||
await second.start();
|
||||
const sql2 = postgres(second.getConnectionUrl(), { max: 1 });
|
||||
try {
|
||||
const rows = await sql2`SELECT note FROM persistence_marker WHERE id = 1`;
|
||||
expect(rows[0].note).toBe("persisted");
|
||||
} finally {
|
||||
await sql2.end({ timeout: 5 });
|
||||
}
|
||||
},
|
||||
REAL_PROCESS_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it("ensureDatabase is idempotent: re-starting and ensuring the same DB does not error", async () => {
|
||||
const dataDir = makeDataDir();
|
||||
const lifecycle = new EmbeddedPostgresLifecycle(baseOptions(dataDir));
|
||||
tracked.push({ lifecycle, dataDir });
|
||||
it(
|
||||
"ensureDatabase is idempotent: re-starting and ensuring the same DB does not error",
|
||||
async () => {
|
||||
const dataDir = makeDataDir();
|
||||
const lifecycle = new EmbeddedPostgresLifecycle(baseOptions(dataDir));
|
||||
tracked.push({ lifecycle, dataDir });
|
||||
|
||||
await lifecycle.start();
|
||||
// Calling ensureDatabase again on the already-created DB should not throw.
|
||||
await lifecycle.ensureDatabase();
|
||||
await lifecycle.ensureDatabase();
|
||||
});
|
||||
await lifecycle.start();
|
||||
// Calling ensureDatabase again on the already-created DB should not throw.
|
||||
await lifecycle.ensureDatabase();
|
||||
await lifecycle.ensureDatabase();
|
||||
},
|
||||
REAL_PROCESS_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it("graceful shutdown stops the Postgres process; no orphan remains (VAL-CONN-007)", async () => {
|
||||
const dataDir = makeDataDir();
|
||||
const lifecycle = new EmbeddedPostgresLifecycle(baseOptions(dataDir));
|
||||
tracked.push({ lifecycle, dataDir });
|
||||
it(
|
||||
"graceful shutdown stops the Postgres process; no orphan remains (VAL-CONN-007)",
|
||||
async () => {
|
||||
const dataDir = makeDataDir();
|
||||
const lifecycle = new EmbeddedPostgresLifecycle(baseOptions(dataDir));
|
||||
tracked.push({ lifecycle, dataDir });
|
||||
|
||||
await lifecycle.start();
|
||||
const port = lifecycle.getPort()!;
|
||||
expect(port).toBeGreaterThan(0);
|
||||
await lifecycle.start();
|
||||
const port = lifecycle.getPort()!;
|
||||
expect(port).toBeGreaterThan(0);
|
||||
|
||||
// Confirm the port is accepting connections before shutdown.
|
||||
const probeBefore = postgres(
|
||||
`postgresql://postgres:password@localhost:${port}/fusion`,
|
||||
{ max: 1, connect_timeout: 5 },
|
||||
);
|
||||
await probeBefore`SELECT 1`;
|
||||
await probeBefore.end({ timeout: 5 });
|
||||
// Confirm the port is accepting connections before shutdown.
|
||||
const probeBefore = postgres(
|
||||
`postgresql://postgres:password@localhost:${port}/fusion`,
|
||||
{ max: 1, connect_timeout: 5 },
|
||||
);
|
||||
await probeBefore`SELECT 1`;
|
||||
await probeBefore.end({ timeout: 5 });
|
||||
|
||||
await lifecycle.stop();
|
||||
expect(lifecycle.isRunning()).toBe(false);
|
||||
await lifecycle.stop();
|
||||
expect(lifecycle.isRunning()).toBe(false);
|
||||
|
||||
// After shutdown, the port should refuse new connections.
|
||||
const probeAfter = postgres(
|
||||
`postgresql://postgres:password@localhost:${port}/fusion`,
|
||||
{ max: 1, connect_timeout: 3 },
|
||||
);
|
||||
await expect(probeAfter`SELECT 1`).rejects.toThrow();
|
||||
await probeAfter.end({ timeout: 5 }).catch(() => {});
|
||||
// After shutdown, the port should refuse new connections.
|
||||
const probeAfter = postgres(
|
||||
`postgresql://postgres:password@localhost:${port}/fusion`,
|
||||
{ max: 1, connect_timeout: 3 },
|
||||
);
|
||||
await expect(probeAfter`SELECT 1`).rejects.toThrow();
|
||||
await probeAfter.end({ timeout: 5 }).catch(() => {});
|
||||
|
||||
// Remove from tracked cleanup since we already stopped.
|
||||
const idx = tracked.findIndex((t) => t.lifecycle === lifecycle);
|
||||
if (idx >= 0) tracked.splice(idx, 1);
|
||||
});
|
||||
// Remove from tracked cleanup since we already stopped.
|
||||
const idx = tracked.findIndex((t) => t.lifecycle === lifecycle);
|
||||
if (idx >= 0) tracked.splice(idx, 1);
|
||||
},
|
||||
REAL_PROCESS_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it("start reports already-initialized reuse via the log when the dir exists", async () => {
|
||||
const dataDir = makeDataDir();
|
||||
const reuseLogLines: string[] = [];
|
||||
const opts: EmbeddedLifecycleOptions = {
|
||||
...baseOptions(dataDir),
|
||||
onLog: (msg) => reuseLogLines.push(msg),
|
||||
};
|
||||
it(
|
||||
"start reports already-initialized reuse via the log when the dir exists",
|
||||
async () => {
|
||||
const dataDir = makeDataDir();
|
||||
const reuseLogLines: string[] = [];
|
||||
const opts: EmbeddedLifecycleOptions = {
|
||||
...baseOptions(dataDir),
|
||||
onLog: (msg) => reuseLogLines.push(msg),
|
||||
};
|
||||
|
||||
const first = new EmbeddedPostgresLifecycle(opts);
|
||||
await first.start();
|
||||
await first.stop();
|
||||
const first = new EmbeddedPostgresLifecycle(opts);
|
||||
await first.start();
|
||||
await first.stop();
|
||||
|
||||
reuseLogLines.length = 0;
|
||||
const second = new EmbeddedPostgresLifecycle(opts);
|
||||
tracked.push({ lifecycle: second, dataDir });
|
||||
await second.start();
|
||||
expect(
|
||||
reuseLogLines.some((l) => /existing data directory/i.test(l)),
|
||||
).toBe(true);
|
||||
});
|
||||
reuseLogLines.length = 0;
|
||||
const second = new EmbeddedPostgresLifecycle(opts);
|
||||
tracked.push({ lifecycle: second, dataDir });
|
||||
await second.start();
|
||||
expect(
|
||||
reuseLogLines.some((l) => /existing data directory/i.test(l)),
|
||||
).toBe(true);
|
||||
},
|
||||
REAL_PROCESS_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe("embedded-lifecycle: startup timeout (P1 #24)", () => {
|
||||
|
||||
@@ -67,6 +67,19 @@ import { createRequire, syncBuiltinESMExports } from "node:module";
|
||||
import { createLogger } from "../logger.js";
|
||||
import { redactConnectionString } from "./credential-redact.js";
|
||||
import type { ResolvedBackend } from "./backend-resolver.js";
|
||||
import {
|
||||
isWindowsElevatedAdmin,
|
||||
startServerAsNonAdminUser,
|
||||
type NonAdminServerHandle,
|
||||
} from "./embedded-windows-admin.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
|
||||
// packages/cli/dist and failed boot-smoke with "Cannot find module 'postgres'"
|
||||
// because @runfusion/fusion does not list postgres as a direct dependency.
|
||||
import postgres from "postgres";
|
||||
|
||||
export { isWindowsElevatedAdmin } from "./embedded-windows-admin.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
@@ -442,10 +455,18 @@ export type EmbeddedPostgresCtor = new (opts: Record<string, unknown>) => {
|
||||
/** Instance type produced by the embedded-postgres constructor. */
|
||||
type EmbeddedPostgresInstance = InstanceType<EmbeddedPostgresCtor>;
|
||||
let embeddedPostgresCtorCache: EmbeddedPostgresCtor | null = null;
|
||||
/**
|
||||
* FNXC:WindowsDesktopPackaging 2026-07-14-22:53:
|
||||
* True while tests inject a mock EmbeddedPostgres ctor. Elevated Windows CI
|
||||
* would otherwise take the real non-admin boot path and ignore the mock's
|
||||
* delayed start() used by cancellation coverage.
|
||||
*/
|
||||
let embeddedPostgresCtorIsTestOverride = false;
|
||||
|
||||
/** Test-only constructor seam for deterministic lifecycle cancellation coverage. */
|
||||
export function __setEmbeddedPostgresCtorForTests(ctor: EmbeddedPostgresCtor | null): void {
|
||||
embeddedPostgresCtorCache = ctor;
|
||||
embeddedPostgresCtorIsTestOverride = ctor !== null;
|
||||
}
|
||||
|
||||
function getEmbeddedPostgresCtor(): EmbeddedPostgresCtor {
|
||||
@@ -674,6 +695,29 @@ function resolveMacosEmbeddedPostgresNativeRoot(): string | null {
|
||||
return resolveGenericEmbeddedPostgresNativeRoot();
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:WindowsDesktopPackaging 2026-07-14-21:40:
|
||||
* Resolve the bundled @embedded-postgres/windows-x64 native root (.../native).
|
||||
* Used to stage binaries for the non-admin server boot path under elevation.
|
||||
* Prefer the host-local materialization when available so elevated Windows
|
||||
* desktop launches never spawn postgres.exe from app.asar.
|
||||
*/
|
||||
function resolveWindowsEmbeddedPostgresNativeRoot(): string | null {
|
||||
if (process.platform !== "win32") return null;
|
||||
const nativeRoot = resolveGenericEmbeddedPostgresNativeRoot();
|
||||
if (!nativeRoot) return null;
|
||||
if (nativeRoot.includes(`${sep}app.asar`)) {
|
||||
try {
|
||||
return materializeEmbeddedPostgresRuntimeBinaries(
|
||||
resolveElectronAsarUnpackedPath(nativeRoot),
|
||||
);
|
||||
} catch {
|
||||
return resolveElectronAsarUnpackedPath(nativeRoot);
|
||||
}
|
||||
}
|
||||
return nativeRoot;
|
||||
}
|
||||
|
||||
function normalizeBundledMacosDylibs(onLog: (message: string) => void): void {
|
||||
const nativeRoot = resolveMacosEmbeddedPostgresNativeRoot();
|
||||
if (!nativeRoot) return;
|
||||
@@ -811,6 +855,13 @@ export class EmbeddedPostgresLifecycle {
|
||||
// is false and stop() is a no-op (the owning instance handles shutdown).
|
||||
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.
|
||||
*/
|
||||
private nonAdminHandle: NonAdminServerHandle | 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
|
||||
@@ -874,7 +925,13 @@ export class EmbeddedPostgresLifecycle {
|
||||
}
|
||||
|
||||
private buildUrl(port: number, database: string): string {
|
||||
return `postgresql://${encodeURIComponent(this.options.user)}:${encodeURIComponent(this.options.password)}@localhost:${port}/${encodeURIComponent(database)}`;
|
||||
// FNXC:WindowsDesktopPackaging 2026-07-15-05:00:
|
||||
// Prefer 127.0.0.1 on Windows. `localhost` can resolve to ::1 first; the
|
||||
// non-admin postmaster path and some Windows loopback policies made IPv6
|
||||
// connects hang while IPv4 was fine, which blocked ensureDatabase after the
|
||||
// cluster was already ready.
|
||||
const host = process.platform === "win32" ? "127.0.0.1" : "localhost";
|
||||
return `postgresql://${encodeURIComponent(this.options.user)}:${encodeURIComponent(this.options.password)}@${host}:${port}/${encodeURIComponent(database)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1016,7 +1073,48 @@ export class EmbeddedPostgresLifecycle {
|
||||
throw new EmbeddedStartCancelledError(this.options.dataDir);
|
||||
}
|
||||
|
||||
await pg.start();
|
||||
// FNXC:WindowsDesktopPackaging 2026-07-14-21:40:
|
||||
// Under an elevated Windows admin token, postgres refuses to inherit the
|
||||
// process token ("Execution of PostgreSQL by a user with administrative
|
||||
// permissions is not permitted"). initdb + the pg client above ran as the
|
||||
// launching (admin) process and work unchanged; only the SERVER start is
|
||||
// re-homed under a dedicated non-admin local user. Normal (non-elevated /
|
||||
// non-Windows) launches use the inherited-token path as before.
|
||||
// FNXC:WindowsDesktopPackaging 2026-07-15-05:20:
|
||||
// Pass AbortSignal so outer start() timeout can cancel a still-polling
|
||||
// non-admin launch and kill the wrapper before readiness assigns a handle.
|
||||
// FNXC:WindowsDesktopPackaging 2026-07-14-22:53:
|
||||
// Skip the real non-admin path when tests inject a mock ctor so delayed
|
||||
// start/cancellation coverage exercises pg.start() even on elevated CI.
|
||||
if (isWindowsElevatedAdmin() && !embeddedPostgresCtorIsTestOverride) {
|
||||
const nativeRoot = resolveWindowsEmbeddedPostgresNativeRoot();
|
||||
if (!nativeRoot) {
|
||||
throw new Error(
|
||||
"embedded postgres: the process is running elevated on Windows, where " +
|
||||
"PostgreSQL refuses to start under an administrative token, and the " +
|
||||
"non-admin boot path could not locate the bundled " +
|
||||
"@embedded-postgres/windows-x64 native binaries to stage. Run Fusion " +
|
||||
"non-elevated, or ensure the embedded-postgres platform package is installed.",
|
||||
);
|
||||
}
|
||||
this.nonAdminHandle = await startServerAsNonAdminUser({
|
||||
nativeRoot,
|
||||
dataDir: this.options.dataDir,
|
||||
port,
|
||||
postgresFlags: this.options.postgresFlags,
|
||||
onLog: this.options.onLog,
|
||||
onError: this.options.onError,
|
||||
startTimeoutMs: this.options.startTimeoutMs,
|
||||
signal,
|
||||
// Assign handle as soon as the wrapper PID is known so outer start()
|
||||
// timeout cleanup can taskkill orphans mid-readiness poll.
|
||||
onLaunched: (handle) => {
|
||||
this.nonAdminHandle = handle;
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await pg.start();
|
||||
}
|
||||
/*
|
||||
FNXC:PostgresResourceLifecycle 2026-07-14-18:42:
|
||||
Promise.race does not cancel the losing embedded-postgres startup. Check the cooperative cancellation signal after every delayed phase and stop the exact late instance before it can publish running state, registry ownership, or process hooks. A timeout may already have attempted stop while pg.start() was pending, so the post-resolution stop is intentionally repeated to catch a postmaster that appeared after that first cleanup.
|
||||
@@ -1065,6 +1163,20 @@ export class EmbeddedPostgresLifecycle {
|
||||
}
|
||||
|
||||
private async settleCancelledStart(pg: EmbeddedPostgresInstance): Promise<void> {
|
||||
// FNXC:WindowsDesktopPackaging 2026-07-15-05:20:
|
||||
// Prefer stopping a non-admin handle (if already assigned) before asking
|
||||
// embedded-postgres to stop a process it never started.
|
||||
if (this.nonAdminHandle) {
|
||||
try {
|
||||
await this.nonAdminHandle.stop();
|
||||
} catch (error) {
|
||||
this.options.onError(
|
||||
`embedded postgres: cancelled non-admin cleanup failed: ${String(error)}`,
|
||||
);
|
||||
} finally {
|
||||
this.nonAdminHandle = null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await pg.stop();
|
||||
} catch (error) {
|
||||
@@ -1083,35 +1195,72 @@ export class EmbeddedPostgresLifecycle {
|
||||
* Idempotent: queries `pg_database` first and only issues `CREATE DATABASE`
|
||||
* when the database is missing. `embedded-postgres.createDatabase()` throws on
|
||||
* an existing database, so this guard is required for safe re-starts.
|
||||
*
|
||||
* FNXC:WindowsDesktopPackaging 2026-07-15-05:00:
|
||||
* Do not call embedded-postgres.createDatabase() when the server was started
|
||||
* under the elevated-Windows non-admin path: that library requires
|
||||
* `this.process` (set only by its own .start()), so createDatabase throws
|
||||
* "cluster must be running" even though postgres is healthy. Use a direct
|
||||
* SQL connection with a bounded connect timeout instead.
|
||||
*/
|
||||
async ensureDatabase(): Promise<void> {
|
||||
if (!this.pg || !this.running) {
|
||||
if (!this.running || this.getPort() === undefined) {
|
||||
throw new Error(
|
||||
"Cannot ensure database: the embedded cluster is not running. Call start() first.",
|
||||
);
|
||||
}
|
||||
const exists = await this.databaseExists(this.options.database);
|
||||
if (exists) return;
|
||||
await this.pg.createDatabase(this.options.database);
|
||||
const sql = this.openMaintenanceSql();
|
||||
try {
|
||||
const safeName = this.options.database.replace(/"/g, '""');
|
||||
await sql.unsafe(`CREATE DATABASE "${safeName}"`);
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Check whether a database with the given name exists on the cluster. */
|
||||
private async databaseExists(name: string): Promise<boolean> {
|
||||
if (!this.pg) return false;
|
||||
// Use the maintenance client (connects to the default "postgres" db).
|
||||
const client = this.pg.getPgClient("postgres", "localhost");
|
||||
if (!this.running || this.getPort() === undefined) return false;
|
||||
const sql = this.openMaintenanceSql();
|
||||
try {
|
||||
await client.connect();
|
||||
const result = await client.query(
|
||||
"SELECT 1 FROM pg_database WHERE datname = $1",
|
||||
[name],
|
||||
);
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
const rows = await sql`SELECT 1 AS one FROM pg_database WHERE datname = ${name}`;
|
||||
return rows.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
await client.end().catch(() => {});
|
||||
await sql.end({ timeout: 5 }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a short-lived maintenance connection to the embedded cluster's
|
||||
* built-in `postgres` database (for CREATE DATABASE / existence checks).
|
||||
*
|
||||
* FNXC:WindowsDesktopPackaging 2026-07-14-22:53:
|
||||
* Uses the statically imported postgres.js client (bundled into CLI) rather
|
||||
* than embedded-postgres getPgClient, which requires this.process set by
|
||||
* library start() — unavailable on the elevated Windows non-admin path.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private openMaintenanceSql(): any {
|
||||
const port = this.getPort();
|
||||
if (port === undefined) {
|
||||
throw new Error("openMaintenanceSql: no port assigned");
|
||||
}
|
||||
const host = process.platform === "win32" ? "127.0.0.1" : "localhost";
|
||||
return postgres({
|
||||
host,
|
||||
port,
|
||||
user: this.options.user,
|
||||
password: this.options.password,
|
||||
database: "postgres",
|
||||
max: 1,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the embedded PostgreSQL process. Safe to call multiple times.
|
||||
* After stop, the data directory is preserved (persistent), so a subsequent
|
||||
@@ -1128,6 +1277,24 @@ export class EmbeddedPostgresLifecycle {
|
||||
return;
|
||||
}
|
||||
|
||||
// FNXC:WindowsDesktopPackaging 2026-07-14-21:40:
|
||||
// Elevated Windows path: the postmaster was started under a dedicated
|
||||
// non-admin user; stop it via the handle instead of embedded-postgres
|
||||
// (which never called .start() and has no process handle).
|
||||
if (this.nonAdminHandle) {
|
||||
try {
|
||||
await this.nonAdminHandle.stop();
|
||||
} catch (err) {
|
||||
this.options.onError(`embedded postgres: error during non-admin stop: ${String(err)}`);
|
||||
} finally {
|
||||
this.nonAdminHandle = null;
|
||||
this.pg = null;
|
||||
this.running = false;
|
||||
runningInstances.delete(this.options.dataDir);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.pg) {
|
||||
this.running = false;
|
||||
// Clean up the registry even if pg is null
|
||||
|
||||
583
packages/core/src/postgres/embedded-windows-admin.ts
Normal file
583
packages/core/src/postgres/embedded-windows-admin.ts
Normal file
@@ -0,0 +1,583 @@
|
||||
// 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. */
|
||||
stop(): 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.).
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
[
|
||||
"param([string]$User,[string]$Password,[string]$DomainUser,[string]$Bat)",
|
||||
"$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 -WindowStyle Hidden -PassThru",
|
||||
"Write-Output $p.Id",
|
||||
"",
|
||||
].join("\r\n"),
|
||||
"utf8",
|
||||
);
|
||||
const powerShell = resolvePowerShell();
|
||||
const launch = spawnSync(
|
||||
powerShell,
|
||||
[
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
launcherPs1,
|
||||
"-User",
|
||||
user,
|
||||
"-Password",
|
||||
password,
|
||||
"-DomainUser",
|
||||
domainUser,
|
||||
"-Bat",
|
||||
bat,
|
||||
],
|
||||
{ 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();
|
||||
},
|
||||
};
|
||||
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));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user