diff --git a/apps/cli/lib/daemon-client.ts b/apps/cli/lib/daemon-client.ts index 5b7b1e5416..5004c35680 100644 --- a/apps/cli/lib/daemon-client.ts +++ b/apps/cli/lib/daemon-client.ts @@ -11,6 +11,7 @@ import { PROCESS_MANAGER_EVENTS_SOCKET_PATH, PROCESS_MANAGER_CONTROL_SOCKET_PATH, PROCESS_MANAGER_HOME, + daemonPipePath, } from 'cli/lib/paths'; import { SocketStreamClient, SocketMessageDecoder, SocketRequestClient } from 'cli/lib/socket'; import { @@ -33,7 +34,7 @@ const CONNECTION_TIMEOUT_MS = 10_000; const PROCESS_MANAGER_LOCKFILE_PATH = path.join( PROCESS_MANAGER_HOME, 'pm-connection.lock' ); export const SITE_EVENTS_SOCKET_PATH = process.platform === 'win32' - ? '\\\\.\\pipe\\studio-events.sock' + ? daemonPipePath( 'studio-events' ) : path.join( PROCESS_MANAGER_HOME, 'events.sock' ); function ensureProcessManagerHome() { diff --git a/apps/cli/lib/native-php/config.ts b/apps/cli/lib/native-php/config.ts index bb6fa39a0d..23d4445c4d 100644 --- a/apps/cli/lib/native-php/config.ts +++ b/apps/cli/lib/native-php/config.ts @@ -6,6 +6,7 @@ import { NativePhpSupportedVersion } from '@studio/common/lib/php-binary-metadat import { writeFile } from 'atomically'; import semver from 'semver'; import { getPhpBinaryPath } from '../dependency-management/paths'; +import { getPhpSafeTmpDir } from './tmp-dir'; // Disabled to shrink the attack surface available to PHP code running inside a // Studio site. Each entry falls into one of: @@ -215,13 +216,9 @@ function getOpcacheRootDir(): string { return opcacheRootDir; } - // Resolve to the long-form path on Windows. `os.tmpdir()` can return an 8.3 - // short name (e.g. C:\Users\BUILDK~1\AppData\…) when the user has a long - // username, and PHP's INI scanner treats `~` as a special token, breaking - // `-d opcache.file_cache=` parsing. - const tmpRoot = - process.platform === 'win32' ? fs.realpathSync.native( os.tmpdir() ) : os.tmpdir(); - opcacheRootDir = fs.mkdtempSync( path.join( tmpRoot, 'studio-opcache-' ) ); + // `getPhpSafeTmpDir()` resolves the Windows 8.3 short name so PHP's INI scanner + // doesn't choke on the `~` in `-d opcache.file_cache=`. + opcacheRootDir = fs.mkdtempSync( path.join( getPhpSafeTmpDir(), 'studio-opcache-' ) ); const dirToClean = opcacheRootDir; process.once( 'exit', () => { try { @@ -292,7 +289,9 @@ export function getDefaultPhpArgs( // runtime.php (constants, SQLite loader, upload proxy) into imported sites // without modifying their wp-config.php. if ( autoPrependFile ) { - args.push( '-d', `auto_prepend_file=${ autoPrependFile }` ); + // Quote the path so a space (or other INI-special char) in it can't break parsing, + // matching the other `-d` directives above. + args.push( '-d', `auto_prepend_file="${ autoPrependFile }"` ); } return args; diff --git a/apps/cli/lib/native-php/phpmyadmin.ts b/apps/cli/lib/native-php/phpmyadmin.ts index 15136a160a..9368945a90 100644 --- a/apps/cli/lib/native-php/phpmyadmin.ts +++ b/apps/cli/lib/native-php/phpmyadmin.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; +import { getPhpSafeTmpDir } from './tmp-dir'; import type { ServerConfig } from 'cli/lib/types/wordpress-server-ipc'; function phpStringLiteral( value: string ): string { @@ -9,12 +9,13 @@ function phpStringLiteral( value: string ): string { export function getNativePhpMyAdminWpEnvPath( config: Pick< ServerConfig, 'siteId' > ): string { const safeSiteId = config.siteId.replace( /[^a-zA-Z0-9._-]/g, '-' ); - return path.join( os.tmpdir(), 'studio-phpmyadmin-wp-env', safeSiteId, 'wp-env.php' ); + // getPhpSafeTmpDir() avoids the Windows 8.3 `~` short name; this path reaches PHP. + return path.join( getPhpSafeTmpDir(), 'studio-phpmyadmin-wp-env', safeSiteId, 'wp-env.php' ); } export function getPhpMyAdminSessionPath( config: Pick< ServerConfig, 'siteId' > ): string { const safeSiteId = config.siteId.replace( /[^a-zA-Z0-9._-]/g, '-' ); - return path.join( os.tmpdir(), 'studio-phpmyadmin-sessions', safeSiteId ); + return path.join( getPhpSafeTmpDir(), 'studio-phpmyadmin-sessions', safeSiteId ); } export async function writeNativePhpMyAdminWpEnv( config: ServerConfig ): Promise< string > { diff --git a/apps/cli/lib/native-php/site-setup.ts b/apps/cli/lib/native-php/site-setup.ts index 9337c193f0..68eb89da93 100644 --- a/apps/cli/lib/native-php/site-setup.ts +++ b/apps/cli/lib/native-php/site-setup.ts @@ -1,11 +1,11 @@ import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; import { DEFAULT_LOCALE } from '@studio/common/lib/locale'; import { escapePhpSingleQuotedString } from '@studio/common/lib/mu-plugins'; import { decodePassword } from '@studio/common/lib/passwords'; import { getWpCliPharPath } from 'cli/lib/dependency-management/paths'; import { runPhpCommand } from './php-process'; +import { getPhpSafeTmpDir } from './tmp-dir'; import type { NativePhpSupportedVersion } from '@studio/common/lib/php-binary-metadata'; import type { ServerConfig } from 'cli/lib/types/wordpress-server-ipc'; @@ -99,7 +99,9 @@ export function writeSiteUrlPrependFile( siteUrl: string, originalAutoPrependFile?: string ): string { - const dir = fs.mkdtempSync( path.join( os.tmpdir(), 'studio-siteurl-prepend-' ) ); + // getPhpSafeTmpDir() avoids the Windows 8.3 `~` short name; this path is handed to PHP as + // auto_prepend_file, and a `~` in it breaks the parser with `syntax error, unexpected '~'`. + const dir = fs.mkdtempSync( path.join( getPhpSafeTmpDir(), 'studio-siteurl-prepend-' ) ); const prependPath = path.join( dir, 'prepend.php' ); fs.writeFileSync( prependPath, getSiteUrlPrependContent( siteUrl, originalAutoPrependFile ) ); return prependPath; diff --git a/apps/cli/lib/native-php/tmp-dir.ts b/apps/cli/lib/native-php/tmp-dir.ts new file mode 100644 index 0000000000..9aff6cb3fc --- /dev/null +++ b/apps/cli/lib/native-php/tmp-dir.ts @@ -0,0 +1,14 @@ +import fs from 'node:fs'; +import os from 'node:os'; + +// Returns `os.tmpdir()` resolved to its long-form path on Windows. +// +// When the OS account name is longer than 8 characters (e.g. CI's `buildkite-agent`), +// Windows exposes an 8.3 short name and `os.tmpdir()` can return something like +// `C:\Users\BUILDK~1\AppData\Local\Temp`. PHP's INI/argument scanner treats the `~` as a +// special token, so any temp path we hand to PHP — auto_prepend_file, opcache.file_cache, +// the phpMyAdmin config, … — breaks with `syntax error, unexpected '~'`. Resolving to the +// long form removes the tilde. No-op on macOS/Linux, which don't have 8.3 short names. +export function getPhpSafeTmpDir(): string { + return process.platform === 'win32' ? fs.realpathSync.native( os.tmpdir() ) : os.tmpdir(); +} diff --git a/apps/cli/lib/paths.ts b/apps/cli/lib/paths.ts index 14f068599d..7810f04268 100644 --- a/apps/cli/lib/paths.ts +++ b/apps/cli/lib/paths.ts @@ -1,16 +1,35 @@ +import crypto from 'crypto'; import os from 'os'; import path from 'path'; export const STUDIO_CLI_HOME = path.join( os.homedir(), '.studio' ); +export const DEFAULT_PROCESS_MANAGER_HOME = path.join( STUDIO_CLI_HOME, 'daemon' ); + export const PROCESS_MANAGER_HOME = - process.env.STUDIO_PROCESS_MANAGER_HOME ?? path.join( STUDIO_CLI_HOME, 'daemon' ); + process.env.STUDIO_PROCESS_MANAGER_HOME ?? DEFAULT_PROCESS_MANAGER_HOME; export const PROCESS_MANAGER_LOGS_DIR = path.join( PROCESS_MANAGER_HOME, 'logs' ); + +// Windows named pipes live in a single, flat, machine-global namespace — unlike Unix domain sockets, +// they can't be nested under PROCESS_MANAGER_HOME. A fixed pipe name therefore makes every process on +// the machine bind/connect to the SAME daemon regardless of PROCESS_MANAGER_HOME, silently defeating +// the per-run isolation that setting STUDIO_PROCESS_MANAGER_HOME gives on macOS/Linux (and that the CLI +// test harness relies on). Derive the pipe name from the home when a custom one is set, so isolation +// works on Windows too. The default home keeps its original fixed name, so the shipping desktop app and +// CLI still share a single daemon exactly as before. +export function daemonPipePath( baseName: string, home: string = PROCESS_MANAGER_HOME ): string { + if ( home === DEFAULT_PROCESS_MANAGER_HOME ) { + return `\\\\.\\pipe\\${ baseName }.sock`; + } + const homeHash = crypto.createHash( 'sha1' ).update( home ).digest( 'hex' ).slice( 0, 12 ); + return `\\\\.\\pipe\\${ baseName }-${ homeHash }.sock`; +} + export const PROCESS_MANAGER_CONTROL_SOCKET_PATH = process.platform === 'win32' - ? '\\\\.\\pipe\\studio-daemon.sock' + ? daemonPipePath( 'studio-daemon' ) : path.join( PROCESS_MANAGER_HOME, 'daemon.sock' ); export const PROCESS_MANAGER_EVENTS_SOCKET_PATH = process.platform === 'win32' - ? '\\\\.\\pipe\\studio-daemon-events.sock' + ? daemonPipePath( 'studio-daemon-events' ) : path.join( PROCESS_MANAGER_HOME, 'daemon-events.sock' ); diff --git a/apps/cli/tests/paths.test.ts b/apps/cli/tests/paths.test.ts new file mode 100644 index 0000000000..fb1aa88ba6 --- /dev/null +++ b/apps/cli/tests/paths.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_PROCESS_MANAGER_HOME, daemonPipePath } from '../lib/paths'; + +describe( 'daemonPipePath (Windows daemon socket isolation)', () => { + it( 'keeps the original fixed pipe name for the default home (desktop/CLI still share one daemon)', () => { + expect( daemonPipePath( 'studio-daemon', DEFAULT_PROCESS_MANAGER_HOME ) ).toBe( + '\\\\.\\pipe\\studio-daemon.sock' + ); + expect( daemonPipePath( 'studio-daemon-events', DEFAULT_PROCESS_MANAGER_HOME ) ).toBe( + '\\\\.\\pipe\\studio-daemon-events.sock' + ); + } ); + + it( 'derives a per-home pipe name for a custom home so runs are isolated on Windows', () => { + const a = daemonPipePath( 'studio-daemon', 'C:\\Temp\\home-a' ); + const b = daemonPipePath( 'studio-daemon', 'C:\\Temp\\home-b' ); + + expect( a ).toMatch( /^\\\\\.\\pipe\\studio-daemon-[0-9a-f]{12}\.sock$/ ); + expect( a ).not.toBe( b ); + // Two custom homes must not collide with the default fixed pipe either. + expect( a ).not.toBe( '\\\\.\\pipe\\studio-daemon.sock' ); + } ); + + it( 'is deterministic for a given home so repeated CLI invocations reach the same daemon', () => { + expect( daemonPipePath( 'studio-daemon', 'C:\\Temp\\home-a' ) ).toBe( + daemonPipePath( 'studio-daemon', 'C:\\Temp\\home-a' ) + ); + } ); + + it( 'namespaces distinct sockets so control/events/site-events do not collide for the same home', () => { + const home = 'C:\\Temp\\home-a'; + const names = new Set( [ + daemonPipePath( 'studio-daemon', home ), + daemonPipePath( 'studio-daemon-events', home ), + daemonPipePath( 'studio-events', home ), + ] ); + expect( names.size ).toBe( 3 ); + } ); +} );