diff --git a/.changeset/windows-nonadmin-workingdirectory.md b/.changeset/windows-nonadmin-workingdirectory.md new file mode 100644 index 0000000000..f48c3931e0 --- /dev/null +++ b/.changeset/windows-nonadmin-workingdirectory.md @@ -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)." diff --git a/packages/core/src/__tests__/postgres/embedded-windows-admin.test.ts b/packages/core/src/__tests__/postgres/embedded-windows-admin.test.ts index 56094f7bf9..bf8d94c33a 100644 --- a/packages/core/src/__tests__/postgres/embedded-windows-admin.test.ts +++ b/packages/core/src/__tests__/postgres/embedded-windows-admin.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; 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: @@ -15,3 +18,25 @@ describe("sanitizePostgresFlags", () => { expect(sanitizePostgresFlags(flags)).toEqual(flags); }); }); + +/* + * FNXC:WindowsDesktopPackaging 2026-07-17-21:20: + * Start-Process -Credential (CreateProcessWithLogonW) validates the working + * directory as the TARGET user. Without an explicit -WorkingDirectory it + * inherits the desktop app's cwd (the admin user's profile / install dir), + * which 'fusion-pg' cannot access, and the launch dies with "The directory + * name is invalid". The launcher must pin -WorkingDirectory to the granted + * .pgrunner run dir, passed as a discrete -File param. + */ +describe("buildNonAdminLauncherPs1", () => { + it("pins Start-Process to the granted run dir via -WorkingDirectory", () => { + const script = buildNonAdminLauncherPs1(); + + expect(script).toContain("[string]$RunDir"); + const startProcessLine = script + .split("\r\n") + .find((line) => line.includes("Start-Process")); + expect(startProcessLine).toContain("-WorkingDirectory $RunDir"); + expect(startProcessLine).toContain("-Credential $c"); + }); +}); diff --git a/packages/core/src/postgres/embedded-windows-admin.ts b/packages/core/src/postgres/embedded-windows-admin.ts index 8a36bc9d68..cd4da90333 100644 --- a/packages/core/src/postgres/embedded-windows-admin.ts +++ b/packages/core/src/postgres/embedded-windows-admin.ts @@ -342,6 +342,41 @@ function resolvePowerShell(): string { 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 @@ -412,27 +447,7 @@ export async function startServerAsNonAdminUser( // 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", - ); + writeFileSync(launcherPs1, buildNonAdminLauncherPs1(), "utf8"); const powerShell = resolvePowerShell(); const launch = spawnSync( powerShell, @@ -450,6 +465,8 @@ export async function startServerAsNonAdminUser( domainUser, "-Bat", bat, + "-RunDir", + runDir, ], { encoding: "utf8" }, );