diff --git a/packages/runtime-host/src/__tests__/control-endpoint.test.ts b/packages/runtime-host/src/__tests__/control-endpoint.test.ts index 1ffc074fa2..4ffff0ff3a 100644 --- a/packages/runtime-host/src/__tests__/control-endpoint.test.ts +++ b/packages/runtime-host/src/__tests__/control-endpoint.test.ts @@ -1,13 +1,25 @@ import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; +import { type ExecFileException, spawnSync } from 'node:child_process'; import { mkdir, mkdtemp, rm, stat } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { afterEach, describe, test } from 'node:test'; -import { prepareRuntimeHostEndpoint, RuntimeHostEndpointError } from '../control/endpoint.js'; +import { + prepareRuntimeHostEndpoint, + RuntimeHostEndpointError, + windowsPipeAclFailure, +} from '../control/endpoint.js'; import { removePosixEndpointDirectories } from './fixtures/endpoint-hygiene.js'; const ROOT_ID = 'ab'.repeat(32); const PORTABLE_UNIX_SOCKET_PATH_LIMIT = 100; +const PIPE_PATH = `\\\\.\\pipe\\maka-runtime-host-${ROOT_ID.slice(0, 16)}-epoch-1`; + +function execFileException(overrides: Partial): ExecFileException { + return Object.assign(new Error('Command failed: powershell.exe -NoLogo'), { + cmd: 'powershell.exe -NoLogo', + ...overrides, + }); +} function rootTag(): string { return Buffer.from(ROOT_ID, 'hex').toString('base64url'); @@ -38,6 +50,52 @@ describe('runtime host Windows named-pipe endpoint', { skip: process.platform != }); }); +// The ACL call itself only runs on Windows, so the failure mapping is covered +// directly: it is the part a CI log has to be read through. +describe('runtime host Windows named-pipe ACL failures', () => { + test('reports the exit code and the PowerShell diagnostic on a failed ACL', () => { + const error = windowsPipeAclFailure( + PIPE_PATH, + execFileException({ code: 1 }), + '', + 'Get-Item : Cannot find path\n At line:12 char:9\n', + ); + assert.equal(error.code, 'insecure_endpoint_directory'); + assert.match(error.message, /powershell exited with 1/); + assert.match(error.message, /Get-Item : Cannot find path At line:12 char:9$/); + assert.ok(error.message.includes(PIPE_PATH)); + assert.ok(!error.message.includes('\n')); + }); + + test('reports a timeout kill as unconfirmed rather than as a failed ACL', () => { + const error = windowsPipeAclFailure( + PIPE_PATH, + execFileException({ killed: true, signal: 'SIGTERM' }), + '', + '', + ); + assert.equal(error.code, 'insecure_endpoint_directory'); + assert.match(error.message, /could not confirm .* within 30000ms and refused the endpoint/); + assert.match(error.message, /powershell killed with SIGTERM/); + }); + + test('reports a spawn failure by its errno code', () => { + const error = windowsPipeAclFailure(PIPE_PATH, execFileException({ code: 'ENOENT' }), '', ''); + assert.equal(error.code, 'insecure_endpoint_directory'); + assert.match(error.message, /\(powershell failed to run: ENOENT\)$/); + }); + + test('falls back to stdout and truncates an oversized diagnostic', () => { + const error = windowsPipeAclFailure( + PIPE_PATH, + execFileException({ code: 1 }), + 'x'.repeat(2000), + '', + ); + assert.match(error.message, /: x{1000} \[truncated\]$/); + }); +}); + describe('runtime host control endpoint', { skip: process.platform === 'win32' }, () => { const originalTmpdir = process.env.TMPDIR; diff --git a/packages/runtime-host/src/control/endpoint.ts b/packages/runtime-host/src/control/endpoint.ts index 6df70e8b71..b205dd348a 100644 --- a/packages/runtime-host/src/control/endpoint.ts +++ b/packages/runtime-host/src/control/endpoint.ts @@ -1,4 +1,4 @@ -import { execFile } from 'node:child_process'; +import { execFile, type ExecFileException } from 'node:child_process'; import { chmod, lstat, mkdtemp, readdir, rm, rmdir, unlink } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -8,6 +8,18 @@ const PORTABLE_UNIX_SOCKET_PATH_LIMIT = 100; const ENDPOINT_SOCKET_NAME = 'h.sock'; const MKDTEMP_SUFFIX_LENGTH = 6; const WINDOWS_PIPE_PATH_ENV = 'MAKA_RUNTIME_HOST_PIPE_PATH'; +// This ACL is the whole trust boundary of the Local IPC endpoint: every +// accepted connection is granted Local Owner authority without a further +// per-connection check, so the call has to succeed and a timeout has to +// refuse the endpoint. That makes the budget a question of how long we are +// willing to wait, not how long the work should take. Windows PowerShell 5.1 +// cold start plus .NET type loading is seconds on a loaded CI runner, and a +// 10s budget killed a healthy run (#3225); keep the ceiling well clear of a +// slow start so it only fires on a genuinely stuck process. Endpoint readiness +// waits on this, so raising it means checking that the waiters still outlast +// it: scripts/windows-runtime-host-local-ipc-trust.ps1 and client/wait-for-ready.ts. +const WINDOWS_PIPE_ACL_TIMEOUT_MS = 30_000; +const WINDOWS_PIPE_ACL_DIAGNOSTIC_LIMIT = 1_000; const WINDOWS_PIPE_ACL_SCRIPT = String.raw` $ErrorActionPreference = 'Stop' $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() @@ -120,25 +132,69 @@ function secureWindowsNamedPipe(path: string): Promise { ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', WINDOWS_PIPE_ACL_SCRIPT], { env: { ...process.env, [WINDOWS_PIPE_PATH_ENV]: path }, - timeout: 10_000, + timeout: WINDOWS_PIPE_ACL_TIMEOUT_MS, windowsHide: true, }, - (error) => { + (error, stdout, stderr) => { if (!error) { resolve(); return; } - reject( - new RuntimeHostEndpointError( - 'insecure_endpoint_directory', - 'Runtime Host could not restrict its Windows Local IPC endpoint to the current user', - ), - ); + reject(windowsPipeAclFailure(path, error, stdout, stderr)); }, ); }); } +// Every failure here refuses the endpoint, but the causes call for different +// responses: a PowerShell diagnostic points at an ACL failure that may leave +// the pipe world-accessible, while a timeout kill means the restriction was +// never confirmed either way. Carry the cause into the message so a CI log +// can be read without rerunning the job (#3225). +export function windowsPipeAclFailure( + path: string, + error: ExecFileException, + stdout: string, + stderr: string, +): RuntimeHostEndpointError { + const diagnostic = powershellDiagnostic(stdout, stderr); + if (error.killed === true) { + return new RuntimeHostEndpointError( + 'insecure_endpoint_directory', + `Runtime Host could not confirm its Windows Local IPC endpoint restriction within ${WINDOWS_PIPE_ACL_TIMEOUT_MS}ms and refused the endpoint: ${path} (powershell killed with ${error.signal ?? 'no signal'})${diagnostic}`, + ); + } + return new RuntimeHostEndpointError( + 'insecure_endpoint_directory', + `Runtime Host could not restrict its Windows Local IPC endpoint to the current user: ${path} (${describeExecFileFailure(error)})${diagnostic}`, + ); +} + +function describeExecFileFailure(error: ExecFileException): string { + if (typeof error.code === 'number') return `powershell exited with ${error.code}`; + if (typeof error.code === 'string') return `powershell failed to run: ${error.code}`; + if (error.signal) return `powershell killed with ${error.signal}`; + return `powershell failed: ${clip(error.message)}`; +} + +// PowerShell reports the failing statement on stderr; stdout only carries +// content when the script writes before failing. Both are output of a script +// whose only input is the pipe path this process just created, so neither +// widens what the message already exposes. +function powershellDiagnostic(stdout: string, stderr: string): string { + const output = clip(stderr.trim() || stdout.trim()); + return output ? `: ${output}` : ''; +} + +// Collapse to a single grep-friendly line and cap it: a PowerShell error +// record spans several lines, and Node's exec message repeats the whole +// command, ACL script included. +function clip(value: string): string { + const collapsed = value.replace(/\s+/g, ' ').trim(); + if (collapsed.length <= WINDOWS_PIPE_ACL_DIAGNOSTIC_LIMIT) return collapsed; + return `${collapsed.slice(0, WINDOWS_PIPE_ACL_DIAGNOSTIC_LIMIT)} [truncated]`; +} + // Honor TMPDIR via os.tmpdir(), but never at the cost of a socket path over // the portable sun_path budget: macOS per-user temp roots and the parallel // test runner's nested TMPDIR produce bases long enough that the full diff --git a/scripts/windows-runtime-host-local-ipc-trust.ps1 b/scripts/windows-runtime-host-local-ipc-trust.ps1 index 17a6bbd8c4..399b759a9e 100644 --- a/scripts/windows-runtime-host-local-ipc-trust.ps1 +++ b/scripts/windows-runtime-host-local-ipc-trust.ps1 @@ -138,7 +138,12 @@ try { } $fixture = $candidateFixture - $ready = Read-ProcessLine -Process $fixture -TimeoutMilliseconds 10000 | ConvertFrom-Json + # The fixture prints readiness only once the endpoint ACL has been applied, so + # this deadline has to outlast WINDOWS_PIPE_ACL_TIMEOUT_MS in + # packages/runtime-host/src/control/endpoint.ts -- otherwise this throws first + # and the fixture's own diagnostic is never read. 45s matches the client + # readiness budget in packages/runtime-host/src/client/wait-for-ready.ts. + $ready = Read-ProcessLine -Process $fixture -TimeoutMilliseconds 45000 | ConvertFrom-Json if ($ready.type -ne 'ready' -or $ready.endpoint -notmatch '^\\\\\.\\pipe\\(.+)$') { throw "Invalid Runtime Host trust fixture readiness: $($ready | ConvertTo-Json -Compress)" }