Skip to content
Draft
3 changes: 2 additions & 1 deletion apps/cli/lib/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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() {
Expand Down
15 changes: 7 additions & 8 deletions apps/cli/lib/native-php/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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=<path>` 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=<path>`.
opcacheRootDir = fs.mkdtempSync( path.join( getPhpSafeTmpDir(), 'studio-opcache-' ) );
const dirToClean = opcacheRootDir;
process.once( 'exit', () => {
try {
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 4 additions & 3 deletions apps/cli/lib/native-php/phpmyadmin.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 > {
Expand Down
6 changes: 4 additions & 2 deletions apps/cli/lib/native-php/site-setup.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions apps/cli/lib/native-php/tmp-dir.ts
Original file line number Diff line number Diff line change
@@ -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();
}
25 changes: 22 additions & 3 deletions apps/cli/lib/paths.ts
Original file line number Diff line number Diff line change
@@ -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' );
39 changes: 39 additions & 0 deletions apps/cli/tests/paths.test.ts
Original file line number Diff line number Diff line change
@@ -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 );
} );
} );
Loading