diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21f972a10..9d52fb6d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,87 @@ env: COREPACK_ENABLE_DOWNLOAD_PROMPT: 0 jobs: + windows-supervisor: + name: Windows supervisor (${{ matrix.arch }}) + strategy: + fail-fast: false + matrix: + include: + - runner: windows-latest + arch: x64 + - runner: windows-11-arm + arch: arm64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + persist-credentials: false + - name: Setup pnpm + uses: pnpm/action-setup@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + architecture: ${{ matrix.arch }} + cache: pnpm + - name: Compile native supervisor + run: node apps/cli/scripts/build-windows-process-supervisor.mjs --arch ${{ matrix.arch }} --out-dir apps/cli/dist + - name: Test native ownership and packaging validation + run: node --test apps/cli/native/windows-process-supervisor.test.mjs apps/cli/scripts/windows-supervisor-artifacts.test.mjs + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Test Node adapter integration + env: + LODY_WINDOWS_SUPERVISOR_INTEGRATION: '1' + run: pnpm --dir apps/cli exec vitest run src/utils/windows-owned-process.integration.test.ts + - name: Upload native supervisor + uses: actions/upload-artifact@v4 + with: + name: windows-process-supervisor-win32-${{ matrix.arch }} + path: apps/cli/dist/windows-process-supervisor-win32-${{ matrix.arch }}.exe + if-no-files-found: error + windows-supervisor-package: + name: Windows supervisor npm package + needs: windows-supervisor + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + persist-credentials: false + - name: Setup pnpm + uses: pnpm/action-setup@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build CLI JavaScript before ingesting native artifacts + run: pnpm --dir apps/cli build + - name: Download both tested Windows supervisors + uses: actions/download-artifact@v4 + with: + pattern: windows-process-supervisor-win32-* + merge-multiple: true + path: apps/cli/dist + - name: Pack without publishing + run: pnpm --dir apps/cli pack --pack-destination "$RUNNER_TEMP/supervisor-package" + - name: Verify both architectures and launcher in npm archive + shell: bash + run: | + archive=$(find "$RUNNER_TEMP/supervisor-package" -maxdepth 1 -name '*.tgz' -print -quit) + test -n "$archive" + tar -tzf "$archive" > "$RUNNER_TEMP/supervisor-package/contents.txt" + for asset in windows-process-supervisor-win32-x64.exe windows-process-supervisor-win32-arm64.exe windows-process-launcher.js; do + grep -Fx -- "package/dist/$asset" "$RUNNER_TEMP/supervisor-package/contents.txt" + done static: name: Static checks runs-on: ubuntu-latest diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index af4c8cf4e..6116eb1c0 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -86,6 +86,23 @@ Two things the dev build does deliberately, both load-bearing: ## Process lifecycle +- Windows owned processes require the flat `windows-process-launcher.js` and + `windows-process-supervisor-win32-{x64,arm64}.exe` next to the CLI entries. + The adapter maps the known `chunks/` build directory to that root. Both Vite + and development builds compile the host architecture on Windows after JS output; + developers need Visual Studio C++ tools, but installed users never compile. + Missing supervisor artifacts must fail closed, never launch an unowned process. +- npm `prepack` validates BOTH Windows PE architectures regardless of publisher OS. + After the final JS build (which cleans `dist`), download the matching commit's + `windows-process-supervisor-win32-x64` and `windows-process-supervisor-win32-arm64` + CI artifacts directly into `apps/cli/dist`, then run `pnpm --dir apps/cli pack`. + Do not rebuild/clean JS after ingestion or commit generated executables. The CI + Windows matrix executes native ownership tests on each architecture before upload. + The dependent Linux package job consumes both same-run artifacts after the JS + build, runs prepack, and checks both executables plus the launcher in the npm archive. + Native launcher integration tests require LODY_WINDOWS_SUPERVISOR_INTEGRATION=1; + the Windows CI matrix sets it explicitly. Default unit runs do not invoke MSVC. + - Read context/local-agent-ownership.md before changing local ports/sockets, daemon PID state, Electron/daemon startup, Supervisor retries, or Worker shutdown. Health probes are observation only and diff --git a/apps/cli/native/windows-process-supervisor.cpp b/apps/cli/native/windows-process-supervisor.cpp new file mode 100644 index 000000000..e5a1b3a49 --- /dev/null +++ b/apps/cli/native/windows-process-supervisor.cpp @@ -0,0 +1,251 @@ +// Windows-only process boundary. The supervisor alone owns the job handle. +// Spawn with pipes for fd 0..3 and arguments: --owner-pid PID -- EXE [ARG...]. +// EXE must be absolute. Environment/cwd and standard streams pass through unchanged. +// fd 3 protocol (ASCII, newline-delimited): +// supervisor: {"type":"ready","protocol":1} parent: start\n +// supervisor: {"type":"prepared","pid":PID} parent: resume\n +// supervisor: {"type":"started"} +// Keep fd 3 open until exit. EOF or unexpected input cancels the owned job. +// Startup failures report only stage and numeric Windows error, never arguments. + +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr DWORD kStartupTimeoutMs = 10000; +constexpr DWORD kSupervisorFailure = 125; +constexpr DWORD kOwnerGone = 124; + +struct Handle { + HANDLE value = nullptr; + explicit Handle(HANDLE handle = nullptr) : value(handle) {} + ~Handle() { + if (value != nullptr && value != INVALID_HANDLE_VALUE) CloseHandle(value); + } + Handle(const Handle&) = delete; + Handle& operator=(const Handle&) = delete; +}; + +bool WriteStatus(HANDLE control, const std::string& message) { + if (control == nullptr || control == INVALID_HANDLE_VALUE) return false; + DWORD written = 0; + return WriteFile(control, message.data(), static_cast(message.size()), + &written, nullptr) && written == message.size(); +} + +[[noreturn]] void Fail(HANDLE control, const char* stage, DWORD error) { + const std::string status = std::string("{\"type\":\"error\",\"stage\":\"") + + stage + "\",\"code\":" + std::to_string(error) + "}\n"; + WriteStatus(control, status); + // This executable is the process boundary. The kernel closes every handle, + // including the only job handle, even if a control reader is blocked. + ExitProcess(kSupervisorFailure); +} + +struct ReadCommand { + HANDLE pipe; + const char* expected; + bool matched = false; +}; + +DWORD WINAPI ReadExpectedCommand(void* argument) { + auto* request = static_cast(argument); + for (const char* next = request->expected; *next != '\0'; ++next) { + char byte = 0; + DWORD received = 0; + if (!ReadFile(request->pipe, &byte, 1, &received, nullptr) || received != 1 || + byte != *next) return 0; + } + request->matched = true; + return 0; +} + +void AwaitCommand(HANDLE control, HANDLE owner, const char* expected) { + ReadCommand request{control, expected}; + Handle reader(CreateThread(nullptr, 0, ReadExpectedCommand, &request, 0, nullptr)); + if (reader.value == nullptr) Fail(control, "handshake-thread", GetLastError()); + const HANDLE waits[] = {owner, reader.value}; + const DWORD result = WaitForMultipleObjects(2, waits, FALSE, kStartupTimeoutMs); + if (result == WAIT_OBJECT_0) ExitProcess(kOwnerGone); + if (result == WAIT_TIMEOUT) Fail(control, "handshake-timeout", WAIT_TIMEOUT); + if (result != WAIT_OBJECT_0 + 1) Fail(control, "handshake-wait", GetLastError()); + if (!request.matched) Fail(control, "handshake", ERROR_INVALID_DATA); + // Reader has exited before its stack argument or thread handle is released. +} + +DWORD WINAPI WaitForControlClosure(void* argument) { + const HANDLE control = static_cast(argument); + char byte = 0; + DWORD received = 0; + // EOF, read failure, or an unexpected command all withdraw ownership. + ReadFile(control, &byte, 1, &received, nullptr); + return 0; +} + +bool ParsePid(const wchar_t* text, DWORD& result) { + if (*text == L'\0') return false; + std::uint64_t value = 0; + for (const wchar_t* next = text; *next != L'\0'; ++next) { + if (*next < L'0' || *next > L'9') return false; + value = value * 10 + static_cast(*next - L'0'); + if (value > std::numeric_limits::max()) return false; + } + if (value == 0) return false; + result = static_cast(value); + return true; +} + +bool IsAbsolute(const std::wstring& path) { + return (path.size() >= 3 && path[1] == L':' && + (path[2] == L'\\' || path[2] == L'/')) || + (path.size() >= 3 && path[0] == L'\\' && path[1] == L'\\'); +} + +// Quote one argv element for the Windows CRT parser. Backslashes are doubled +// only before a quote or the closing quote; no shell interprets this string. +std::wstring QuoteArgument(const wchar_t* argument) { + std::wstring quoted = L"\""; + std::size_t slashes = 0; + for (const wchar_t* next = argument; *next != L'\0'; ++next) { + if (*next == L'\\') { + ++slashes; + continue; + } + if (*next == L'\"') { + quoted.append(slashes * 2 + 1, L'\\'); + } else { + quoted.append(slashes, L'\\'); + } + quoted.push_back(*next); + slashes = 0; + } + quoted.append(slashes * 2, L'\\'); + quoted.push_back(L'\"'); + return quoted; +} +} // namespace + +int wmain(int argc, wchar_t** argv) { + // A missing fd 3 is a launch-contract failure, not a CRT crash dialog. + _set_invalid_parameter_handler([](const wchar_t*, const wchar_t*, const wchar_t*, + unsigned, uintptr_t) {}); + const intptr_t descriptor = _get_osfhandle(3); + const HANDLE control = descriptor == -1 ? INVALID_HANDLE_VALUE + : reinterpret_cast(descriptor); + if (control == INVALID_HANDLE_VALUE) return static_cast(kSupervisorFailure); + if (!SetHandleInformation(control, HANDLE_FLAG_INHERIT, 0)) { + Fail(control, "control-handle", GetLastError()); + } + DWORD ownerPid = 0; + if (argc < 5 || std::wcscmp(argv[1], L"--owner-pid") != 0 || + std::wcscmp(argv[3], L"--") != 0 || !ParsePid(argv[2], ownerPid) || + !IsAbsolute(argv[4])) { + Fail(control, "arguments", ERROR_INVALID_PARAMETER); + } + Handle owner(OpenProcess(SYNCHRONIZE, FALSE, ownerPid)); + if (owner.value == nullptr) Fail(control, "owner-handle", GetLastError()); + Handle job(CreateJobObjectW(nullptr, nullptr)); + if (job.value == nullptr) Fail(control, "create-job", GetLastError()); + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{}; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if (!SetInformationJobObject(job.value, JobObjectExtendedLimitInformation, + &limits, sizeof(limits))) { + Fail(control, "job-limits", GetLastError()); + } + if (!WriteStatus(control, "{\"type\":\"ready\",\"protocol\":1}\n")) { + ExitProcess(kSupervisorFailure); + } + // The parent must answer AFTER its handle was captured. A reused owner PID + // cannot authorize startup after the original parent/pipe writer has died. + AwaitCommand(control, owner.value, "start\n"); + + STARTUPINFOEXW startup{}; + startup.StartupInfo.cb = sizeof(startup); + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW; + startup.StartupInfo.wShowWindow = SW_HIDE; + startup.StartupInfo.hStdInput = GetStdHandle(STD_INPUT_HANDLE); + startup.StartupInfo.hStdOutput = GetStdHandle(STD_OUTPUT_HANDLE); + startup.StartupInfo.hStdError = GetStdHandle(STD_ERROR_HANDLE); + std::vector inherited; + for (HANDLE stream : {startup.StartupInfo.hStdInput, startup.StartupInfo.hStdOutput, + startup.StartupInfo.hStdError}) { + if (stream == nullptr || stream == INVALID_HANDLE_VALUE) { + Fail(control, "standard-stream", ERROR_INVALID_HANDLE); + } + if (!SetHandleInformation(stream, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT)) { + Fail(control, "standard-stream", GetLastError()); + } + bool duplicate = false; + for (HANDLE previous : inherited) duplicate = duplicate || previous == stream; + if (!duplicate) inherited.push_back(stream); + } + + SIZE_T attributeBytes = 0; + InitializeProcThreadAttributeList(nullptr, 2, 0, &attributeBytes); + std::vector attributes(attributeBytes); + startup.lpAttributeList = reinterpret_cast(attributes.data()); + if (!InitializeProcThreadAttributeList(startup.lpAttributeList, 2, 0, &attributeBytes)) { + Fail(control, "startup-attributes", GetLastError()); + } + if (!UpdateProcThreadAttribute(startup.lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_JOB_LIST, + &job.value, sizeof(job.value), nullptr, nullptr) || + !UpdateProcThreadAttribute(startup.lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + inherited.data(), inherited.size() * sizeof(HANDLE), nullptr, + nullptr)) { + Fail(control, "startup-attributes", GetLastError()); + } + std::wstring commandLine; + for (int index = 4; index < argc; ++index) { + if (!commandLine.empty()) commandLine.push_back(L' '); + commandLine += QuoteArgument(argv[index]); + } + if (commandLine.size() >= 32767) Fail(control, "command-length", ERROR_INVALID_PARAMETER); + if (WaitForSingleObject(owner.value, 0) != WAIT_TIMEOUT) ExitProcess(kOwnerGone); + + PROCESS_INFORMATION process{}; + // JOB_LIST makes association atomic with creation, including a supervisor + // crash inside CreateProcess. No running or suspended unowned-child window. + const BOOL created = CreateProcessW( + argv[4], commandLine.data(), nullptr, nullptr, TRUE, + CREATE_SUSPENDED | CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT | + EXTENDED_STARTUPINFO_PRESENT, + nullptr, nullptr, &startup.StartupInfo, &process); + const DWORD creationError = GetLastError(); + DeleteProcThreadAttributeList(startup.lpAttributeList); + if (!created) Fail(control, "create-process", creationError); + Handle child(process.hProcess); + Handle primaryThread(process.hThread); + if (!WriteStatus(control, "{\"type\":\"prepared\",\"pid\":" + + std::to_string(process.dwProcessId) + "}\n")) { + ExitProcess(kSupervisorFailure); + } + AwaitCommand(control, owner.value, "resume\n"); + if (WaitForSingleObject(owner.value, 0) != WAIT_TIMEOUT) ExitProcess(kOwnerGone); + if (ResumeThread(primaryThread.value) == static_cast(-1)) { + Fail(control, "resume", GetLastError()); + } + CloseHandle(primaryThread.value); + primaryThread.value = nullptr; + if (!WriteStatus(control, "{\"type\":\"started\"}\n")) ExitProcess(kSupervisorFailure); + + Handle controlReader(CreateThread(nullptr, 0, WaitForControlClosure, control, 0, nullptr)); + if (controlReader.value == nullptr) Fail(control, "control-thread", GetLastError()); + const HANDLE waits[] = {owner.value, child.value, controlReader.value}; + const DWORD result = WaitForMultipleObjects(3, waits, FALSE, INFINITE); + if (result == WAIT_OBJECT_0 || result == WAIT_OBJECT_0 + 2) ExitProcess(kOwnerGone); + if (result != WAIT_OBJECT_0 + 1) Fail(control, "process-wait", GetLastError()); + DWORD exitCode = kSupervisorFailure; + if (!GetExitCodeProcess(child.value, &exitCode)) Fail(control, "exit-code", GetLastError()); + ExitProcess(exitCode); +} diff --git a/apps/cli/native/windows-process-supervisor.test.mjs b/apps/cli/native/windows-process-supervisor.test.mjs new file mode 100644 index 000000000..8517b5a44 --- /dev/null +++ b/apps/cli/native/windows-process-supervisor.test.mjs @@ -0,0 +1,381 @@ +import assert from 'node:assert/strict'; +import { spawn, execFileSync } from 'node:child_process'; +import { once } from 'node:events'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createInterface } from 'node:readline'; +import { before, after, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const cliRoot = fileURLToPath(new URL('../', import.meta.url)); +let scratch; +let binary; + +function bounded(promise, timeoutMs = 15_000) { + let timer; + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Process fixture deadline exceeded')), timeoutMs); + }), + ]).finally(() => clearTimeout(timer)); +} + +function processExit(child) { + const result = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal })); + }); + void result.catch(() => {}); + return result; +} + +function readLines(stream) { + const lines = createInterface({ input: stream }); + const iterator = lines[Symbol.asyncIterator](); + return { + async next() { + const result = await bounded(iterator.next()); + assert.equal(result.done, false, 'Process closed before expected output'); + return result.value; + }, + close() { + lines.close(); + }, + }; +} + +async function stopOwned(child, exit) { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await bounded(exit); +} + +function startSupervisor(t, executable, args, env = process.env) { + const child = spawn(binary, ['--owner-pid', String(process.pid), '--', executable, ...args], { + stdio: ['pipe', 'pipe', 'pipe', 'pipe'], + windowsHide: true, + // Prevent Node's own parent-lifetime job from satisfying these assertions. + detached: true, + cwd: scratch, + env, + }); + const exit = processExit(child); + const control = child.stdio[3]; + assert.ok(control); + const status = readLines(control); + t.after(async () => { + try { + await stopOwned(child, exit); + } finally { + status.close(); + } + }); + return { + child, + control, + status, + exit, + async prepare() { + assert.deepEqual(JSON.parse(await status.next()), { type: 'ready', protocol: 1 }); + control.write('start\n'); + const prepared = JSON.parse(await status.next()); + assert.equal(prepared.type, 'prepared'); + assert.ok(Number.isSafeInteger(prepared.pid) && prepared.pid > 0); + return prepared.pid; + }, + async resume() { + control.write('resume\n'); + assert.deepEqual(JSON.parse(await status.next()), { type: 'started' }); + }, + }; +} + +async function observeOwned(t, pids) { + assert.equal(new Set(pids).size, pids.length); + assert.ok(pids.every((pid) => Number.isSafeInteger(pid) && pid > 0)); + const script = String.raw` + $ErrorActionPreference = 'Stop' + $owned = @() + try { + foreach ($processId in @(${pids.join(',')})) { + $item = [System.Diagnostics.Process]::GetProcessById($processId) + $null = $item.Handle + $owned += $item + if ($item.HasExited) { throw 'Fixture exited before observation' } + } + [Console]::WriteLine('handles-ready') + $command = [Console]::In.ReadLineAsync() + if (-not $command.Wait(15000) -or $command.Result -ne 'verify') { throw 'Observation canceled' } + foreach ($item in $owned) { + if (-not $item.WaitForExit(5000)) { throw 'Owned process survived' } + if ($item.ExitCode -eq 90) { throw 'Fixture watchdog fired' } + } + [Console]::WriteLine('all-exited') + } finally { + [Array]::Reverse($owned) + $cleanupFailed = $false + foreach ($item in $owned) { + try { + if (-not $item.HasExited) { $item.Kill() } + if (-not $item.WaitForExit(5000)) { $cleanupFailed = $true } + } catch { $cleanupFailed = $true } finally { $item.Dispose() } + } + if ($cleanupFailed) { throw 'Owned fixture cleanup failed' } + } + `; + const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + const exit = processExit(child); + const lines = readLines(child.stdout); + let stderr = ''; + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + t.after(async () => { + child.stdin.end(); + try { + await bounded(exit); + } finally { + lines.close(); + } + }); + assert.equal(await lines.next(), 'handles-ready'); + return { + async verify() { + child.stdin.end('verify\n'); + const result = await bounded(exit); + assert.deepEqual(result, { code: 0, signal: null }, stderr); + assert.equal(await lines.next(), 'all-exited', stderr); + }, + }; +} + +// Every descendant stays alive independently of IPC disconnect/parent death. +// A timer is solely an orphan watchdog, never a test's synchronization mechanism. +const treeSource = String.raw` + const { spawn } = require('node:child_process'); + function descendant(depth) { + const { spawn } = require('node:child_process'); + setTimeout(() => process.exit(90), 60000); + if (depth === 0) { process.send([process.pid]); return; } + const child = spawn(process.execPath, ['-e', '(' + descendant.toString() + ')(' + (depth - 1) + ')'], { + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], windowsHide: true, detached: true, + }); + child.once('message', (pids) => process.send([process.pid, ...pids])); + } + const child = spawn(process.execPath, ['-e', '(' + descendant.toString() + ')(1)'], { + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], windowsHide: true, detached: true, + }); + child.once('message', (pids) => process.stdout.write(JSON.stringify([process.pid, ...pids]) + '\n')); + process.stdin.once('data', (command) => { + if (command.toString().startsWith('crash')) process.kill(process.pid, 'SIGKILL'); + else process.exit(23); + }); + setTimeout(() => process.exit(90), 60000); +`; + +async function runningTree(t) { + const supervisor = startSupervisor(t, process.execPath, ['-e', treeSource]); + const output = readLines(supervisor.child.stdout); + t.after(() => output.close()); + const rootPid = await supervisor.prepare(); + await supervisor.resume(); + const pids = JSON.parse(await output.next()); + assert.equal(pids.length, 3); + assert.equal(pids[0], rootPid); + return { ...supervisor, pids }; +} + +void describe('native Windows process supervisor', { skip: process.platform !== 'win32' }, () => { + before(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'lody-supervisor-test-')); + execFileSync( + process.execPath, + [path.join(cliRoot, 'scripts', 'build-windows-process-supervisor.mjs'), '--out-dir', scratch], + { stdio: 'pipe', windowsHide: true, timeout: 120_000 } + ); + binary = path.join(scratch, `windows-process-supervisor-win32-${process.arch}.exe`); + }); + after(() => { + if (!scratch) return; + const relative = path.relative(os.tmpdir(), scratch); + assert.ok(relative && !relative.startsWith('..') && !path.isAbsolute(relative)); + fs.rmSync(scratch, { recursive: true, force: true }); + }); + + void it('preserves UTF-16 argv, inherited streams/env/cwd, and the target exit code', async (t) => { + const args = [ + '', + 'a b', + 'quote"here', + 'trailing\\', + '\\\\"quoted', + '日本語 😀', + '&|<>^%PATH%!', + ]; + const source = String.raw` + let input = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { input += chunk; }); + process.stdin.on('end', () => { + process.stdout.write(JSON.stringify({ args: process.argv.slice(1), cwd: process.cwd(), + env: process.env.LODY_SUPERVISOR_TEST, input })); + process.stderr.write('stderr-preserved'); + process.exitCode = 37; + }); + `; + const supervisor = startSupervisor(t, process.execPath, ['-e', source, '--', ...args], { + ...process.env, + LODY_SUPERVISOR_TEST: 'synthetic-value', + }); + let stdout = ''; + let stderr = ''; + supervisor.child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + supervisor.child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + await supervisor.prepare(); + await supervisor.resume(); + supervisor.child.stdin.end('stdin-preserved'); + assert.deepEqual(await bounded(supervisor.exit), { code: 37, signal: null }); + assert.deepEqual(JSON.parse(stdout), { + args, + cwd: scratch, + env: 'synthetic-value', + input: 'stdin-preserved', + }); + assert.equal(stderr, 'stderr-preserved'); + }); + + void it('kills detached descendants when the target exits normally', async (t) => { + const tree = await runningTree(t); + const observer = await observeOwned(t, [tree.child.pid, ...tree.pids]); + tree.child.stdin.write('exit\n'); + await observer.verify(); + assert.deepEqual(await bounded(tree.exit), { code: 23, signal: null }); + }); + + void it('kills detached descendants when the native supervisor is killed', async (t) => { + const tree = await runningTree(t); + const observer = await observeOwned(t, [tree.child.pid, ...tree.pids]); + tree.child.kill('SIGKILL'); + await observer.verify(); + }); + + void it('kills detached descendants when the target crashes', async (t) => { + const tree = await runningTree(t); + const observer = await observeOwned(t, [tree.child.pid, ...tree.pids]); + tree.child.stdin.write('crash\n'); + await observer.verify(); + assert.deepEqual(await bounded(tree.exit), { code: 1, signal: null }); + }); + + void it('kills the job when the owner closes its control channel', async (t) => { + const tree = await runningTree(t); + const observer = await observeOwned(t, [tree.child.pid, ...tree.pids]); + tree.control.end(); + await observer.verify(); + assert.deepEqual(await bounded(tree.exit), { code: 124, signal: null }); + }); + + void it('returns every owned process to zero survivors across three lifetimes', async (t) => { + for (const ending of ['exit', 'crash', 'supervisor-kill']) { + const tree = await runningTree(t); + const observer = await observeOwned(t, [tree.child.pid, ...tree.pids]); + if (ending === 'supervisor-kill') tree.child.kill('SIGKILL'); + else tree.child.stdin.write(`${ending}\n`); + // Settle all four original OS handles before starting the next lifetime. + // This is an owned-fixture baseline, never a machine-wide process census. + await observer.verify(); + await bounded(tree.exit); + } + }); + + for (const phase of ['prepared', 'running']) { + void it(`kills all owned processes after abrupt owner death (${phase})`, async (t) => { + const ownerSource = String.raw` + const { spawn } = require('node:child_process'); + const { createInterface } = require('node:readline'); + const child = spawn(process.argv[1], ['--owner-pid', String(process.pid), '--', process.execPath, + '-e', process.argv[2]], { stdio: ['pipe', 'pipe', 'pipe', 'pipe'], detached: true, windowsHide: true }); + const control = child.stdio[3]; + createInterface({ input: control }).on('line', (line) => { + const status = JSON.parse(line); + if (status.type === 'ready') control.write('start\n'); + if (status.type === 'prepared') { + if (process.argv[3] === 'prepared') process.send([process.pid, child.pid, status.pid]); + else control.write('resume\n'); + } + }); + createInterface({ input: child.stdout }).on('line', (line) => { + process.send([process.pid, child.pid, ...JSON.parse(line)]); + }); + setTimeout(() => process.exit(90), 60000); + `; + const owner = spawn(process.execPath, ['-e', ownerSource, binary, treeSource, phase], { + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + windowsHide: true, + }); + const exit = processExit(owner); + t.after(() => stopOwned(owner, exit)); + const [pids] = await once(owner, 'message', { signal: AbortSignal.timeout(15_000) }); + assert.equal(pids.length, phase === 'prepared' ? 3 : 5); + const observer = await observeOwned(t, pids); + owner.kill('SIGKILL'); + await observer.verify(); + }); + } + + void it('kills a suspended child if the supervisor crashes before resume', async (t) => { + const supervisor = startSupervisor(t, process.execPath, [ + '-e', + 'process.stdout.write("unexpected-run")', + ]); + let output = ''; + supervisor.child.stdout.on('data', (chunk) => { + output += chunk.toString(); + }); + const pid = await supervisor.prepare(); + const observer = await observeOwned(t, [supervisor.child.pid, pid]); + supervisor.child.kill('SIGKILL'); + await observer.verify(); + assert.equal(output, ''); + }); + + void it('rejects native launch failure without emitting arguments or environment', async (t) => { + const supervisor = startSupervisor(t, path.join(scratch, 'missing.exe'), ['synthetic-secret']); + assert.deepEqual(JSON.parse(await supervisor.status.next()), { type: 'ready', protocol: 1 }); + supervisor.control.write('start\n'); + assert.deepEqual(JSON.parse(await supervisor.status.next()), { + type: 'error', + stage: 'create-process', + code: 2, + }); + assert.deepEqual(await bounded(supervisor.exit), { code: 125, signal: null }); + }); + + void it('requires explicit parent authorization before creating a child', async (t) => { + const supervisor = startSupervisor(t, process.execPath, [ + '-e', + 'process.stdout.write("unexpected-run")', + ]); + let output = ''; + supervisor.child.stdout.on('data', (chunk) => { + output += chunk.toString(); + }); + assert.deepEqual(JSON.parse(await supervisor.status.next()), { type: 'ready', protocol: 1 }); + supervisor.control.write('invalid\n'); + assert.deepEqual(JSON.parse(await supervisor.status.next()), { + type: 'error', + stage: 'handshake', + code: 13, + }); + assert.deepEqual(await bounded(supervisor.exit), { code: 125, signal: null }); + assert.equal(output, ''); + }); +}); diff --git a/apps/cli/package.json b/apps/cli/package.json index 2a9aefdb6..10728b9c1 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -10,6 +10,7 @@ "scripts": { "dev": "pnpm run prepare:acp-adapters && node scripts/dev-build.mjs && node --enable-source-maps dist-dev/index.js", "dev:build": "node scripts/dev-build.mjs", + "prepack": "node scripts/windows-supervisor-artifacts.mjs verify-package", "build": "pnpm run clean && pnpm run prepare:acp-adapters && pnpm run typecheck && pnpm run build:bundle && pnpm run copy:dsh-presets && pnpm run check:published-bundle-imports && pnpm run copy:wasm", "build:watch": "pnpm run prepare:review-assets && vite build --watch", "build:bundle": "pnpm run prepare:review-assets && vite build", diff --git a/apps/cli/scripts/build-windows-process-supervisor.mjs b/apps/cli/scripts/build-windows-process-supervisor.mjs new file mode 100644 index 000000000..ed6ea655c --- /dev/null +++ b/apps/cli/scripts/build-windows-process-supervisor.mjs @@ -0,0 +1,132 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const cliRoot = fileURLToPath(new URL('../', import.meta.url)); +const options = { arch: process.arch, outDir: path.join(cliRoot, 'dist') }; +for (let index = 2; index < process.argv.length; index += 2) { + const flag = process.argv[index]; + const value = process.argv[index + 1]; + if (!value || !['--arch', '--out-dir'].includes(flag)) { + throw new Error( + 'Usage: build-windows-process-supervisor.mjs [--arch x64|arm64] [--out-dir DIR]' + ); + } + if (flag === '--arch') options.arch = value; + else options.outDir = path.resolve(value); +} +if (process.platform !== 'win32') + throw new Error('The Windows supervisor must be built on Windows.'); +if (!['x64', 'arm64'].includes(options.arch)) + throw new Error('Supported Windows architectures: x64, arm64.'); + +function newestDirectory(root, predicate = () => true) { + if (!fs.existsSync(root)) throw new Error(`Required build directory is missing: ${root}`); + const directories = fs + .readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && /^\d+(?:\.\d+)*$/.test(entry.name)) + .map((entry) => path.join(root, entry.name)) + .filter(predicate) + .sort((left, right) => + path.basename(right).localeCompare(path.basename(left), undefined, { numeric: true }) + ); + if (!directories[0]) throw new Error(`No compatible build tools found in ${root}`); + return directories[0]; +} + +const programFilesX86 = process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)'; +const vswhere = path.join(programFilesX86, 'Microsoft Visual Studio', 'Installer', 'vswhere.exe'); +const discovery = spawnSync( + vswhere, + [ + '-latest', + '-products', + '*', + '-requires', + 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64', + '-property', + 'installationPath', + ], + { encoding: 'utf8', windowsHide: true, timeout: 15_000 } +); +if (discovery.error || discovery.status !== 0 || !discovery.stdout.trim()) { + throw new Error('Visual Studio C++ build tools were not found.'); +} +const visualStudio = discovery.stdout.trim(); +const toolset = newestDirectory(path.join(visualStudio, 'VC', 'Tools', 'MSVC')); +const host = process.arch === 'arm64' ? 'Hostarm64' : 'Hostx64'; +const compiler = path.join(toolset, 'bin', host, options.arch, 'cl.exe'); +if (!fs.existsSync(compiler)) + throw new Error(`Visual Studio C++ ${options.arch} compiler is not installed.`); +const sdkRoot = path.join(programFilesX86, 'Windows Kits', '10'); +const sdkInclude = newestDirectory( + path.join(sdkRoot, 'Include'), + (directory) => + fs.existsSync(path.join(directory, 'um', 'Windows.h')) && + fs.existsSync( + path.join(sdkRoot, 'Lib', path.basename(directory), 'um', options.arch, 'kernel32.lib') + ) +); +const sdkVersion = path.basename(sdkInclude); +const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === 'path') ?? 'PATH'; +const buildEnv = { + ...process.env, + [pathKey]: [ + path.dirname(compiler), + path.join(sdkRoot, 'bin', sdkVersion, process.arch), + process.env[pathKey] ?? '', + ].join(path.delimiter), + INCLUDE: [ + path.join(toolset, 'include'), + ...['ucrt', 'shared', 'um'].map((part) => path.join(sdkInclude, part)), + ].join(';'), + LIB: [ + path.join(toolset, 'lib', options.arch), + ...['ucrt', 'um'].map((part) => path.join(sdkRoot, 'Lib', sdkVersion, part, options.arch)), + ].join(';'), +}; +const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'lody-supervisor-build-')); +try { + const binaryName = `windows-process-supervisor-win32-${options.arch}.exe`; + const binary = path.join(scratch, binaryName); + const result = spawnSync( + compiler, + [ + '/nologo', + '/O2', + '/MT', + '/std:c++17', + '/EHsc', + '/W4', + '/WX', + '/utf-8', + '/DUNICODE', + '/D_UNICODE', + '/D_WIN32_WINNT=0x0A00', + `/Fe:${binary}`, + `/Fo:${path.join(scratch, 'supervisor.obj')}`, + path.join(cliRoot, 'native', 'windows-process-supervisor.cpp'), + '/link', + '/SUBSYSTEM:CONSOLE', + '/DYNAMICBASE', + '/NXCOMPAT', + ], + { cwd: scratch, env: buildEnv, encoding: 'utf8', windowsHide: true, timeout: 120_000 } + ); + if (result.error || result.status !== 0) { + throw new Error( + `Windows supervisor compilation failed:\n${result.stdout ?? ''}${result.stderr ?? ''}` + ); + } + fs.mkdirSync(options.outDir, { recursive: true }); + fs.copyFileSync(binary, path.join(options.outDir, binaryName)); + console.log(`Built Windows process supervisor (${options.arch}).`); +} finally { + const relative = path.relative(os.tmpdir(), scratch); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error('Refusing cleanup outside the build scratch directory.'); + } + fs.rmSync(scratch, { recursive: true, force: true }); +} diff --git a/apps/cli/scripts/dev-build.mjs b/apps/cli/scripts/dev-build.mjs index eddbef688..0c84c3a9a 100644 Binary files a/apps/cli/scripts/dev-build.mjs and b/apps/cli/scripts/dev-build.mjs differ diff --git a/apps/cli/scripts/probe-windows-supervisor.mjs b/apps/cli/scripts/probe-windows-supervisor.mjs new file mode 100644 index 000000000..89249812c --- /dev/null +++ b/apps/cli/scripts/probe-windows-supervisor.mjs @@ -0,0 +1,126 @@ +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { Duplex } from 'node:stream'; +import { + assertWindowsSupervisorArtifacts, + windowsSupervisorName, +} from './windows-supervisor-artifacts.mjs'; + +/** Exercise the packaged native job, launcher, split chunks, and Node-mode environment. */ +export async function probeWindowsSupervisor({ directory, runtimePath }) { + if (process.platform !== 'win32') throw new Error('Windows supervisor probe requires Windows'); + assertWindowsSupervisorArtifacts({ directory, architectures: [process.arch] }); + const marker = 'lody-windows-supervisor-probe-ok'; + const source = `if(process.env.ELECTRON_RUN_AS_NODE!=='1'||process.env.LODY_WINDOWS_TARGET_NODE_MODE!==undefined)process.exitCode=1;else process.stdout.write(${JSON.stringify(marker)});`; + await new Promise((resolve, reject) => { + const child = spawn( + path.join(directory, windowsSupervisorName(process.arch)), + [ + '--owner-pid', + String(process.pid), + '--', + path.resolve(runtimePath), + path.join(directory, 'windows-process-launcher.js'), + path.resolve(runtimePath), + '-e', + source, + ], + { + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe', 'pipe'], + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + LODY_WINDOWS_TARGET_NODE_MODE: JSON.stringify('1'), + }, + } + ); + const control = child.stdio[3]; + let state = 'ready'; + let buffer = ''; + let stdout = ''; + let stderrBytes = 0; + let settled = false; + const timer = setTimeout(() => fail('deadline exceeded'), 15_000); + function fail(reason) { + if (settled) return; + settled = true; + clearTimeout(timer); + // fd3 withdrawal and termination address only this spawned supervisor; + // its private job owns teardown of the launcher and synthetic target. + control?.destroy(); + try { + child.kill(); + } catch { + /* Preserve the probe failure. */ + } + child.stdout?.destroy(); + child.stderr?.destroy(); + reject(new Error(`[windows-supervisor-smoke] ${reason}`)); + } + child.on('error', () => fail('supervisor failed to launch')); + child.once('close', (code, signal) => { + if (settled) return; + if (code !== 0 || signal !== null || state !== 'complete' || stdout !== marker) { + fail('owned launcher did not complete the synthetic Node target'); + return; + } + settled = true; + clearTimeout(timer); + control?.destroy(); + resolve(); + }); + child.stdout?.on('data', (chunk) => { + if (stdout.length + chunk.length > 4096) { + fail('unexpected target output'); + return; + } + stdout += chunk.toString('utf8'); + }); + child.stderr?.on('data', (chunk) => { + stderrBytes += chunk.length; + if (stderrBytes > 4096) fail('unexpected target diagnostics'); + }); + if (!(control instanceof Duplex)) { + fail('control channel unavailable'); + return; + } + control.on('error', () => fail('control channel failed')); + control.on('data', (chunk) => { + if (buffer.length + chunk.length > 4096) { + fail('invalid control response'); + return; + } + buffer += chunk.toString('utf8'); + let newline; + while ((newline = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + let message; + try { + message = JSON.parse(line); + } catch { + fail('invalid control response'); + return; + } + if (state === 'ready' && message?.type === 'ready' && message.protocol === 1) { + state = 'prepared'; + control.write('start\n'); + } else if ( + state === 'prepared' && + message?.type === 'prepared' && + Number.isSafeInteger(message.pid) && + message.pid > 0 + ) { + state = 'started'; + control.write('resume\n'); + } else if (state === 'started' && message?.type === 'started') { + state = 'complete'; + } else { + fail('unexpected control response'); + return; + } + } + }); + }); +} diff --git a/apps/cli/scripts/windows-supervisor-artifacts.mjs b/apps/cli/scripts/windows-supervisor-artifacts.mjs new file mode 100644 index 000000000..b79095a3b --- /dev/null +++ b/apps/cli/scripts/windows-supervisor-artifacts.mjs @@ -0,0 +1,113 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +export const windowsSupervisorArchitectures = ['x64', 'arm64']; +const machines = { x64: 0x8664, arm64: 0xaa64 }; +const cliRoot = fileURLToPath(new URL('../', import.meta.url)); + +export function windowsSupervisorName(arch) { + if (!windowsSupervisorArchitectures.includes(arch)) { + throw new Error(`Unsupported Windows supervisor architecture: ${arch}`); + } + return `windows-process-supervisor-win32-${arch}.exe`; +} + +export function assertWindowsSupervisorArtifacts({ directory, architectures }) { + const launcher = path.join(directory, 'windows-process-launcher.js'); + if (!fs.existsSync(launcher) || !fs.statSync(launcher).isFile()) { + throw new Error(`Missing flat Windows process launcher: ${launcher}. Build the CLI first.`); + } + for (const arch of architectures) { + const binary = path.join(directory, windowsSupervisorName(arch)); + if (!fs.existsSync(binary)) { + throw new Error( + `Missing Windows supervisor: ${binary}. Build or download the ${arch} CI artifact before packaging.` + ); + } + const bytes = fs.readFileSync(binary); + const peOffset = bytes.length >= 64 ? bytes.readUInt32LE(0x3c) : 0; + if ( + bytes.length < 64 || + bytes.readUInt16LE(0) !== 0x5a4d || + peOffset < 64 || + peOffset > bytes.length - 24 || + bytes.readUInt32LE(peOffset) !== 0x00004550 || + bytes.readUInt16LE(peOffset + 4) !== machines[arch] + ) { + throw new Error(`Invalid Windows supervisor PE architecture (expected ${arch}): ${binary}`); + } + const sectionCount = bytes.readUInt16LE(peOffset + 6); + const optionalSize = bytes.readUInt16LE(peOffset + 20); + const sectionTable = peOffset + 24 + optionalSize; + if ( + sectionCount === 0 || + optionalSize < 112 || + sectionTable + sectionCount * 40 > bytes.length || + bytes.readUInt16LE(peOffset + 24) !== 0x20b || + (bytes.readUInt16LE(peOffset + 22) & 0x0002) === 0 + ) { + throw new Error(`Invalid Windows supervisor PE executable: ${binary}`); + } + for (let index = 0; index < sectionCount; index += 1) { + const section = sectionTable + index * 40; + const size = bytes.readUInt32LE(section + 16); + const offset = bytes.readUInt32LE(section + 20); + if (offset + size > bytes.length) { + throw new Error(`Truncated Windows supervisor PE section: ${binary}`); + } + } + } +} + +export function buildWindowsSupervisor({ + directory, + arch = process.arch, + platform = process.platform, +}) { + if (platform !== 'win32') return; + windowsSupervisorName(arch); + const result = spawnSync( + process.execPath, + [ + path.join(cliRoot, 'scripts', 'build-windows-process-supervisor.mjs'), + '--arch', + arch, + '--out-dir', + directory, + ], + { stdio: 'inherit', windowsHide: true, timeout: 150_000 } + ); + if (result.error || result.status !== 0) { + throw new Error( + `Windows supervisor build failed for ${arch}: ${result.error?.message ?? `exit ${result.status}`}` + ); + } + assertWindowsSupervisorArtifacts({ directory, architectures: [arch] }); +} + +export function stageWindowsSupervisor({ sourceDirectory, destinationDirectory, arch, platform }) { + if (platform !== 'win32') return; + // Windows hosts can cross-compile with the installed target toolchain. Other + // hosts must ingest the corresponding CI artifact; never ship a host binary. + buildWindowsSupervisor({ directory: sourceDirectory, arch }); + assertWindowsSupervisorArtifacts({ directory: sourceDirectory, architectures: [arch] }); + fs.mkdirSync(destinationDirectory, { recursive: true }); + fs.copyFileSync( + path.join(sourceDirectory, windowsSupervisorName(arch)), + path.join(destinationDirectory, windowsSupervisorName(arch)) + ); + assertWindowsSupervisorArtifacts({ directory: destinationDirectory, architectures: [arch] }); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const [command, ...extra] = process.argv.slice(2); + if (command !== 'verify-package' || extra.length !== 0) { + throw new Error('Usage: windows-supervisor-artifacts.mjs verify-package'); + } + assertWindowsSupervisorArtifacts({ + directory: path.join(cliRoot, 'dist'), + architectures: windowsSupervisorArchitectures, + }); +} diff --git a/apps/cli/scripts/windows-supervisor-artifacts.test.mjs b/apps/cli/scripts/windows-supervisor-artifacts.test.mjs new file mode 100644 index 000000000..5d5d4ea41 --- /dev/null +++ b/apps/cli/scripts/windows-supervisor-artifacts.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { + assertWindowsSupervisorArtifacts, + buildWindowsSupervisor, + windowsSupervisorName, +} from './windows-supervisor-artifacts.mjs'; + +function fixture(t) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'lody-supervisor-artifacts-')); + t.after(() => { + const relative = path.relative(os.tmpdir(), directory); + assert.ok(relative && !relative.startsWith('..') && !path.isAbsolute(relative)); + fs.rmSync(directory, { recursive: true, force: true }); + }); + fs.writeFileSync(path.join(directory, 'windows-process-launcher.js'), 'export {};'); + return directory; +} + +function writePe(directory, arch, machine = arch === 'x64' ? 0x8664 : 0xaa64) { + const bytes = Buffer.alloc(512); + bytes.writeUInt16LE(0x5a4d, 0); + bytes.writeUInt32LE(128, 0x3c); + bytes.writeUInt32LE(0x00004550, 128); + bytes.writeUInt16LE(machine, 132); + bytes.writeUInt16LE(1, 134); + bytes.writeUInt16LE(112, 148); + bytes.writeUInt16LE(2, 150); + bytes.writeUInt16LE(0x20b, 152); + fs.writeFileSync(path.join(directory, windowsSupervisorName(arch)), bytes); +} + +test('universal package requires independently matching x64 and ARM64 images', (t) => { + const directory = fixture(t); + writePe(directory, 'x64'); + assert.throws( + () => assertWindowsSupervisorArtifacts({ directory, architectures: ['x64', 'arm64'] }), + /Missing Windows supervisor/ + ); + writePe(directory, 'arm64', 0x8664); + assert.throws( + () => assertWindowsSupervisorArtifacts({ directory, architectures: ['x64', 'arm64'] }), + /expected arm64/ + ); + writePe(directory, 'arm64'); + assertWindowsSupervisorArtifacts({ directory, architectures: ['x64', 'arm64'] }); +}); + +test('a launcher hidden in chunks does not satisfy the flat runtime layout', (t) => { + const directory = fixture(t); + writePe(directory, 'x64'); + fs.mkdirSync(path.join(directory, 'chunks')); + fs.renameSync( + path.join(directory, 'windows-process-launcher.js'), + path.join(directory, 'chunks', 'windows-process-launcher.js') + ); + assert.throws( + () => assertWindowsSupervisorArtifacts({ directory, architectures: ['x64'] }), + /Missing flat Windows process launcher/ + ); +}); + +test('truncated and malformed PE files fail before packaging', (t) => { + const directory = fixture(t); + const binary = path.join(directory, windowsSupervisorName('x64')); + for (const bytes of [Buffer.alloc(0), Buffer.alloc(63), Buffer.alloc(100)]) { + fs.writeFileSync(binary, bytes); + assert.throws( + () => assertWindowsSupervisorArtifacts({ directory, architectures: ['x64'] }), + /Invalid Windows supervisor PE/ + ); + } + writePe(directory, 'x64'); + const corrupt = fs.readFileSync(binary); + corrupt.writeUInt32LE(0xffffffff, 0x3c); + fs.writeFileSync(binary, corrupt); + assert.throws( + () => assertWindowsSupervisorArtifacts({ directory, architectures: ['x64'] }), + /Invalid Windows supervisor PE/ + ); +}); + +test('Mac and Linux JS builds do not invoke a Windows compiler', () => { + for (const platform of ['darwin', 'linux']) { + buildWindowsSupervisor({ directory: 'not-created', platform }); + } + assert.throws(() => windowsSupervisorName('ia32'), /Unsupported Windows supervisor architecture/); +}); + +test('PE headers and section extents must describe a complete 64-bit executable', (t) => { + const directory = fixture(t); + const binary = path.join(directory, windowsSupervisorName('x64')); + for (const corrupt of [ + (bytes) => bytes.writeUInt16LE(0, 134), + (bytes) => bytes.writeUInt16LE(0x10b, 152), + (bytes) => bytes.writeUInt16LE(0, 150), + (bytes) => bytes.writeUInt32LE(1024, 128 + 24 + 112 + 16), + ]) { + writePe(directory, 'x64'); + const bytes = fs.readFileSync(binary); + corrupt(bytes); + fs.writeFileSync(binary, bytes); + assert.throws( + () => assertWindowsSupervisorArtifacts({ directory, architectures: ['x64'] }), + /Windows supervisor PE/ + ); + } +}); diff --git a/apps/cli/src/agent/acp-authentication.ts b/apps/cli/src/agent/acp-authentication.ts index 4486c6ee6..4bd477320 100644 --- a/apps/cli/src/agent/acp-authentication.ts +++ b/apps/cli/src/agent/acp-authentication.ts @@ -1,3 +1,4 @@ +import { spawnOwnedProcess } from '@/utils/windows-owned-process'; import type { ChildProcess } from 'child_process'; import os from 'os'; import spawn from 'cross-spawn'; @@ -428,7 +429,7 @@ export async function probeBuiltinAuthentication( if (hasBuiltinEnvAuthentication(options.agentType, env)) { return { status: 'unknown' }; } - const child = (options.spawnProcess ?? spawn)(launch.command, launch.args, { + const child = (options.spawnProcess ?? spawnOwnedProcess)(launch.command, launch.args, { cwd: os.homedir(), env, stdio: 'ignore', @@ -524,7 +525,7 @@ export class AcpAuthenticationManager { 1, options.terminationGraceMs ?? DEFAULT_TERMINATION_GRACE_MS ); - this.spawnProcess = options.spawnProcess ?? spawn; + this.spawnProcess = options.spawnProcess ?? spawnOwnedProcess; this.resolveLoginShellEnv = options.resolveLoginShellEnv ?? getLoginShellEnv; } diff --git a/apps/cli/src/agent/acp-runner.ts b/apps/cli/src/agent/acp-runner.ts index 534222b17..444183693 100644 --- a/apps/cli/src/agent/acp-runner.ts +++ b/apps/cli/src/agent/acp-runner.ts @@ -1,3 +1,4 @@ +import { spawnOwnedProcess } from '@/utils/windows-owned-process'; import spawn from 'cross-spawn'; import { type ChildProcess } from 'child_process'; import os from 'os'; @@ -291,7 +292,7 @@ export const spawnAcpProcess = (options: SpawnAcpProcessOptions): ChildProcess = command = command ?? launch.command; args = args ?? launch.args; } - const spawnFn = options.spawnImpl ?? spawn; + const spawnFn = options.spawnImpl ?? spawnOwnedProcess; return spawnFn(command, args, { cwd: options.workdir, diff --git a/apps/cli/src/session/session-sandbox.ts b/apps/cli/src/session/session-sandbox.ts index 9a8aca131..3c842dddf 100644 --- a/apps/cli/src/session/session-sandbox.ts +++ b/apps/cli/src/session/session-sandbox.ts @@ -1,3 +1,4 @@ +import { spawnOwnedProcess } from '@/utils/windows-owned-process'; import { ChildProcess, type SpawnOptions } from 'child_process'; import * as fs from 'fs/promises'; import path from 'path'; @@ -151,7 +152,7 @@ const defaultSandboxDeps = (): SessionSandboxDeps => ({ platform: process.platform, cgroupMount: DEFAULT_CGROUP_MOUNT, fs, - spawnProcess: spawn, + spawnProcess: spawnOwnedProcess, readSelfCgroupPath: async () => { const content = (await fs.readFile('/proc/self/cgroup', 'utf8')) as string; const line = content @@ -236,7 +237,7 @@ export function createSessionResourceLimitError( } export function createNoopSessionSandbox( - spawnProcess: typeof spawn = spawn, + spawnProcess: typeof spawn = spawnOwnedProcess, description: string = 'noop' ): SessionSandbox { return new NoopSessionSandbox( diff --git a/apps/cli/src/utils/windows-owned-process.integration.test.ts b/apps/cli/src/utils/windows-owned-process.integration.test.ts new file mode 100644 index 000000000..bf2a3f2d5 --- /dev/null +++ b/apps/cli/src/utils/windows-owned-process.integration.test.ts @@ -0,0 +1,234 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn, spawnSync } from 'node:child_process'; +import { once } from 'node:events'; +import { createInterface } from 'node:readline'; +import { z } from 'zod'; +import { terminateWindowsChildProcess } from './windows-child-process'; +import { build } from 'esbuild'; +import { spawnWindowsOwnedProcess } from './windows-owned-process'; + +const suite = + process.platform === 'win32' && process.env.LODY_WINDOWS_SUPERVISOR_INTEGRATION === '1' + ? describe + : describe.skip; +suite('Windows owned launcher integration', () => { + let directory: string; + let launcherPath: string; + let supervisorPath: string; + let target: string; + beforeAll(async () => { + directory = mkdtempSync(path.join(os.tmpdir(), 'lody-owned-launch-')); + launcherPath = path.join(directory, 'launcher.cjs'); + supervisorPath = path.join(directory, `windows-process-supervisor-win32-${process.arch}.exe`); + const compiled = spawnSync( + process.execPath, + ['scripts/build-windows-process-supervisor.mjs', '--out-dir', directory], + { encoding: 'utf8', windowsHide: true } + ); + if (compiled.status !== 0) throw new Error('Native supervisor test build failed'); + await build({ + entryPoints: ['src/windows-process-launcher.ts'], + outfile: launcherPath, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent', + }); + target = path.join(directory, 'target with spaces.cjs'); + writeFileSync( + target, + `let input='';process.stdin.setEncoding('utf8');process.stdin.on('data',c=>input+=c);process.stdin.on('end',()=>{process.stdout.write(JSON.stringify({args:process.argv.slice(2),input,mode:process.env.ELECTRON_RUN_AS_NODE??null}));process.stderr.write('target stderr');process.exitCode=7;});` + ); + }, 180_000); + afterAll(() => { + if ( + directory && + path.dirname(directory) === os.tmpdir() && + path.basename(directory).startsWith('lody-owned-launch-') + ) + rmSync(directory, { recursive: true, force: true }); + }); + async function run(command: string, args: string[], mode?: string, pathDirectory?: string) { + const env = { ...process.env }; + delete env.ELECTRON_RUN_AS_NODE; + if (mode !== undefined) env.ELECTRON_RUN_AS_NODE = mode; + if (pathDirectory) { + const pathKey = Object.keys(env).find((key) => key.toLowerCase() === 'path') ?? 'PATH'; + env[pathKey] = [pathDirectory, env[pathKey] ?? ''].join(path.delimiter); + } + const child = spawnWindowsOwnedProcess( + command, + args, + { cwd: directory, env, stdio: 'pipe' }, + { launcherPath, supervisorPath } + ); + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString(); + }); + const closed = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', resolve); + }); + child.stdin?.end('stdin snow 雪'); + const code = await closed; + return { code, stdout, stderr }; + } + it('preserves native argv, pipes, exit code and target environment', async () => { + const args = ['space value', '雪', '& echo injected', 'quote"value']; + const result = await run(process.execPath, [target, ...args]); + expect(result.code).toBe(7); + expect(JSON.parse(result.stdout)).toEqual({ args, input: 'stdin snow 雪', mode: null }); + expect(result.stderr).toBe('target stderr'); + }, 15_000); + it('preserves cmd launcher argument escaping and existing Node mode', async () => { + const cmd = path.join(directory, 'target command.cmd'); + writeFileSync(cmd, `@"${process.execPath}" "${target}" %*\r\n`); + const args = ['space value', '雪', '& echo injected']; + const result = await run(cmd, args, '1'); + expect(result.code).toBe(7); + expect(JSON.parse(result.stdout)).toEqual({ args, input: 'stdin snow 雪', mode: '1' }); + expect(result.stderr).toBe('target stderr'); + }, 15_000); + it('resolves a bare PATH command inside the owned Node launcher', async () => { + const bin = mkdtempSync(path.join(directory, 'path-only-')); + writeFileSync( + path.join(bin, 'lody-owned-path-test.cmd'), + `@"${process.execPath}" "${target}" %*\r\n` + ); + const args = ['space value', '雪', '& echo injected']; + const result = await run('lody-owned-path-test', args, undefined, bin); + expect(result.code).toBe(7); + expect(JSON.parse(result.stdout)).toEqual({ args, input: 'stdin snow 雪', mode: null }); + expect(result.stderr).toBe('target stderr'); + }, 15_000); + it('reports target spawn failure without unowned fallback', async () => { + const result = await run(path.join(directory, 'missing-target.exe'), []); + expect(result.code).toBe(125); + }, 15_000); + it('terminates the owned job through the retained child handle without consulting its PID', async () => { + const source = String.raw` + const { spawn } = require('node:child_process'); + function descendant(depth) { + const { spawn } = require('node:child_process'); + setTimeout(() => process.exit(90), 60000); + if (depth === 0) { process.send([process.pid]); return; } + const child = spawn(process.execPath, ['-e', '(' + descendant.toString() + ')(' + (depth - 1) + ')'], { + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], windowsHide: true, detached: true, + }); + child.once('message', (pids) => process.send([process.pid, ...pids])); + child.once('error', () => process.exit(91)); + } + const child = spawn(process.execPath, ['-e', '(' + descendant.toString() + ')(1)'], { + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], windowsHide: true, detached: true, + }); + child.once('message', (pids) => process.stdout.write(JSON.stringify([process.ppid, process.pid, ...pids]) + '\n')); + child.once('error', () => process.exit(91)); + setTimeout(() => process.exit(90), 60000); + `; + const child = spawnWindowsOwnedProcess( + process.execPath, + ['-e', source], + { stdio: 'pipe' }, + { launcherPath, supervisorPath } + ); + const supervisorPid = z.number().int().positive().parse(child.pid); + const closed = once(child, 'close', { signal: AbortSignal.timeout(30_000) }); + void closed.catch(() => {}); + let observer: ReturnType | undefined; + let observerClosed: Promise | undefined; + let outputLines: ReturnType | undefined; + let observerLines: ReturnType | undefined; + try { + if (!child.stdout) throw new Error('Owned fixture stdout unavailable'); + outputLines = createInterface({ input: child.stdout }); + const [line] = await once(outputLines, 'line', { signal: AbortSignal.timeout(10_000) }); + const pids = [ + supervisorPid, + ...z + .array(z.number().int().positive()) + .length(4) + .parse(JSON.parse(String(line))), + ]; + expect(new Set(pids).size).toBe(5); + // Pin independent handles while every synthetic process is still alive. + // Detached descendants remain alive after IPC disconnect, so killing only + // the supervisor cannot satisfy this assertion without Job Object teardown. + const script = String.raw` + $ErrorActionPreference = 'Stop' + $owned = @() + try { + foreach ($processId in @(${pids.join(',')})) { + $item = [System.Diagnostics.Process]::GetProcessById($processId) + $null = $item.Handle + $owned += $item + if ($item.HasExited) { throw 'Fixture exited before observation' } + } + [Console]::WriteLine('handles-ready') + $command = [Console]::In.ReadLineAsync() + if (-not $command.Wait(15000) -or $command.Result -ne 'verify') { throw 'Observation canceled' } + foreach ($item in $owned) { + if (-not $item.WaitForExit(5000)) { throw 'Owned process survived' } + if ($item.ExitCode -eq 90) { throw 'Fixture watchdog fired' } + } + [Console]::WriteLine('all-five-exited') + } finally { + [Array]::Reverse($owned) + $cleanupFailed = $false + foreach ($item in $owned) { + try { + if (-not $item.HasExited) { $item.Kill() } + if (-not $item.WaitForExit(5000)) { $cleanupFailed = $true } + } catch { $cleanupFailed = $true } finally { $item.Dispose() } + } + if ($cleanupFailed) { throw 'Owned fixture cleanup failed' } + } + `; + observer = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + observerClosed = once(observer, 'close', { signal: AbortSignal.timeout(25_000) }); + void observerClosed.catch(() => {}); + if (!observer.stdout || !observer.stdin) throw new Error('Observer pipes unavailable'); + let output = ''; + let errors = ''; + observer.stdout.on('data', (chunk: Buffer) => { + output += chunk.toString(); + }); + observer.stderr?.on('data', (chunk: Buffer) => { + errors += chunk.toString(); + }); + observerLines = createInterface({ input: observer.stdout }); + const [ready] = await once(observerLines, 'line', { signal: AbortSignal.timeout(10_000) }); + expect(ready).toBe('handles-ready'); + Object.defineProperty(child, 'pid', { + get: () => { + throw new Error('Cached supervisor PID was read'); + }, + }); + await terminateWindowsChildProcess(child, true); + observer.stdin.end('verify\n'); + expect(await observerClosed, errors).toEqual([0, null]); + expect(output).toContain('all-five-exited'); + await closed; + } finally { + observer?.stdin?.end(); + try { + if (observerClosed) await observerClosed; + } finally { + observerLines?.close(); + outputLines?.close(); + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await closed; + } + } + }, 40_000); +}); diff --git a/apps/cli/src/utils/windows-owned-process.test.ts b/apps/cli/src/utils/windows-owned-process.test.ts new file mode 100644 index 000000000..d170271a8 --- /dev/null +++ b/apps/cli/src/utils/windows-owned-process.test.ts @@ -0,0 +1,133 @@ +import { EventEmitter } from 'node:events'; +import { Duplex } from 'node:stream'; +import type { ChildProcess } from 'node:child_process'; +import spawn from 'cross-spawn'; +import { afterEach, expect, it, vi } from 'vitest'; +import { + spawnWindowsOwnedProcess, + spawnOwnedProcess, + WINDOWS_TARGET_NODE_MODE, + windowsOwnershipRuntimeRoot, +} from './windows-owned-process'; + +afterEach(() => vi.useRealTimers()); +function fixture() { + const writes: string[] = []; + const control = new Duplex({ + read() {}, + write(chunk, _encoding, callback) { + writes.push(chunk.toString()); + callback(); + }, + }); + const child = Object.assign(new EventEmitter(), { + stdio: [null, null, null, control], + exitCode: null, + signalCode: null, + kill: vi.fn(), + }) as unknown as ChildProcess; + const spawnProcess = Object.assign( + vi.fn(() => child), + { spawn: spawn.spawn, sync: spawn.sync } + ); + const errors: Error[] = []; + child.on('error', (error) => errors.push(error)); + const deps = { + spawnProcess, + exists: () => true, + supervisorPath: 'supervisor.exe', + launcherPath: 'launcher.js', + timeoutMs: 25, + }; + return { child, control, writes, deps, errors }; +} + +it('requires packaged ownership assets and never launches the target as fallback', () => { + const f = fixture(); + expect(() => + spawnWindowsOwnedProcess('target', [], {}, { ...f.deps, exists: () => false }) + ).toThrow('unavailable'); + expect(f.deps.spawnProcess).not.toHaveBeenCalled(); +}); + +it('preserves target arguments and uses fd3 without passing it to the target', async () => { + vi.useFakeTimers(); + const f = fixture(); + spawnWindowsOwnedProcess( + 'a file.cmd', + ['space value', '雪', '& echo bad'], + { env: { TEST: 'value' }, stdio: 'pipe' }, + f.deps + ); + expect(f.deps.spawnProcess).toHaveBeenCalledWith( + 'supervisor.exe', + [ + '--owner-pid', + String(process.pid), + '--', + process.execPath, + 'launcher.js', + 'a file.cmd', + 'space value', + '雪', + '& echo bad', + ], + expect.objectContaining({ + windowsHide: true, + detached: false, + stdio: ['pipe', 'pipe', 'pipe', 'pipe'], + env: { TEST: 'value', ELECTRON_RUN_AS_NODE: '1', [WINDOWS_TARGET_NODE_MODE]: 'null' }, + }) + ); + f.control.push('{"type":"ready","protocol":1}\n'); + await vi.advanceTimersByTimeAsync(0); + expect(f.writes).toEqual(['start\n']); + f.control.push('{"type":"prepared","pid":42}\n'); + await vi.advanceTimersByTimeAsync(0); + expect(f.writes).toEqual(['start\n', 'resume\n']); + f.control.push('{"type":"started"}\n'); + await vi.advanceTimersByTimeAsync(0); + expect(vi.getTimerCount()).toBe(0); + expect(f.control.destroyed).toBe(false); + f.child.emit('exit', 0, null); + expect(f.control.destroyed).toBe(true); + expect(f.errors).toEqual([]); +}); + +it.each(['{"type":"prepared","pid":42}\n', 'bad\n', 'x'.repeat(4097)])( + 'withdraws ownership on malformed protocol', + async (message) => { + vi.useFakeTimers(); + const f = fixture(); + spawnWindowsOwnedProcess('target', [], {}, f.deps); + f.control.push(message); + await vi.advanceTimersByTimeAsync(0); + expect(f.errors).toHaveLength(1); + expect(f.control.destroyed).toBe(true); + expect(vi.getTimerCount()).toBe(0); + } +); + +it('withdraws ownership when startup never acknowledges', async () => { + vi.useFakeTimers(); + const f = fixture(); + spawnWindowsOwnedProcess('target', [], {}, f.deps); + await vi.advanceTimersByTimeAsync(25); + expect(f.errors).toHaveLength(1); + expect(f.control.destroyed).toBe(true); + expect(vi.getTimerCount()).toBe(0); +}); + +it('resolves packaged flat and shared-chunk modules to the same asset root', () => { + expect(windowsOwnershipRuntimeRoot('file:///C:/app/cli/index.js').href).toBe( + 'file:///C:/app/cli/' + ); + expect(windowsOwnershipRuntimeRoot('file:///C:/app/cli/chunks/shared.js').href).toBe( + 'file:///C:/app/cli/' + ); +}); + +it('does not expose unowned alternate spawn entrypoints', () => { + expect(() => spawnOwnedProcess.spawn('target')).toThrow('callable owned process'); + expect(() => spawnOwnedProcess.sync('target')).toThrow('unsupported'); +}); diff --git a/apps/cli/src/utils/windows-owned-process.ts b/apps/cli/src/utils/windows-owned-process.ts new file mode 100644 index 000000000..47a2bff3a --- /dev/null +++ b/apps/cli/src/utils/windows-owned-process.ts @@ -0,0 +1,185 @@ +import type { ChildProcess, SpawnOptions, StdioOptions } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { Duplex } from 'node:stream'; +import spawn from 'cross-spawn'; + +export const WINDOWS_TARGET_NODE_MODE = 'LODY_WINDOWS_TARGET_NODE_MODE'; + +/** Both production and development bundles place shared chunks one level below assets. */ +export function windowsOwnershipRuntimeRoot(moduleUrl: string): URL { + const directory = new URL('.', moduleUrl); + return directory.pathname.endsWith('/chunks/') ? new URL('../', directory) : directory; +} + +export interface WindowsOwnedProcessDependencies { + spawnProcess?: typeof spawn; + supervisorPath?: string; + launcherPath?: string; + exists?: (path: string) => boolean; + timeoutMs?: number; +} + +/** The returned process is the job supervisor; its exit releases the entire job. */ +export function spawnWindowsOwnedProcess( + command: string, + args: readonly string[], + options: SpawnOptions, + deps: WindowsOwnedProcessDependencies = {} +): ChildProcess { + const runtimeRoot = windowsOwnershipRuntimeRoot(import.meta.url); + const supervisorPath = + deps.supervisorPath ?? + fileURLToPath(new URL(`windows-process-supervisor-win32-${process.arch}.exe`, runtimeRoot)); + const launcherPath = + deps.launcherPath ?? fileURLToPath(new URL('windows-process-launcher.js', runtimeRoot)); + const exists = deps.exists ?? existsSync; + if (!exists(supervisorPath) || !exists(launcherPath)) + throw new Error('Windows process ownership runtime is unavailable'); + if (options.shell || options.windowsVerbatimArguments) + throw new Error('Owned Windows process launch requires structured arguments'); + const selected = options.stdio ?? 'pipe'; + const stdio: StdioOptions = + typeof selected === 'string' ? [selected, selected, selected] : [...selected]; + if (stdio.length > 3) + throw new Error('Owned Windows processes support only standard input/output/error'); + while (stdio.length < 3) stdio.push('pipe'); + stdio.push('pipe'); + const targetEnv = options.env ?? process.env; + const child = (deps.spawnProcess ?? spawn)( + supervisorPath, + ['--owner-pid', String(process.pid), '--', process.execPath, launcherPath, command, ...args], + { + ...options, + shell: false, + detached: false, + windowsHide: true, + stdio, + env: { + ...targetEnv, + [WINDOWS_TARGET_NODE_MODE]: JSON.stringify(targetEnv.ELECTRON_RUN_AS_NODE ?? null), + ELECTRON_RUN_AS_NODE: '1', + }, + } + ); + const control = child.stdio[3]; + if (!(control instanceof Duplex)) { + child.kill(); + throw new Error('Windows process ownership control channel is unavailable'); + } + let state: 'ready' | 'prepared' | 'started' = 'ready'; + let buffer = ''; + let failed = false; + let established = false; + const finishHandshake = () => { + clearTimeout(timer); + control.off('data', onData); + }; + const cleanup = () => { + finishHandshake(); + control.off('error', onControlError); + control.off('close', onControlClose); + child.off('error', onChildError); + child.off('exit', cleanup); + control.destroy(); + }; + const fail = () => { + if (failed) return; + failed = true; + cleanup(); + // Closing fd3 withdraws authorization; the native supervisor owns job teardown. + child.emit('error', new Error('Windows process ownership handshake failed')); + }; + const onChildError = () => { + failed = true; + cleanup(); + }; + const onControlError = () => fail(); + const onControlClose = () => { + if (!established && child.exitCode == null && child.signalCode == null) fail(); + }; + const onData = (chunk: Buffer) => { + if (Buffer.byteLength(buffer, 'utf8') + chunk.byteLength > 4096) { + fail(); + return; + } + buffer += chunk.toString('utf8'); + if (buffer.length > 4096) { + fail(); + return; + } + let newline: number; + while ((newline = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + let message: unknown; + try { + message = JSON.parse(line); + } catch { + fail(); + return; + } + if (typeof message !== 'object' || message === null || !('type' in message)) { + fail(); + return; + } + if ( + state === 'ready' && + message.type === 'ready' && + 'protocol' in message && + message.protocol === 1 + ) { + state = 'prepared'; + control.write('start\n'); + } else if ( + state === 'prepared' && + message.type === 'prepared' && + 'pid' in message && + typeof message.pid === 'number' && + Number.isSafeInteger(message.pid) && + message.pid > 0 + ) { + state = 'started'; + control.write('resume\n'); + } else if (state === 'started' && message.type === 'started') { + established = true; + finishHandshake(); + return; + } else { + fail(); + return; + } + } + }; + const timer = setTimeout(fail, deps.timeoutMs ?? 10_000); + control.on('data', onData); + control.on('error', onControlError); + control.on('close', onControlClose); + child.on('error', onChildError); + child.once('exit', cleanup); + return child; +} + +function spawnWithOwnership( + command: string, + args?: readonly string[] | SpawnOptions, + options?: SpawnOptions +): ChildProcess { + const argv = Array.isArray(args) ? args : []; + const spawnOptions = Array.isArray(args) + ? (options ?? {}) + : ((args as SpawnOptions | undefined) ?? options ?? {}); + return process.platform === 'win32' + ? spawnWindowsOwnedProcess(command, argv, spawnOptions) + : spawn(command, argv, spawnOptions); +} + +/** Preserve the cross-spawn injection surface used by existing process factories. */ +export const spawnOwnedProcess: typeof spawn = Object.assign(spawnWithOwnership, { + spawn: (): never => { + throw new Error('Use the callable owned process launcher'); + }, + sync: (): never => { + throw new Error('Synchronous owned process launch is unsupported'); + }, +}); diff --git a/apps/cli/src/windows-process-launcher.ts b/apps/cli/src/windows-process-launcher.ts new file mode 100644 index 000000000..2546f205d --- /dev/null +++ b/apps/cli/src/windows-process-launcher.ts @@ -0,0 +1,21 @@ +import spawn from 'cross-spawn'; + +// This launcher runs inside the native supervisor's job before spawning any target. +const env = { ...process.env }; +const marker = env.LODY_WINDOWS_TARGET_NODE_MODE; +delete env.LODY_WINDOWS_TARGET_NODE_MODE; +if (marker !== undefined) { + const original: unknown = JSON.parse(marker); + if (original === null) delete env.ELECTRON_RUN_AS_NODE; + else if (typeof original === 'string') env.ELECTRON_RUN_AS_NODE = original; + else throw new Error('Invalid owned-process environment'); +} +const command = process.argv[2]; +if (!command) throw new Error('Owned-process target command is missing'); +const child = spawn(command, process.argv.slice(3), { stdio: 'inherit', env, windowsHide: true }); +child.on('error', () => { + process.exitCode = 125; +}); +child.on('exit', (code) => { + process.exitCode = code ?? 125; +}); diff --git a/apps/cli/vite.config.ts b/apps/cli/vite.config.ts index 358305f08..c34171438 100644 --- a/apps/cli/vite.config.ts +++ b/apps/cli/vite.config.ts @@ -2,6 +2,7 @@ import { builtinModules } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vite'; +import { buildWindowsSupervisor } from './scripts/windows-supervisor-artifacts.mjs'; import topLevelAwait from 'vite-plugin-top-level-await'; import wasm from 'vite-plugin-wasm'; @@ -37,7 +38,16 @@ const explicitlyExternal = new Set([ ]); export default defineConfig({ - plugins: [wasm(), topLevelAwait()], + plugins: [ + wasm(), + topLevelAwait(), + { + name: 'windows-process-supervisor', + writeBundle() { + buildWindowsSupervisor({ directory: path.resolve(__dirname, 'dist') }); + }, + }, + ], define: inlineEnv, resolve: { alias: { @@ -71,6 +81,7 @@ export default defineConfig({ // better-sqlite3 external, just like the main CLI entry. input: { index: path.resolve(__dirname, 'src/index.ts'), + 'windows-process-launcher': path.resolve(__dirname, 'src/windows-process-launcher.ts'), 'codex-acp': path.resolve(__dirname, 'src/codex-acp-entry.ts'), 'claude-acp': path.resolve(__dirname, 'src/claude-acp-entry.ts'), 'deepseek-acp': path.resolve(__dirname, 'src/deepseek-acp-entry.ts'), diff --git a/apps/electron/AGENTS.md b/apps/electron/AGENTS.md index 4f53849fb..0b0c3888c 100644 --- a/apps/electron/AGENTS.md +++ b/apps/electron/AGENTS.md @@ -128,6 +128,13 @@ Root `AGENTS.md` also applies. ## Embedded CLI and native dependencies +- Windows CLI staging requires a flat `windows-process-launcher.js` and a PE-validated + `windows-process-supervisor-win32-.exe`. `beforePack` builds the exact + target on Windows (including cross-architecture builds) or requires its downloaded + CI artifact in `apps/cli/dist` on other hosts; `afterPack` validates the unpacked + files even when the runtime probe cannot run on that host. Missing target tools or + artifacts fail packaging. Never add end-user compilation or unowned launch fallback. + - The embedded CLI launches built JavaScript only; there is no source-loader/Jiti fallback. Development and packaged builds must use the same output layout. - `better-sqlite3`, `@lydell/node-pty`, and `loro-crdt` remain external and must be diff --git a/apps/electron/scripts/eb-after-pack.mjs b/apps/electron/scripts/eb-after-pack.mjs index a65e92995..5018b97c1 100644 --- a/apps/electron/scripts/eb-after-pack.mjs +++ b/apps/electron/scripts/eb-after-pack.mjs @@ -1,3 +1,5 @@ +import { assertWindowsSupervisorArtifacts } from '../../cli/scripts/windows-supervisor-artifacts.mjs' +import { probeWindowsSupervisor } from '../../cli/scripts/probe-windows-supervisor.mjs' import fs from 'node:fs' import path from 'node:path' import { spawnSync } from 'node:child_process' @@ -105,6 +107,9 @@ export default async function afterPack(context) { throw new Error(`[embedded-cli] missing expected path: ${cliEntry}`) } assertPackagedDeepSeekAssets(packedCliDir) + if (platform === 'win32') { + assertWindowsSupervisorArtifacts({ directory: packedCliDir, architectures: [archName] }) + } // beforePack staged both native bindings for this exact target, so mirror their // staged-relative locations rather than guessing the per-platform file names here. const nativeTarget = { platform: platform === 'mas' ? 'darwin' : platform, arch: archName } @@ -182,6 +187,11 @@ export default async function afterPack(context) { throw new Error(`[embedded-cli-smoke] missing expected runtime path: ${cliRuntimePath}`) } + if (platform === 'win32') { + await probeWindowsSupervisor({ directory: packedCliDir, runtimePath: cliRuntimePath }) + console.log('[embedded-cli-smoke] Windows owned launcher probe passed') + } + const result = spawnSync(cliRuntimePath, [cliEntry, '--help'], { env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, encoding: 'utf8', diff --git a/apps/electron/scripts/eb-before-pack.mjs b/apps/electron/scripts/eb-before-pack.mjs index 0dccf808a..7b3e6c269 100644 --- a/apps/electron/scripts/eb-before-pack.mjs +++ b/apps/electron/scripts/eb-before-pack.mjs @@ -1,3 +1,5 @@ +import { fileURLToPath } from 'node:url' +import { stageWindowsSupervisor } from '../../cli/scripts/windows-supervisor-artifacts.mjs' import { installEmbeddedNodePtyBinding, installEmbeddedSqliteBinding } from './cli-native-deps.mjs' // electron-builder Arch enum (electron-builder/out/index Arch). @@ -18,6 +20,12 @@ export default async function beforePack(context) { `universal builds would need one binding per slice.` ) } + stageWindowsSupervisor({ + sourceDirectory: fileURLToPath(new URL('../../cli/dist/', import.meta.url)), + destinationDirectory: fileURLToPath(new URL('../resources/cli/', import.meta.url)), + platform, + arch: archName + }) installEmbeddedSqliteBinding({ platform, arch: archName }) installEmbeddedNodePtyBinding({ platform, arch: archName }) } diff --git a/apps/electron/scripts/sync-cli-dev-dist.mjs b/apps/electron/scripts/sync-cli-dev-dist.mjs index 5888948f6..8d768d0fb 100644 --- a/apps/electron/scripts/sync-cli-dev-dist.mjs +++ b/apps/electron/scripts/sync-cli-dev-dist.mjs @@ -1,6 +1,7 @@ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { assertWindowsSupervisorArtifacts } from '../../cli/scripts/windows-supervisor-artifacts.mjs' import { installEmbeddedNodePtyBinding, @@ -50,6 +51,10 @@ if (!fs.existsSync(sourceDir)) { ) } +if (process.platform === 'win32') { + assertWindowsSupervisorArtifacts({ directory: sourceDir, architectures: [process.arch] }) +} + fs.rmSync(destDir, { recursive: true, force: true }) copyDir(sourceDir, destDir) writeCliPackageMetadata() diff --git a/apps/electron/scripts/sync-cli-dist.mjs b/apps/electron/scripts/sync-cli-dist.mjs index 7d2ab6a36..411a17a9d 100644 --- a/apps/electron/scripts/sync-cli-dist.mjs +++ b/apps/electron/scripts/sync-cli-dist.mjs @@ -1,6 +1,7 @@ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { assertWindowsSupervisorArtifacts } from '../../cli/scripts/windows-supervisor-artifacts.mjs' import { installEmbeddedNodePtyBinding, @@ -50,6 +51,10 @@ if (!fs.existsSync(sourceDir)) { ) } +if (process.platform === 'win32') { + assertWindowsSupervisorArtifacts({ directory: sourceDir, architectures: [process.arch] }) +} + fs.rmSync(destDir, { recursive: true, force: true }) copyDir(sourceDir, destDir) writeCliPackageMetadata()