Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 60 additions & 2 deletions packages/runtime-host/src/__tests__/control-endpoint.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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');
Expand Down Expand Up @@ -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;

Expand Down
74 changes: 65 additions & 9 deletions packages/runtime-host/src/control/endpoint.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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()
Expand Down Expand Up @@ -120,25 +132,69 @@ function secureWindowsNamedPipe(path: string): Promise<void> {
['-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
Expand Down
7 changes: 6 additions & 1 deletion scripts/windows-runtime-host-local-ipc-trust.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
Expand Down
Loading