fix(core): pin non-admin postgres launcher working directory on elevated Windows

Start-Process -Credential (CreateProcessWithLogonW) validates the working
directory as the TARGET user. The launcher inherited the desktop app's cwd
(admin profile / install dir), which the dedicated fusion-pg user cannot
read, so elevated desktop boots died with "The directory name is invalid"
before postgres ever started. launch.ps1 now pins -WorkingDirectory to the
.pgrunner run dir inside the data dir the user was just granted full
control on. CI never caught it because runner cwds are world-traversable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-17 21:01:29 -07:00
parent 7dda1aa3f5
commit fd87c3f23f
3 changed files with 71 additions and 22 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix elevated Windows desktop boot failing with "The directory name is invalid" when starting embedded PostgreSQL.
category: fix
dev: "Start-Process -Credential (CreateProcessWithLogonW) validates the working directory as the target non-admin user; the launcher inherited the desktop app's cwd (admin profile / install dir) which fusion-pg cannot access. launch.ps1 now pins -WorkingDirectory to the granted .pgrunner run dir, passed as a -File param (buildNonAdminLauncherPs1 in packages/core embedded-windows-admin.ts)."

View File

@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { DEFAULT_EMBEDDED_POSTGRES_FLAGS } from "../../postgres/embedded-lifecycle.js"; import { DEFAULT_EMBEDDED_POSTGRES_FLAGS } from "../../postgres/embedded-lifecycle.js";
import { sanitizePostgresFlags } from "../../postgres/embedded-windows-admin.js"; import {
buildNonAdminLauncherPs1,
sanitizePostgresFlags,
} from "../../postgres/embedded-windows-admin.js";
/* /*
* FNXC:PostgresEmbedded 2026-07-16-12:45: * FNXC:PostgresEmbedded 2026-07-16-12:45:
@@ -15,3 +18,25 @@ describe("sanitizePostgresFlags", () => {
expect(sanitizePostgresFlags(flags)).toEqual(flags); expect(sanitizePostgresFlags(flags)).toEqual(flags);
}); });
}); });
/*
* FNXC:WindowsDesktopPackaging 2026-07-17-21:20:
* Start-Process -Credential (CreateProcessWithLogonW) validates the working
* directory as the TARGET user. Without an explicit -WorkingDirectory it
* inherits the desktop app's cwd (the admin user's profile / install dir),
* which 'fusion-pg' cannot access, and the launch dies with "The directory
* name is invalid". The launcher must pin -WorkingDirectory to the granted
* .pgrunner run dir, passed as a discrete -File param.
*/
describe("buildNonAdminLauncherPs1", () => {
it("pins Start-Process to the granted run dir via -WorkingDirectory", () => {
const script = buildNonAdminLauncherPs1();
expect(script).toContain("[string]$RunDir");
const startProcessLine = script
.split("\r\n")
.find((line) => line.includes("Start-Process"));
expect(startProcessLine).toContain("-WorkingDirectory $RunDir");
expect(startProcessLine).toContain("-Credential $c");
});
});

View File

@@ -342,6 +342,41 @@ function resolvePowerShell(): string {
return pwshCache; 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 * 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 * accepting connections. Rejects with a clear error (including the postgres log
@@ -412,27 +447,7 @@ export async function startServerAsNonAdminUser(
// password contains ! and #; passing each as a discrete argv token via -File // password contains ! and #; passing each as a discrete argv token via -File
// params is robust across Node's Windows arg escaping and PowerShell parsing. // params is robust across Node's Windows arg escaping and PowerShell parsing.
const launcherPs1 = join(runDir, "launch.ps1"); const launcherPs1 = join(runDir, "launch.ps1");
writeFileSync( writeFileSync(launcherPs1, buildNonAdminLauncherPs1(), "utf8");
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 powerShell = resolvePowerShell();
const launch = spawnSync( const launch = spawnSync(
powerShell, powerShell,
@@ -450,6 +465,8 @@ export async function startServerAsNonAdminUser(
domainUser, domainUser,
"-Bat", "-Bat",
bat, bat,
"-RunDir",
runDir,
], ],
{ encoding: "utf8" }, { encoding: "utf8" },
); );