From aa65de4c00e172e85cb21b17e40b9c515ca05c71 Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Fri, 4 Sep 2026 12:52:43 +0800 Subject: [PATCH 1/7] fix(desktop): enable Windows local remote access Generated-by: Codex --- .../src/main/runtime-host-local-remote-access.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/main/runtime-host-local-remote-access.ts b/apps/desktop/src/main/runtime-host-local-remote-access.ts index 268fa68dee..15da36327c 100644 --- a/apps/desktop/src/main/runtime-host-local-remote-access.ts +++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts @@ -253,7 +253,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { try { const lifecycle = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); managedService = lifecycle !== undefined && hasManagedServiceTarget(lifecycle); - if (!supported(input.directPeerAvailable)) { + if (!input.directPeerAvailable) { return { ...unsupportedSnapshot(), ...(managedService ? { managedService: true as const } : {}), @@ -289,7 +289,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { const enable = (value: unknown): Promise => serialize(async () => { const request = requireEnableInput(value); - if (!supported(input.directPeerAvailable)) throw new Error(unsupportedSnapshot().message); + if (!input.directPeerAvailable) throw new Error(unsupportedSnapshot().message); let lifecycle = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); if (lifecycle?.state === 'uninstalling') { const recovered = await finishUninstall(lifecycle); @@ -895,7 +895,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { if (lifecycle.state === 'handoff' || lifecycle.state === 'setupPending') { const committed = await adoptCommittedSetup(lifecycle); if (committed.kind === 'managed') return; - if (!supported(input.directPeerAvailable)) return; + if (!input.directPeerAvailable) return; if (lifecycle.state === 'handoff') await recoverLegacyHandoff(lifecycle); else await finishSetup(lifecycle, 'recovery'); return; @@ -914,20 +914,13 @@ function conflictReplacementError(pid: number, reason: string): Error { return new Error(`Maka could not replace Runtime Host process ${pid}: ${reason}`); } -function supported(directPeerAvailable: boolean): boolean { - return directPeerAvailable && (process.platform === 'darwin' || process.platform === 'linux'); -} - function unsupportedSnapshot(): Extract< DesktopLocalRuntimeHostRemoteAccessSnapshot, { state: 'unsupported' } > { return { state: 'unsupported', - message: - process.platform === 'darwin' || process.platform === 'linux' - ? 'This Desktop build does not include Direct peer support' - : 'Remote access to this computer currently requires macOS or Linux', + message: 'This Desktop build does not include Direct peer support', }; } From 5f36e0889649d84f8d6dc74e35da84bdb18529ea Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Fri, 4 Sep 2026 13:04:26 +0800 Subject: [PATCH 2/7] fix(desktop): launch Windows local setup safely Generated-by: Codex --- .../src/main/runtime-host-local-operator.ts | 46 +++++++++++++++++-- .../locales/settings-projects-copy.ts | 3 ++ .../runtime-host-profiles-section.tsx | 16 +++++-- 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main/runtime-host-local-operator.ts b/apps/desktop/src/main/runtime-host-local-operator.ts index 3bda1aef6b..c19f67d66e 100644 --- a/apps/desktop/src/main/runtime-host-local-operator.ts +++ b/apps/desktop/src/main/runtime-host-local-operator.ts @@ -20,7 +20,7 @@ import { spawn, type ChildProcess } from 'node:child_process'; import { mkdtemp, rm, rmdir, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { delimiter, dirname, isAbsolute, join } from 'node:path'; import { redactSecrets } from '@maka/core/redaction'; import { DEFAULT_PROCESS_TERMINATION_GRACE_MS, @@ -662,6 +662,45 @@ function resolveLocalSetupPackage( return { specifier: setupPackage.path, integrity: setupPackage.integrity }; } +async function resolveLocalNpmCommand( + command: DesktopRuntimeHostLocalSetupCommand, + environment: NodeJS.ProcessEnv, +): Promise { + if (process.platform !== 'win32' || command.executable !== 'npm') return command; + + const configuredNode = environment.npm_node_execpath; + const configuredCli = environment.npm_execpath; + const candidates: Array = []; + if (configuredNode && configuredCli) candidates.push([configuredNode, configuredCli]); + + const path = Object.entries(environment).find(([key]) => key.toUpperCase() === 'PATH')?.[1]; + for (const entry of path?.split(delimiter) ?? []) { + const directory = entry.replace(/^"|"$/gu, '').trim(); + if (!directory) continue; + candidates.push([ + join(directory, 'node.exe'), + join(directory, 'node_modules', 'npm', 'bin', 'npm-cli.js'), + ]); + } + + for (const [nodePath, cliPath] of candidates) { + if (!isAbsolute(nodePath) || !isAbsolute(cliPath)) continue; + if ((await regularFile(nodePath)) && (await regularFile(cliPath))) { + return { executable: nodePath, args: [cliPath, ...command.args] }; + } + } + throw new Error('Local Runtime Host management requires a complete Node.js and npm installation'); +} + +async function regularFile(path: string): Promise { + try { + return (await stat(path)).isFile(); + } catch (error) { + if (isNodeError(error, 'ENOENT')) return false; + throw error; + } +} + function managedTargetArgs(target: DesktopRuntimeHostLocalServiceTarget): string[] { return [ '--expected-service-id', @@ -752,7 +791,7 @@ function runSetupProcess(input: { }); } -function runFramedProcess(input: { +async function runFramedProcess(input: { readonly command: DesktopRuntimeHostLocalSetupCommand; readonly cwd?: string; readonly prefix: string; @@ -772,8 +811,9 @@ function runFramedProcess(input: { readonly pendingMaxBytes?: number; }): Promise { input.signal?.throwIfAborted(); + const command = await resolveLocalNpmCommand(input.command, input.environment); return new Promise((resolve, reject) => { - const child = input.spawnProcess(input.command.executable, [...input.command.args], { + const child = input.spawnProcess(command.executable, [...command.args], { ...(input.cwd ? { cwd: input.cwd } : {}), detached: process.platform !== 'win32', env: input.environment, diff --git a/apps/desktop/src/renderer/locales/settings-projects-copy.ts b/apps/desktop/src/renderer/locales/settings-projects-copy.ts index 3666a18ddd..fa2aee3c2c 100644 --- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts @@ -38,6 +38,7 @@ export type SettingsProjectsCopy = { configureManuallyDescription: string; thisComputerRemoteAccess: string; thisComputerRemoteAccessHelp: string; + remoteAccessEnabling: string; remoteAccessOn: string; remoteAccessOff: string; enableRemoteAccess: string; @@ -329,6 +330,7 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { configureManuallyDescription: '为已有 Host 填写 TLS、SSH 或 Direct peer 参数', thisComputerRemoteAccess: '远程访问', thisComputerRemoteAccessHelp: '通过实验性端到端直连访问此 Host;可自动发现公共协调节点来辅助打洞', + remoteAccessEnabling: '正在准备并开启远程访问;首次可能需要一点时间。', remoteAccessOn: '已开启', remoteAccessOff: '未开启', enableRemoteAccess: '开启', @@ -645,6 +647,7 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { configureManuallyDescription: 'Enter TLS, SSH, or Direct peer details for an existing Host', thisComputerRemoteAccess: 'Remote access', thisComputerRemoteAccessHelp: 'Reach this Host through experimental end-to-end direct connections, with automatic public coordination discovery', + remoteAccessEnabling: 'Preparing and enabling remote access. The first setup may take a moment.', remoteAccessOn: 'On', remoteAccessOff: 'Off', enableRemoteAccess: 'Enable', diff --git a/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx b/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx index 3e39df880f..73d8d2161e 100644 --- a/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx +++ b/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx @@ -125,7 +125,10 @@ export function RuntimeHostProfilesSection(props: { readonly name: string; }>(); - const [switching, setSwitching] = useState(false); + const [activeAction, setActiveAction] = useState<'other' | 'local-access-enable'>(); + const switching = activeAction !== undefined; + const localAccessEnabling = activeAction === 'local-access-enable'; + const setSwitching = (value: boolean) => setActiveAction(value ? 'other' : undefined); const [draft, setDraft] = useState(createRemoteHostDraft); const reload = useCallback(async () => { @@ -262,7 +265,7 @@ export function RuntimeHostProfilesSection(props: { } async function enableLocalRemoteAccess(allowInterruptActiveTasks = false): Promise { - setSwitching(true); + setActiveAction('local-access-enable'); try { const result = await window.maka.localRuntimeHostRemoteAccess.enable({ allowInterruptActiveTasks, @@ -298,7 +301,9 @@ export function RuntimeHostProfilesSection(props: { ); } } finally { - if (mountedRef.current) setSwitching(false); + if (mountedRef.current) { + setActiveAction(undefined); + } } } @@ -399,7 +404,9 @@ export function RuntimeHostProfilesSection(props: { Date: Fri, 4 Sep 2026 14:29:11 +0800 Subject: [PATCH 3/7] fix(cli): keep Windows managed Host headless Generated-by: Codex --- .github/workflows/cli-package-validation.yml | 26 +-- .gitignore | 1 + .../src/windows_lifecycle.rs | 59 +++---- .../Cargo.lock | 162 ++++++++++++++++++ .../Cargo.toml | 45 +++++ .../build.mjs | 77 +++++++++ .../src/main.rs | 143 ++++++++++++++++ package.json | 1 + .../cli/src/runtime-host-windows-service.ts | 25 ++- ...ime-host-windows-task-launcher-artifact.ts | 61 +++++++ .../src/runtime-host-windows-task-runner.ts | 98 ----------- scripts/asf-license-headers.mjs | 1 + scripts/release-cli-package.mjs | 95 +++++++--- scripts/smoke-release-cli-package.mjs | 4 - 14 files changed, 619 insertions(+), 179 deletions(-) create mode 100644 native/runtime-host-windows-task-launcher/Cargo.lock create mode 100644 native/runtime-host-windows-task-launcher/Cargo.toml create mode 100644 native/runtime-host-windows-task-launcher/build.mjs create mode 100644 native/runtime-host-windows-task-launcher/src/main.rs create mode 100644 packages/cli/src/runtime-host-windows-task-launcher-artifact.ts delete mode 100644 packages/cli/src/runtime-host-windows-task-runner.ts diff --git a/.github/workflows/cli-package-validation.yml b/.github/workflows/cli-package-validation.yml index d449063ea0..92cffd30a6 100644 --- a/.github/workflows/cli-package-validation.yml +++ b/.github/workflows/cli-package-validation.yml @@ -25,6 +25,7 @@ on: - '.github/workflows/runtime-host-peer-admission.yml' - 'deny.toml' - 'native/runtime-host-peer/**' + - 'native/runtime-host-windows-task-launcher/**' - 'package-lock.json' - 'packages/cli/RUNTIME_HOST_PEER_*' - 'packages/cli/src/cli-core.ts' @@ -156,6 +157,11 @@ jobs: MAKA_RUNTIME_HOST_PEER_CARGO_SUBCOMMAND: ${{ matrix.rust_target && 'zigbuild' || '' }} MAKA_RUNTIME_HOST_PEER_CARGO_TARGET: ${{ matrix.rust_target }} run: node native/runtime-host-peer/build.mjs + - name: Build the Windows task launcher + if: matrix.target == 'win32-x64' + run: | + cargo fmt --manifest-path native/runtime-host-windows-task-launcher/Cargo.toml --check + node native/runtime-host-windows-task-launcher/build.mjs - name: Report Rust build cache shell: bash run: kache report --format github >> "$GITHUB_STEP_SUMMARY" @@ -181,15 +187,15 @@ jobs: const newer = versions.find(([major, minor]) => major > 2 || (major === 2 && minor > 28)); if (newer) throw new Error(`Direct-peer addon requires GLIBC_${newer.join('.')}`); NODE - - name: Stage the platform addon + - name: Stage the platform native artifacts env: - PEER_TARGET: ${{ matrix.target }} - run: node -e "const fs=require('node:fs'),p=require('node:path'); const d=p.join('peer-prebuilds',process.env.PEER_TARGET); fs.mkdirSync(d,{recursive:true}); fs.copyFileSync(p.join('native','runtime-host-peer','target','release','maka_runtime_host_peer.node'),p.join(d,'maka_runtime_host_peer.node'))" - - name: Upload the platform addon + NATIVE_TARGET: ${{ matrix.target }} + run: node -e "const fs=require('node:fs'),p=require('node:path'),t=process.env.NATIVE_TARGET,d=p.join('native-prebuilds',t); fs.mkdirSync(d,{recursive:true}); fs.copyFileSync(p.join('native','runtime-host-peer','target','release','maka_runtime_host_peer.node'),p.join(d,'maka_runtime_host_peer.node')); if(t==='win32-x64') fs.copyFileSync(p.join('native','runtime-host-windows-task-launcher','target','release','maka-runtime-host-task-launcher.exe'),p.join(d,'maka-runtime-host-task-launcher.exe'))" + - name: Upload the platform native artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: runtime-host-peer-${{ matrix.target }} - path: peer-prebuilds + name: runtime-host-native-${{ matrix.target }} + path: native-prebuilds if-no-files-found: error retention-days: 1 @@ -216,16 +222,16 @@ jobs: uses: taiki-e/install-action@1ed6d7be6168f6c9046541087ff549b6bc581fdf # v2 with: tool: cargo-deny@0.20.2 - - name: Download direct-peer addons + - name: Download Runtime Host native artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - pattern: runtime-host-peer-* - path: ${{ runner.temp }}/runtime-host-peer-prebuilds + pattern: runtime-host-native-* + path: ${{ runner.temp }}/runtime-host-native-prebuilds merge-multiple: true - name: Build the release tarball once env: MAKA_CLI_NIGHTLY_VERSION: ${{ inputs.package_version }} - MAKA_RUNTIME_HOST_PEER_PREBUILDS: ${{ runner.temp }}/runtime-host-peer-prebuilds + MAKA_RUNTIME_HOST_NATIVE_PREBUILDS: ${{ runner.temp }}/runtime-host-native-prebuilds run: npm run release:cli:pack - name: Upload the immutable release candidate id: release-candidate diff --git a/.gitignore b/.gitignore index 7f296f9fdd..9ce473efed 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ deepseek.key # Built only by the dedicated Gitoxide helper lane; normal workspace tests do not use Cargo. /native/gitoxide-helper/target/ /native/runtime-host-peer/target/ +/native/runtime-host-windows-task-launcher/target/ # Generated Computer Use executor binary; provenance metadata stays tracked. apps/desktop/resources/bin/ diff --git a/native/runtime-host-peer/src/windows_lifecycle.rs b/native/runtime-host-peer/src/windows_lifecycle.rs index 7d4e0995cf..164c1d6774 100644 --- a/native/runtime-host-peer/src/windows_lifecycle.rs +++ b/native/runtime-host-peer/src/windows_lifecycle.rs @@ -90,7 +90,7 @@ pub fn windows_task_probe() -> Result<()> { pub fn windows_task_converge( root_id: String, target: String, - runner_path: String, + launcher_path: String, command: Vec, ) -> Result<()> { let target = require_target(&root_id, &target)?; @@ -99,7 +99,7 @@ pub fn windows_task_converge( &scheduler().map_err(native_error)?, &root_id, target, - &runner_path, + &launcher_path, &command, ) .map_err(native_error) @@ -109,7 +109,7 @@ pub fn windows_task_converge( pub fn windows_task_verify( root_id: String, target: String, - runner_path: String, + launcher_path: String, command: Vec, ) -> Result<()> { let target = require_target(&root_id, &target)?; @@ -118,7 +118,7 @@ pub fn windows_task_verify( let name = task_name(&root_id, target); let task = required_owned_task(&context.folder, &name, &root_id, target).map_err(native_error)?; - verify_registered_definition(&task, target, &runner_path, &command, &context.user) + verify_registered_definition(&task, target, &launcher_path, &command, &context.user) .map_err(native_error) } @@ -244,7 +244,7 @@ fn converge_task( context: &Scheduler, root_id: &str, target: Target, - runner_path: &str, + launcher_path: &str, command: &[String], ) -> windows::core::Result<()> { let name = task_name(root_id, target); @@ -257,7 +257,7 @@ fn converge_task( &context.service, root_id, target, - runner_path, + launcher_path, command, &context.user, )?; @@ -282,7 +282,7 @@ fn normalized_definition( service: &ITaskService, root_id: &str, target: Target, - runner_path: &str, + launcher_path: &str, command: &[String], user: &str, ) -> windows::core::Result { @@ -292,7 +292,7 @@ fn normalized_definition( definition.SetXmlText(&BSTR::from(render_task_xml( root_id, target, - runner_path, + launcher_path, command, user, )?))?; @@ -322,14 +322,14 @@ fn owned_task( fn verify_registered_definition( task: &IRegisteredTask, target: Target, - runner_path: &str, + launcher_path: &str, command: &[String], user: &str, ) -> windows::core::Result<()> { // SAFETY: every interface is obtained from this thread's live registered-task definition; // all out pointers refer to initialized local values for the duration of each call. unsafe { - let expected_command = task_action_command(target, runner_path, command)?; + let expected_command = task_action_command(target, launcher_path, command)?; let definition = task.Definition()?; let actions = definition.Actions()?; @@ -590,20 +590,18 @@ fn direct_child_pid_in_snapshot( parent_pid: u32, processes: &[PROCESSENTRY32W], ) -> windows::core::Result> { - let parent_name = processes + if !processes .iter() - .find(|process| process.th32ProcessID == parent_pid) - .map(|process| process_name(&process.szExeFile)) - .ok_or_else(|| { - windows::core::Error::new( - windows::core::HRESULT(0x80070002_u32 as i32), - "The Windows Runtime Host supervisor process is not available", - ) - })?; + .any(|process| process.th32ProcessID == parent_pid) + { + return Err(windows::core::Error::new( + windows::core::HRESULT(0x80070002_u32 as i32), + "The Windows Runtime Host supervisor process is not available", + )); + } let mut child = None; for process in processes { if process.th32ParentProcessID == parent_pid - && process_name(&process.szExeFile).eq_ignore_ascii_case(&parent_name) && child.replace(process.th32ProcessID).is_some() { return Err(windows::core::Error::new( @@ -615,15 +613,6 @@ fn direct_child_pid_in_snapshot( Ok(child) } -fn process_name(value: &[u16]) -> String { - String::from_utf16_lossy( - &value[..value - .iter() - .position(|part| *part == 0) - .unwrap_or(value.len())], - ) -} - fn wait_until_task_stopped(task: &IRegisteredTask) -> windows::core::Result<()> { let deadline = Instant::now() + STOP_TIMEOUT; while Instant::now() < deadline { @@ -695,7 +684,7 @@ fn stop_task(task: &IRegisteredTask) -> windows::core::Result<()> { fn render_task_xml( root_id: &str, target: Target, - runner_path: &str, + launcher_path: &str, command: &[String], user: &str, ) -> windows::core::Result { @@ -713,7 +702,7 @@ fn render_task_xml( ) .to_owned(), }; - let action_command = task_action_command(target, runner_path, command)?; + let action_command = task_action_command(target, launcher_path, command)?; let arguments = command_line(&action_command[1..]); Ok(format!( concat!( @@ -745,11 +734,11 @@ fn render_task_xml( fn task_action_command( target: Target, - runner_path: &str, + launcher_path: &str, command: &[String], ) -> windows::core::Result> { - if !Path::new(runner_path).is_absolute() - || runner_path.contains('%') + if !Path::new(launcher_path).is_absolute() + || launcher_path.contains('%') || command[0].contains('%') || (matches!(target, Target::Host) && (command.len() < 4 || command[2] != "runtime-host" || command[3] != "serve")) @@ -760,7 +749,7 @@ fn task_action_command( Target::Host => "--supervise", Target::Reconciliation => "--once", }; - let mut projected = vec![command[0].clone(), runner_path.to_owned(), mode.to_owned()]; + let mut projected = vec![launcher_path.to_owned(), mode.to_owned()]; projected.extend( command .iter() diff --git a/native/runtime-host-windows-task-launcher/Cargo.lock b/native/runtime-host-windows-task-launcher/Cargo.lock new file mode 100644 index 0000000000..fa89aa9c36 --- /dev/null +++ b/native/runtime-host-windows-task-launcher/Cargo.lock @@ -0,0 +1,162 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "maka-runtime-host-windows-task-launcher" +version = "0.0.0" +dependencies = [ + "base64", + "windows", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] diff --git a/native/runtime-host-windows-task-launcher/Cargo.toml b/native/runtime-host-windows-task-launcher/Cargo.toml new file mode 100644 index 0000000000..8c0da81850 --- /dev/null +++ b/native/runtime-host-windows-task-launcher/Cargo.toml @@ -0,0 +1,45 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +[package] +name = "maka-runtime-host-windows-task-launcher" +version = "0.0.0" +edition = "2024" +license = "Apache-2.0" +rust-version = "1.98" +publish = false + +[[bin]] +name = "maka-runtime-host-task-launcher" +path = "src/main.rs" + +[dependencies] +base64 = "0.22" + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.62.2", default-features = false, features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_System_JobObjects", + "Win32_System_Threading", +] } + +[profile.release] +strip = "symbols" +opt-level = "s" +lto = "thin" +codegen-units = 1 diff --git a/native/runtime-host-windows-task-launcher/build.mjs b/native/runtime-host-windows-task-launcher/build.mjs new file mode 100644 index 0000000000..55d93c27c6 --- /dev/null +++ b/native/runtime-host-windows-task-launcher/build.mjs @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { copyFile, mkdir, readFile } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = dirname(fileURLToPath(import.meta.url)); +if (process.platform !== 'win32' || process.arch !== 'x64') { + throw new Error('The Runtime Host Windows task launcher must be built on Windows x64'); +} +const encodedRustflags = [ + process.env.CARGO_ENCODED_RUSTFLAGS, + `--remap-path-prefix=${root}=native/runtime-host-windows-task-launcher`, + '-Clink-arg=/PDBALTPATH:maka-runtime-host-task-launcher.pdb', +] + .filter(Boolean) + .join('\x1f'); +await run('cargo', ['build', '--release', '--locked'], root, { + ...process.env, + CARGO_ENCODED_RUSTFLAGS: encodedRustflags, +}); + +const source = join(root, 'target', 'release', 'maka-runtime-host-task-launcher.exe'); +const destination = process.env.MAKA_RUNTIME_HOST_WINDOWS_TASK_LAUNCHER_OUTPUT?.trim() + ? resolve(process.env.MAKA_RUNTIME_HOST_WINDOWS_TASK_LAUNCHER_OUTPUT.trim()) + : source; +if (destination !== source) { + await mkdir(dirname(destination), { recursive: true }); + await copyFile(source, destination); +} +const executable = await readFile(destination); +assertWindowsGuiSubsystem(executable); +if (executable.includes(Buffer.from(root))) { + throw new Error('The Runtime Host Windows task launcher contains its build path'); +} +process.stdout.write(`${destination}\n`); + +function assertWindowsGuiSubsystem(executable) { + const peHeader = executable.readUInt32LE(0x3c); + const optionalHeader = peHeader + 24; + if ( + executable.toString('ascii', peHeader, peHeader + 4) !== 'PE\0\0' || + ![0x10b, 0x20b].includes(executable.readUInt16LE(optionalHeader)) || + executable.readUInt16LE(optionalHeader + 68) !== 2 + ) { + throw new Error('The Runtime Host task launcher is not a Windows GUI executable'); + } +} + +function run(command, args, cwd, env) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, env, stdio: 'inherit' }); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(`${command} failed: ${signal ?? code}`)); + }); + }); +} diff --git a/native/runtime-host-windows-task-launcher/src/main.rs b/native/runtime-host-windows-task-launcher/src/main.rs new file mode 100644 index 0000000000..c686a4eaf3 --- /dev/null +++ b/native/runtime-host-windows-task-launcher/src/main.rs @@ -0,0 +1,143 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#![cfg_attr(target_os = "windows", windows_subsystem = "windows")] + +use std::{ + env, + path::Path, + process::{Command, Stdio}, + thread::sleep, + time::Duration, +}; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; + +#[cfg(target_os = "windows")] +use std::{ + mem::size_of, + os::windows::{ + io::{AsRawHandle, FromRawHandle}, + process::CommandExt, + }, +}; +#[cfg(target_os = "windows")] +use windows::Win32::{ + Foundation::HANDLE, + System::{ + JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, + }, + Threading::{CREATE_NO_WINDOW, GetCurrentProcess}, + }, +}; +#[cfg(target_os = "windows")] +use windows::core::PCWSTR; + +const RESTART_DELAY: Duration = Duration::from_secs(2); + +fn main() { + std::process::exit(run()); +} + +fn run() -> i32 { + let mut arguments = env::args_os(); + let _executable = arguments.next(); + let Some(mode) = arguments.next().and_then(|value| value.into_string().ok()) else { + return 1; + }; + let Some(command) = arguments + .map(|value| value.into_string().ok().and_then(decode_argument)) + .collect::>>() + else { + return 1; + }; + if !valid_command(&mode, &command) { + return 1; + } + let Ok(_owner) = own_process_tree() else { + return 1; + }; + if mode == "--once" { + return run_child(&command).unwrap_or(1); + } + loop { + if run_child(&command) == Some(0) { + return 0; + } + sleep(RESTART_DELAY); + } +} + +fn decode_argument(argument: String) -> Option { + let decoded = URL_SAFE_NO_PAD.decode(&argument).ok()?; + if URL_SAFE_NO_PAD.encode(&decoded) != argument { + return None; + } + String::from_utf8(decoded).ok() +} + +fn valid_command(mode: &str, command: &[String]) -> bool { + (mode == "--once" || mode == "--supervise") + && command + .first() + .is_some_and(|path| Path::new(path).is_absolute()) + && (mode != "--supervise" + || (command.len() >= 4 && command[2] == "runtime-host" && command[3] == "serve")) +} + +fn run_child(command: &[String]) -> Option { + let mut child = Command::new(&command[0]); + child + .args(&command[1..]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(target_os = "windows")] + child.creation_flags(CREATE_NO_WINDOW.0); + child.status().ok()?.code() +} + +#[cfg(target_os = "windows")] +fn own_process_tree() -> windows::core::Result { + // SAFETY: the returned handle remains owned until the launcher exits, the initialized + // structure matches the selected information class, and GetCurrentProcess is valid here. + unsafe { + let created = CreateJobObjectW(None, PCWSTR::null())?; + let owned = std::os::windows::io::OwnedHandle::from_raw_handle(created.0); + let handle = HANDLE(owned.as_raw_handle()); + let mut information = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + (&raw const information).cast(), + size_of::() as u32, + )?; + AssignProcessToJobObject(handle, GetCurrentProcess())?; + Ok(owned) + } +} + +#[cfg(not(target_os = "windows"))] +fn own_process_tree() -> Result<(), ()> { + Ok(()) +} diff --git a/package.json b/package.json index 86b97fc986..f5c0938019 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "test:dist": "node scripts/run-workspace-tests-parallel.mjs --concurrency=3", "test:gitoxide-helper": "cargo +1.98.0 test --locked --manifest-path native/gitoxide-helper/Cargo.toml", "build:runtime-host-peer": "node native/runtime-host-peer/build.mjs", + "build:runtime-host-windows-task-launcher": "node native/runtime-host-windows-task-launcher/build.mjs", "lint:runtime-host-peer": "cargo clippy --locked --all-targets --manifest-path native/runtime-host-peer/Cargo.toml -- -D warnings", "dev": "npm --workspace @maka/desktop run dev:hmr --", "dev:peer": "npm --workspace @maka/desktop run dev:peer --", diff --git a/packages/cli/src/runtime-host-windows-service.ts b/packages/cli/src/runtime-host-windows-service.ts index e41d4c1859..9ba6ffb8f8 100644 --- a/packages/cli/src/runtime-host-windows-service.ts +++ b/packages/cli/src/runtime-host-windows-service.ts @@ -18,7 +18,6 @@ */ import { createRequire } from 'node:module'; -import { dirname, join } from 'node:path'; import { assertRuntimeHostProviderDefinition, type RuntimeHostLifecycleProvider, @@ -27,6 +26,7 @@ import { } from './runtime-host-lifecycle-provider.js'; import { resolveRuntimeHostNativePath } from './runtime-host-peer-artifact.js'; import { RuntimeHostServiceManagerError } from './runtime-host-service-manager.js'; +import { resolveRuntimeHostWindowsTaskLauncherPath } from './runtime-host-windows-task-launcher-artifact.js'; const require = createRequire(import.meta.url); const ROOT_ID_PATTERN = /^[a-f0-9]{64}$/u; @@ -54,13 +54,13 @@ interface WindowsLifecycleNative { readonly windowsTaskConverge: ( rootId: string, target: WindowsTaskTarget, - runnerPath: string, + launcherPath: string, command: string[], ) => void; readonly windowsTaskVerify: ( rootId: string, target: WindowsTaskTarget, - runnerPath: string, + launcherPath: string, command: string[], ) => void; readonly windowsTaskStatus: (rootId: string, target: WindowsTaskTarget) => unknown; @@ -85,20 +85,28 @@ export function createWindowsRuntimeHostLifecycleProvider( ); } const native = createWindowsLifecycleNativeLoader(options.cliPath); - const runnerPath = join(dirname(options.cliPath), 'runtime-host-windows-task-runner.js'); + let launcherPath: Promise | undefined; + const resolveLauncherPath = (): Promise => { + launcherPath ??= resolveRuntimeHostWindowsTaskLauncherPath(options.cliPath); + return launcherPath; + }; const converge = async ( target: WindowsTaskTarget, definition: RuntimeHostProviderDefinition, ): Promise => { assertRuntimeHostProviderDefinition(definition); - (await native()).windowsTaskConverge(rootId, target, runnerPath, [...definition.command]); + (await native()).windowsTaskConverge(rootId, target, await resolveLauncherPath(), [ + ...definition.command, + ]); }; const verify = async ( target: WindowsTaskTarget, definition: RuntimeHostProviderDefinition, ): Promise => { assertRuntimeHostProviderDefinition(definition); - (await native()).windowsTaskVerify(rootId, target, runnerPath, [...definition.command]); + (await native()).windowsTaskVerify(rootId, target, await resolveLauncherPath(), [ + ...definition.command, + ]); }; const status = async (target: WindowsTaskTarget): Promise => decodeStatus((await native()).windowsTaskStatus(rootId, target)); @@ -106,7 +114,10 @@ export function createWindowsRuntimeHostLifecycleProvider( return { supervisor: { provider: 'windows_task', - preflight: async () => (await native()).windowsTaskProbe(), + preflight: async () => { + await resolveLauncherPath(); + (await native()).windowsTaskProbe(); + }, converge: (definition) => converge('host', definition), verify: (definition) => verify('host', definition), status: async () => { diff --git a/packages/cli/src/runtime-host-windows-task-launcher-artifact.ts b/packages/cli/src/runtime-host-windows-task-launcher-artifact.ts new file mode 100644 index 0000000000..5045017dd3 --- /dev/null +++ b/packages/cli/src/runtime-host-windows-task-launcher-artifact.ts @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { access, realpath } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; + +const LAUNCHER_FILE = 'maka-runtime-host-task-launcher.exe'; + +export async function resolveRuntimeHostWindowsTaskLauncherPath(cliPath: string): Promise { + const packageRoot = dirname(dirname(await realpath(cliPath))); + const packaged = join( + packageRoot, + 'native', + 'runtime-host-windows-task-launcher', + 'prebuilds', + 'win32-x64', + LAUNCHER_FILE, + ); + if (await isReadable(packaged)) return realpath(packaged); + + if (basename(packageRoot) === 'cli' && basename(dirname(packageRoot)) === 'packages') { + const development = join( + packageRoot, + '..', + '..', + 'native', + 'runtime-host-windows-task-launcher', + 'target', + 'release', + LAUNCHER_FILE, + ); + if (await isReadable(development)) return realpath(development); + } + + throw new Error('Maka does not include the Windows Runtime Host task launcher'); +} + +async function isReadable(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} diff --git a/packages/cli/src/runtime-host-windows-task-runner.ts b/packages/cli/src/runtime-host-windows-task-runner.ts deleted file mode 100644 index 7287322ebb..0000000000 --- a/packages/cli/src/runtime-host-windows-task-runner.ts +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { spawn } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; -import { assertRuntimeHostProviderDefinition } from './runtime-host-lifecycle-provider.js'; -import { ownWindowsRuntimeHostProcessTree } from './runtime-host-windows-service.js'; - -const RESTART_DELAY_MS = 2_000; -const [mode, ...encodedCommand] = process.argv.slice(2); - -try { - const command = decodeCommand(encodedCommand); - assertRuntimeHostProviderDefinition({ command }); - if (process.platform !== 'win32' || (mode !== '--once' && mode !== '--supervise')) { - throw new Error('The Windows Runtime Host task command is invalid'); - } - if ( - mode === '--supervise' && - (command.length < 4 || command[2] !== 'runtime-host' || command[3] !== 'serve') - ) { - throw new Error('The Windows Runtime Host supervisor command is invalid'); - } - await ownWindowsRuntimeHostProcessTree(fileURLToPath(import.meta.url)); - if (mode === '--once') { - const result = await runChild(command); - process.exitCode = result.signal === null && result.code !== null ? result.code : 1; - } else { - for (;;) { - const result = await runChild(command); - if (result.code === 0 && result.signal === null) break; - console.error( - `[runtime-host] Host exited unexpectedly (${result.signal ?? result.code ?? 'launch failed'}); restarting`, - ); - await new Promise((resolve) => setTimeout(resolve, RESTART_DELAY_MS)); - } - } -} catch (error) { - console.error(error instanceof Error ? (error.stack ?? error.message) : String(error)); - process.exitCode = 1; -} - -function decodeCommand(encoded: readonly string[]): [string, ...string[]] { - const command = encoded.map((argument) => { - if (!/^[A-Za-z0-9_-]+$/u.test(argument)) { - throw new Error('The Windows Runtime Host task argument is invalid'); - } - const bytes = Buffer.from(argument, 'base64url'); - if (bytes.toString('base64url') !== argument) { - throw new Error('The Windows Runtime Host task argument is invalid'); - } - return new TextDecoder('utf-8', { fatal: true }).decode(bytes); - }); - if (command.length === 0) { - throw new Error('The Windows Runtime Host task command is empty'); - } - return command as [string, ...string[]]; -} - -function runChild( - childCommand: readonly [string, ...string[]], -): Promise<{ readonly code: number | null; readonly signal: NodeJS.Signals | null }> { - return new Promise((resolve) => { - const child = spawn(childCommand[0], childCommand.slice(1), { - stdio: 'inherit', - windowsHide: true, - }); - let settled = false; - child.once('error', () => { - if (!settled) { - settled = true; - resolve({ code: null, signal: null }); - } - }); - child.once('exit', (code, signal) => { - if (!settled) { - settled = true; - resolve({ code, signal }); - } - }); - }); -} diff --git a/scripts/asf-license-headers.mjs b/scripts/asf-license-headers.mjs index b418ea8b9c..edfe6194c4 100644 --- a/scripts/asf-license-headers.mjs +++ b/scripts/asf-license-headers.mjs @@ -228,6 +228,7 @@ export const exclusionRules = [ 'docs/windows-test-inventory.md', 'native/gitoxide-helper/Cargo.lock', 'native/runtime-host-peer/Cargo.lock', + 'native/runtime-host-windows-task-launcher/Cargo.lock', 'packages/runtime/src/bundled-skill-catalog.generated.ts', ), }, diff --git a/scripts/release-cli-package.mjs b/scripts/release-cli-package.mjs index c3976e694c..cfc188f513 100644 --- a/scripts/release-cli-package.mjs +++ b/scripts/release-cli-package.mjs @@ -60,8 +60,8 @@ const preparedTree = process.env.MAKA_CLI_RELEASE_PREPARED_TREE === '1'; const releaseRoot = join(cliSource, 'release'); const artifactRoot = developmentBuild ? createDevelopmentArtifactRoot() : releaseRoot; const stageRoot = join(artifactRoot, 'package'); -const peerPrebuildTargets = ['darwin-arm64', 'linux-arm64', 'linux-x64', 'win32-x64']; -const privatePeerTarget = developmentBuild +const runtimeHostTargets = ['darwin-arm64', 'linux-arm64', 'linux-x64', 'win32-x64']; +const privateRuntimeHostTarget = developmentBuild ? resolveDevelopmentPeerTarget() : `${process.platform}-${process.arch}`; const unsupportedArguments = process.argv @@ -149,7 +149,7 @@ function packageCli(publishable) { rmSync(artifactRoot, { recursive: true, force: true }); mkdirSync(stageRoot, { recursive: true, mode: 0o755 }); copyCliRuntime(); - copyRuntimeHostPeerPrebuilds(publishable); + copyRuntimeHostNativePrebuilds(publishable); const expectedDependencyManifests = copyDependencyClosure(cli); copyReleaseDocuments(); writeReleaseManifest(cli, publishable); @@ -217,7 +217,7 @@ function buildFromCleanDependencyTree() { stdio: 'inherit', }); execFileSync('tar', ['-xf', archivePath, '-C', cleanRoot], { stdio: 'inherit' }); - const preparedPeerPrebuilds = copyPeerPrebuildInputToCleanTree(cleanRoot); + const preparedNativePrebuilds = copyNativePrebuildInputToCleanTree(cleanRoot); console.log('[release-cli] installing the committed dependency tree with npm ci'); const cleanEnvironment = releaseNpmEnvironment(process.env, join(cleanRoot, '.npmrc')); execFileSync( @@ -230,8 +230,8 @@ function buildFromCleanDependencyTree() { env: { ...cleanEnvironment, MAKA_CLI_RELEASE_PREPARED_TREE: '1', - ...(preparedPeerPrebuilds - ? { MAKA_RUNTIME_HOST_PEER_PREBUILDS: preparedPeerPrebuilds } + ...(preparedNativePrebuilds + ? { MAKA_RUNTIME_HOST_NATIVE_PREBUILDS: preparedNativePrebuilds } : {}), }, stdio: 'inherit', @@ -249,10 +249,10 @@ function buildFromCleanDependencyTree() { } } -function copyPeerPrebuildInputToCleanTree(cleanRoot) { - const source = process.env.MAKA_RUNTIME_HOST_PEER_PREBUILDS?.trim(); +function copyNativePrebuildInputToCleanTree(cleanRoot) { + const source = process.env.MAKA_RUNTIME_HOST_NATIVE_PREBUILDS?.trim(); if (!source) return undefined; - const destination = join(cleanRoot, '.release-runtime-host-peer-prebuilds'); + const destination = join(cleanRoot, '.release-runtime-host-native-prebuilds'); cpSync(realpathSync(source), destination, { recursive: true, preserveTimestamps: true }); return destination; } @@ -534,39 +534,54 @@ function copyReleaseDocuments() { ); } -function copyRuntimeHostPeerPrebuilds(publishable) { - const sourceRoot = process.env.MAKA_RUNTIME_HOST_PEER_PREBUILDS?.trim(); +function copyRuntimeHostNativePrebuilds(publishable) { + const sourceRoot = process.env.MAKA_RUNTIME_HOST_NATIVE_PREBUILDS?.trim(); const targets = publishable - ? peerPrebuildTargets - : privatePeerTarget === 'none' + ? runtimeHostTargets + : privateRuntimeHostTarget === 'none' ? [] - : [privatePeerTarget]; + : [privateRuntimeHostTarget]; if (targets.length === 0) return; - const destinationRoot = join(stageRoot, 'native/runtime-host-peer/prebuilds'); + const peerDestinationRoot = join(stageRoot, 'native/runtime-host-peer/prebuilds'); + const launcherDestination = join( + stageRoot, + 'native/runtime-host-windows-task-launcher/prebuilds/win32-x64/maka-runtime-host-task-launcher.exe', + ); if (!sourceRoot && !publishable) { const [target] = targets; - const destination = join(destinationRoot, target, 'maka_runtime_host_peer.node'); - buildDevelopmentPeerAddon(target, destination); + buildDevelopmentPeerAddon( + target, + join(peerDestinationRoot, target, 'maka_runtime_host_peer.node'), + ); + if (target === 'win32-x64') buildDevelopmentWindowsTaskLauncher(launcherDestination); return; } if (!sourceRoot) { - throw new Error('MAKA_RUNTIME_HOST_PEER_PREBUILDS must contain all release platform addons'); + throw new Error('MAKA_RUNTIME_HOST_NATIVE_PREBUILDS must contain all release native artifacts'); } for (const target of targets) { const source = join(sourceRoot, target, 'maka_runtime_host_peer.node'); if (!existsSync(source) || !statSync(source).isFile()) { throw new Error(`Runtime Host peer prebuild is missing: ${target}`); } - const destination = join(destinationRoot, target, 'maka_runtime_host_peer.node'); + const destination = join(peerDestinationRoot, target, 'maka_runtime_host_peer.node'); mkdirSync(dirname(destination), { recursive: true, mode: 0o755 }); copyFileSync(source, destination); } + if (targets.includes('win32-x64')) { + const source = join(sourceRoot, 'win32-x64', 'maka-runtime-host-task-launcher.exe'); + if (!existsSync(source) || !statSync(source).isFile()) { + throw new Error('Runtime Host Windows task launcher prebuild is missing'); + } + mkdirSync(dirname(launcherDestination), { recursive: true, mode: 0o755 }); + copyFileSync(source, launcherDestination); + } } function resolveDevelopmentPeerTarget() { const configured = process.env.MAKA_CLI_DEVELOPMENT_PEER_TARGET?.trim(); const target = configured || `${process.platform}-${process.arch}`; - if (target !== 'none' && !peerPrebuildTargets.includes(target)) { + if (target !== 'none' && !runtimeHostTargets.includes(target)) { throw new Error( `MAKA_CLI_DEVELOPMENT_PEER_TARGET must be none or a supported target; found ${target}`, ); @@ -591,7 +606,7 @@ function buildDevelopmentPeerAddon(target, output) { }[target]; if (!rustTarget) { throw new Error( - `Cannot build the ${target} direct-peer addon from ${hostTarget}; run Desktop on that target or provide MAKA_RUNTIME_HOST_PEER_PREBUILDS`, + `Cannot build the ${target} direct-peer addon from ${hostTarget}; run Desktop on that target or provide MAKA_RUNTIME_HOST_NATIVE_PREBUILDS`, ); } requireDevelopmentCommand( @@ -616,6 +631,23 @@ function buildDevelopmentPeerAddon(target, output) { }); } +function buildDevelopmentWindowsTaskLauncher(output) { + if (`${process.platform}-${process.arch}` !== 'win32-x64') { + throw new Error( + 'Building the Windows task launcher requires Windows x64 or MAKA_RUNTIME_HOST_NATIVE_PREBUILDS', + ); + } + execFileSync( + process.execPath, + [join(repoRoot, 'native/runtime-host-windows-task-launcher/build.mjs')], + { + cwd: repoRoot, + env: { ...process.env, MAKA_RUNTIME_HOST_WINDOWS_TASK_LAUNCHER_OUTPUT: output }, + stdio: 'inherit', + }, + ); +} + function requireDevelopmentCommand(command, args, message) { const result = spawnSync(command, args, { cwd: repoRoot, encoding: 'utf8' }); if (result.status === 0) return; @@ -729,14 +761,20 @@ function validateStaging(publishable) { ]; if (publishable) { required.push( - ...peerPrebuildTargets.map( + ...runtimeHostTargets.map( (target) => `native/runtime-host-peer/prebuilds/${target}/maka_runtime_host_peer.node`, ), + 'native/runtime-host-windows-task-launcher/prebuilds/win32-x64/maka-runtime-host-task-launcher.exe', ); - } else if (privatePeerTarget !== 'none') { + } else if (privateRuntimeHostTarget !== 'none') { required.push( - `native/runtime-host-peer/prebuilds/${privatePeerTarget}/maka_runtime_host_peer.node`, + `native/runtime-host-peer/prebuilds/${privateRuntimeHostTarget}/maka_runtime_host_peer.node`, ); + if (privateRuntimeHostTarget === 'win32-x64') { + required.push( + 'native/runtime-host-windows-task-launcher/prebuilds/win32-x64/maka-runtime-host-task-launcher.exe', + ); + } } for (const path of required) { if (!existsSync(join(stageRoot, path))) @@ -822,7 +860,14 @@ function validatePackedFiles(files, expectedDependencyManifests, publishable) { 'node_modules/@maka/runtime/dist/workers/filesystem-worker.js', 'node_modules/@maka/runtime-host/dist/execution-candidate-main.js', 'node_modules/@maka/eval/harbor/relay_agent.py', - ...(publishable || privatePeerTarget !== 'none' ? ['native/runtime-host-peer/prebuilds/'] : []), + ...(publishable || privateRuntimeHostTarget !== 'none' + ? ['native/runtime-host-peer/prebuilds/'] + : []), + ...(publishable || privateRuntimeHostTarget === 'win32-x64' + ? [ + 'native/runtime-host-windows-task-launcher/prebuilds/win32-x64/maka-runtime-host-task-launcher.exe', + ] + : []), ]; for (const suffix of requiredPacked) { if ( diff --git a/scripts/smoke-release-cli-package.mjs b/scripts/smoke-release-cli-package.mjs index b8307b1bac..b4fde8cdde 100644 --- a/scripts/smoke-release-cli-package.mjs +++ b/scripts/smoke-release-cli-package.mjs @@ -430,8 +430,6 @@ async function smokeWindowsTaskScheduler(createProvider, cliEntrypoint, root) { dirname(managedCliEntrypoint), 'runtime-host-windows-supervisor-smoke.mjs', ); - const controllerRunnerPath = join(dirname(cliEntrypoint), 'runtime-host-windows-task-runner.js'); - const disabledControllerRunnerPath = `${controllerRunnerPath}.disabled`; const readyPath = join(root, 'ready.json'); const replacementReadyPath = join(root, 'replacement-ready.json'); const hostileArgument = '空 格 &|^<>%PATH% " \\'; @@ -470,7 +468,6 @@ async function smokeWindowsTaskScheduler(createProvider, cliEntrypoint, root) { ]; const replacementHostCommand = [...hostCommand.slice(0, -1), replacementReadyPath]; const reconciliationCommand = [process.execPath, '-e', 'process.exit(0)']; - renameSync(controllerRunnerPath, disabledControllerRunnerPath); try { await provider.supervisor.preflight(); await provider.supervisor.converge({ command: hostCommand }); @@ -551,7 +548,6 @@ async function smokeWindowsTaskScheduler(createProvider, cliEntrypoint, root) { } finally { await provider.supervisor.uninstall().catch(() => undefined); await provider.reconciliationTrigger.uninstall().catch(() => undefined); - renameSync(disabledControllerRunnerPath, controllerRunnerPath); } } From 1a5ef8116f9074b6b0a61dba44f6f44e2d013bfc Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Fri, 4 Sep 2026 15:33:46 +0800 Subject: [PATCH 4/7] fix(cli): preserve Windows lifecycle handoff --- .../src/main/runtime-host-local-operator.ts | 1 + native/runtime-host-peer/Cargo.lock | 1 + native/runtime-host-peer/Cargo.toml | 1 + native/runtime-host-peer/src/lib.rs | 4 +- .../src/windows_lifecycle.rs | 85 +++++++++++++++---- .../RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt | 2 +- .../cli/src/runtime-host-windows-service.ts | 60 +++++++++++-- scripts/smoke-release-cli-package.mjs | 10 ++- 8 files changed, 133 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/main/runtime-host-local-operator.ts b/apps/desktop/src/main/runtime-host-local-operator.ts index c19f67d66e..d2c22300ce 100644 --- a/apps/desktop/src/main/runtime-host-local-operator.ts +++ b/apps/desktop/src/main/runtime-host-local-operator.ts @@ -812,6 +812,7 @@ async function runFramedProcess(input: { }): Promise { input.signal?.throwIfAborted(); const command = await resolveLocalNpmCommand(input.command, input.environment); + input.signal?.throwIfAborted(); return new Promise((resolve, reject) => { const child = input.spawnProcess(command.executable, [...command.args], { ...(input.cwd ? { cwd: input.cwd } : {}), diff --git a/native/runtime-host-peer/Cargo.lock b/native/runtime-host-peer/Cargo.lock index 9b5ee8a54e..ab0f7cc154 100644 --- a/native/runtime-host-peer/Cargo.lock +++ b/native/runtime-host-peer/Cargo.lock @@ -1936,6 +1936,7 @@ name = "maka-runtime-host-peer" version = "0.0.0" dependencies = [ "async-trait", + "base64 0.22.1", "bytes", "futures", "libp2p", diff --git a/native/runtime-host-peer/Cargo.toml b/native/runtime-host-peer/Cargo.toml index 993431e5c0..fcd102c32c 100644 --- a/native/runtime-host-peer/Cargo.toml +++ b/native/runtime-host-peer/Cargo.toml @@ -59,6 +59,7 @@ unsigned-varint = "0.8" webrtc = { git = "https://github.com/webrtc-rs/webrtc", rev = "e132552fc67b84c30e63c5ce916a9a63e2484b6f" } [target.'cfg(windows)'.dependencies] +base64 = "0.22" windows = { version = "0.62.2", features = [ "Win32_Foundation", "Win32_Security", diff --git a/native/runtime-host-peer/src/lib.rs b/native/runtime-host-peer/src/lib.rs index 95ddec137b..4017167406 100644 --- a/native/runtime-host-peer/src/lib.rs +++ b/native/runtime-host-peer/src/lib.rs @@ -32,6 +32,6 @@ pub use bindings::{ #[cfg(target_os = "windows")] pub use windows_lifecycle::{ WindowsTaskStatus, own_current_process_tree, windows_task_activate, windows_task_converge, - windows_task_probe, windows_task_retire, windows_task_status, windows_task_uninstall, - windows_task_verify, + windows_task_converge_launcher, windows_task_probe, windows_task_retire, windows_task_status, + windows_task_uninstall, windows_task_verify, windows_task_verify_launcher, }; diff --git a/native/runtime-host-peer/src/windows_lifecycle.rs b/native/runtime-host-peer/src/windows_lifecycle.rs index 164c1d6774..f2f2a00795 100644 --- a/native/runtime-host-peer/src/windows_lifecycle.rs +++ b/native/runtime-host-peer/src/windows_lifecycle.rs @@ -26,6 +26,7 @@ use std::{ time::{Duration, Instant}, }; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use napi::bindgen_prelude::{Error as NapiError, Result, Status}; use napi_derive::napi; use windows::{ @@ -88,6 +89,26 @@ pub fn windows_task_probe() -> Result<()> { #[napi] pub fn windows_task_converge( + root_id: String, + target: String, + runner_path: String, + command: Vec, +) -> Result<()> { + let launcher_path = launcher_for_legacy_runner(&runner_path).map_err(native_error)?; + converge_launcher_task(root_id, target, launcher_path, command) +} + +#[napi] +pub fn windows_task_converge_launcher( + root_id: String, + target: String, + launcher_path: String, + command: Vec, +) -> Result<()> { + converge_launcher_task(root_id, target, launcher_path, command) +} + +fn converge_launcher_task( root_id: String, target: String, launcher_path: String, @@ -107,6 +128,26 @@ pub fn windows_task_converge( #[napi] pub fn windows_task_verify( + root_id: String, + target: String, + runner_path: String, + command: Vec, +) -> Result<()> { + let launcher_path = launcher_for_legacy_runner(&runner_path).map_err(native_error)?; + verify_launcher_task(root_id, target, launcher_path, command) +} + +#[napi] +pub fn windows_task_verify_launcher( + root_id: String, + target: String, + launcher_path: String, + command: Vec, +) -> Result<()> { + verify_launcher_task(root_id, target, launcher_path, command) +} + +fn verify_launcher_task( root_id: String, target: String, launcher_path: String, @@ -753,7 +794,7 @@ fn task_action_command( projected.extend( command .iter() - .map(|argument| base64_url(argument.as_bytes())), + .map(|argument| URL_SAFE_NO_PAD.encode(argument.as_bytes())), ); if command_line(&projected[1..]).encode_utf16().count() >= 32_767 { return Err(invalid_windows_request()); @@ -761,23 +802,33 @@ fn task_action_command( Ok(projected) } -fn base64_url(bytes: &[u8]) -> String { - const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - let mut encoded = String::with_capacity(bytes.len().div_ceil(3) * 4); - for chunk in bytes.chunks(3) { - let first = chunk[0]; - let second = chunk.get(1).copied().unwrap_or(0); - let third = chunk.get(2).copied().unwrap_or(0); - encoded.push(ALPHABET[(first >> 2) as usize] as char); - encoded.push(ALPHABET[(((first & 0x03) << 4) | (second >> 4)) as usize] as char); - if chunk.len() > 1 { - encoded.push(ALPHABET[(((second & 0x0f) << 2) | (third >> 6)) as usize] as char); - } - if chunk.len() > 2 { - encoded.push(ALPHABET[(third & 0x3f) as usize] as char); - } +fn launcher_for_legacy_runner(runner_path: &str) -> windows::core::Result { + let runner = Path::new(runner_path); + let valid_runner = runner.is_absolute() + && runner + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("runtime-host-windows-task-runner.js")) + && runner + .parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("dist")); + let package_root = valid_runner + .then_some(runner) + .and_then(Path::parent) + .and_then(Path::parent) + .ok_or_else(invalid_windows_request)?; + let launcher = package_root.join( + "native/runtime-host-windows-task-launcher/prebuilds/win32-x64/maka-runtime-host-task-launcher.exe", + ); + if !launcher.is_file() { + return Err(invalid_windows_request()); } - encoded + launcher + .to_str() + .map(str::to_owned) + .ok_or_else(invalid_windows_request) } fn ownership_marker(root_id: &str, target: Target) -> String { diff --git a/packages/cli/RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt b/packages/cli/RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt index d1c0b43e42..4ee1e03486 100644 --- a/packages/cli/RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt +++ b/packages/cli/RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt @@ -5,7 +5,7 @@ Generated by scripts/generate-runtime-host-peer-notices.mjs from the exact four-target production dependency inventory. Do not edit this file by hand. Manifest: native/runtime-host-peer/Cargo.toml -Cargo.lock SHA-256: b1a7cdd67fee2bbde5bd8d91c6773f97e809515898604bde7ee0dbf4e04cbbd8 +Cargo.lock SHA-256: 6e489606d4ad4f45433b853ddd0ca96aa2e4160f34bb0446f8f63f7f0fd639b6 Inventory SHA-256: c28a17749fa5f9af237a49dd2b4ad9149b6c4224d383c4a5050eeac3be14073c Packages diff --git a/packages/cli/src/runtime-host-windows-service.ts b/packages/cli/src/runtime-host-windows-service.ts index 9ba6ffb8f8..d957fda36f 100644 --- a/packages/cli/src/runtime-host-windows-service.ts +++ b/packages/cli/src/runtime-host-windows-service.ts @@ -18,6 +18,7 @@ */ import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; import { assertRuntimeHostProviderDefinition, type RuntimeHostLifecycleProvider, @@ -54,10 +55,22 @@ interface WindowsLifecycleNative { readonly windowsTaskConverge: ( rootId: string, target: WindowsTaskTarget, - launcherPath: string, + runnerPath: string, command: string[], ) => void; readonly windowsTaskVerify: ( + rootId: string, + target: WindowsTaskTarget, + runnerPath: string, + command: string[], + ) => void; + readonly windowsTaskConvergeLauncher?: ( + rootId: string, + target: WindowsTaskTarget, + launcherPath: string, + command: string[], + ) => void; + readonly windowsTaskVerifyLauncher?: ( rootId: string, target: WindowsTaskTarget, launcherPath: string, @@ -85,6 +98,7 @@ export function createWindowsRuntimeHostLifecycleProvider( ); } const native = createWindowsLifecycleNativeLoader(options.cliPath); + const runnerPath = join(dirname(options.cliPath), 'runtime-host-windows-task-runner.js'); let launcherPath: Promise | undefined; const resolveLauncherPath = (): Promise => { launcherPath ??= resolveRuntimeHostWindowsTaskLauncherPath(options.cliPath); @@ -95,18 +109,28 @@ export function createWindowsRuntimeHostLifecycleProvider( definition: RuntimeHostProviderDefinition, ): Promise => { assertRuntimeHostProviderDefinition(definition); - (await native()).windowsTaskConverge(rootId, target, await resolveLauncherPath(), [ - ...definition.command, - ]); + const control = await native(); + if (supportsLauncherProjection(control)) { + control.windowsTaskConvergeLauncher(rootId, target, await resolveLauncherPath(), [ + ...definition.command, + ]); + } else { + control.windowsTaskConverge(rootId, target, runnerPath, [...definition.command]); + } }; const verify = async ( target: WindowsTaskTarget, definition: RuntimeHostProviderDefinition, ): Promise => { assertRuntimeHostProviderDefinition(definition); - (await native()).windowsTaskVerify(rootId, target, await resolveLauncherPath(), [ - ...definition.command, - ]); + const control = await native(); + if (supportsLauncherProjection(control)) { + control.windowsTaskVerifyLauncher(rootId, target, await resolveLauncherPath(), [ + ...definition.command, + ]); + } else { + control.windowsTaskVerify(rootId, target, runnerPath, [...definition.command]); + } }; const status = async (target: WindowsTaskTarget): Promise => decodeStatus((await native()).windowsTaskStatus(rootId, target)); @@ -115,8 +139,9 @@ export function createWindowsRuntimeHostLifecycleProvider( supervisor: { provider: 'windows_task', preflight: async () => { - await resolveLauncherPath(); - (await native()).windowsTaskProbe(); + const control = await native(); + if (supportsLauncherProjection(control)) await resolveLauncherPath(); + control.windowsTaskProbe(); }, converge: (definition) => converge('host', definition), verify: (definition) => verify('host', definition), @@ -188,9 +213,26 @@ function loadWindowsLifecycleNative(path: string): WindowsLifecycleNative { 'The Runtime Host native artifact does not support Windows lifecycle control', ); } + if ( + (typeof loaded.windowsTaskConvergeLauncher === 'function') !== + (typeof loaded.windowsTaskVerifyLauncher === 'function') + ) { + throw unavailable( + 'The Runtime Host native artifact has an incomplete Windows launcher contract', + ); + } return loaded as WindowsLifecycleNative; } +function supportsLauncherProjection( + native: WindowsLifecycleNative, +): native is WindowsLifecycleNative & + Required< + Pick + > { + return typeof native.windowsTaskConvergeLauncher === 'function'; +} + function decodeStatus(value: unknown): WindowsTaskStatus { if ( !isRecord(value) || diff --git a/scripts/smoke-release-cli-package.mjs b/scripts/smoke-release-cli-package.mjs index b4fde8cdde..889a039879 100644 --- a/scripts/smoke-release-cli-package.mjs +++ b/scripts/smoke-release-cli-package.mjs @@ -263,6 +263,7 @@ async function smokeRuntimeHostPeerProtocol({ packageRoot, cliEntrypoint, root } if (!nativePath) throw new Error('Installed CLI did not configure its direct-peer artifact'); const addon = require(nativePath); await smokeWindowsTaskScheduler( + addon, windowsLifecycle.createWindowsRuntimeHostLifecycleProvider, cliEntrypoint, join(root, 'windows task & % 生命周期'), @@ -413,7 +414,7 @@ async function smokeRuntimeHostPeerProtocol({ packageRoot, cliEntrypoint, root } } } -async function smokeWindowsTaskScheduler(createProvider, cliEntrypoint, root) { +async function smokeWindowsTaskScheduler(addon, createProvider, cliEntrypoint, root) { if (process.platform !== 'win32') return; mkdirSync(root, { recursive: true }); const rootId = createHash('sha256').update(root).digest('hex'); @@ -458,6 +459,10 @@ async function smokeWindowsTaskScheduler(createProvider, cliEntrypoint, root) { 'utf8', ); const provider = createProvider(rootId, { cliPath: managedCliEntrypoint }); + const legacyRunnerPath = join( + dirname(managedCliEntrypoint), + 'runtime-host-windows-task-runner.js', + ); const hostCommand = [ process.execPath, scriptPath, @@ -470,7 +475,8 @@ async function smokeWindowsTaskScheduler(createProvider, cliEntrypoint, root) { const reconciliationCommand = [process.execPath, '-e', 'process.exit(0)']; try { await provider.supervisor.preflight(); - await provider.supervisor.converge({ command: hostCommand }); + addon.windowsTaskConverge(rootId, 'host', legacyRunnerPath, hostCommand); + addon.windowsTaskVerify(rootId, 'host', legacyRunnerPath, hostCommand); await provider.supervisor.verify({ command: hostCommand }); await provider.reconciliationTrigger.converge({ command: reconciliationCommand }); await provider.reconciliationTrigger.verify({ command: reconciliationCommand }); From 7886145d23ed7aa02c2ce02e23ab302acb90da0d Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Fri, 4 Sep 2026 15:53:21 +0800 Subject: [PATCH 5/7] fix(cli): normalize legacy launcher path --- native/runtime-host-peer/src/windows_lifecycle.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/native/runtime-host-peer/src/windows_lifecycle.rs b/native/runtime-host-peer/src/windows_lifecycle.rs index f2f2a00795..4c4d213141 100644 --- a/native/runtime-host-peer/src/windows_lifecycle.rs +++ b/native/runtime-host-peer/src/windows_lifecycle.rs @@ -819,9 +819,12 @@ fn launcher_for_legacy_runner(runner_path: &str) -> windows::core::Result Date: Fri, 4 Sep 2026 16:24:14 +0800 Subject: [PATCH 6/7] fix(cli): canonicalize legacy Windows launcher --- native/runtime-host-peer/src/windows_lifecycle.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/native/runtime-host-peer/src/windows_lifecycle.rs b/native/runtime-host-peer/src/windows_lifecycle.rs index 4c4d213141..c77336a687 100644 --- a/native/runtime-host-peer/src/windows_lifecycle.rs +++ b/native/runtime-host-peer/src/windows_lifecycle.rs @@ -825,13 +825,19 @@ fn launcher_for_legacy_runner(runner_path: &str) -> windows::core::Result Option { + let path = path.to_str()?; + if let Some(path) = path.strip_prefix(r"\\?\UNC\") { + return Some(format!(r"\\{path}")); + } + Some(path.strip_prefix(r"\\?\").unwrap_or(path).to_owned()) } fn ownership_marker(root_id: &str, target: Target) -> String { From f9dfa0914f4b30dab56ca338dc3f604cd05400ba Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Fri, 4 Sep 2026 16:49:34 +0800 Subject: [PATCH 7/7] fix(desktop): bound Windows npm discovery --- .../runtime-host-local-operator.test.ts | 66 ++++++++- .../src/main/runtime-host-local-operator.ts | 135 +++++++++++++----- 2 files changed, 166 insertions(+), 35 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts b/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts index 7663076a0e..8b57c82549 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts @@ -21,6 +21,9 @@ import assert from 'node:assert/strict'; import type { spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; import { EventEmitter } from 'node:events'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { PassThrough } from 'node:stream'; import test from 'node:test'; import { @@ -130,6 +133,55 @@ test('local setup forwards the exact development archive evidence', async (t) => assert.equal(environment?.[RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV], integrity); }); +test('Windows npm discovery cannot outlive setup cancellation', async (t) => { + const originalPlatform = process.platform; + const fixtureRoot = await mkdtemp(join(tmpdir(), 'maka-windows-npm-lookup-')); + const resolver = join(fixtureRoot, 'hang.cjs'); + await writeFile( + resolver, + 'Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0);\n', + ); + Object.defineProperty(process, 'platform', { value: 'win32' }); + t.after(async () => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + await rm(fixtureRoot, { recursive: true, force: true }); + }); + + let setupSpawned = false; + const operator = createDesktopRuntimeHostLocalOperator({ + environment: { PATH: process.env.PATH, NODE_OPTIONS: `--require=${resolver}` }, + setupTimeoutMs: 60_000, + spawnProcess: (() => { + setupSpawned = true; + throw new Error('npm must not start after cancellation'); + }) as typeof spawn, + }); + t.after(() => operator.close()); + const cancellation = new AbortController(); + const startedAt = Date.now(); + const setup = operator.runSetup( + { + setupPackage: { kind: 'npm', specifier: 'maka-agent@0.2.0' }, + clientDataRoot: '/tmp/maka/client', + rootPath: '/tmp/maka/root', + principalId: 'desktop-owner:pairing', + expectedTarget: { + serviceId: 'b'.repeat(64), + rootPath: '/tmp/maka/root', + rootId: 'a'.repeat(64), + }, + signal: cancellation.signal, + }, + () => undefined, + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + cancellation.abort(new Error('setup cancelled')); + + await assert.rejects(setup, /setup cancelled/u); + assert.equal(setupSpawned, false); + assert.ok(Date.now() - startedAt < 1_000); +}); + test('local update runs the selected package against the exact managed deployment', async (t) => { let executable: string | undefined; let args: readonly string[] | undefined; @@ -181,6 +233,15 @@ test('local update runs the selected package against the exact managed deploymen }); t.after(() => operator.close()); const deploymentId = '00000000-0000-4000-8000-000000000001'; + const operatorArgs = () => { + if (process.platform !== 'win32') { + assert.equal(executable, 'npm'); + return args; + } + assert.match(executable ?? '', /[\\/]node\.exe$/ui); + assert.match(args?.[0] ?? '', /[\\/]npm-cli\.js$/u); + return args?.slice(1); + }; await operator.runUpdate( { @@ -197,8 +258,7 @@ test('local update runs the selected package against the exact managed deploymen (phase) => phases.push(phase), ); - assert.equal(executable, 'npm'); - assert.deepEqual(args, [ + assert.deepEqual(operatorArgs(), [ 'exec', '--yes', '--package', 'maka-agent@0.3.0', '--', 'maka', 'runtime-host', 'service', 'update', '--framed', '--target', '0.3.0', @@ -236,7 +296,7 @@ test('local update runs the selected package against the exact managed deploymen () => undefined, ); - assert.deepEqual(args, [ + assert.deepEqual(operatorArgs(), [ 'exec', '--yes', '--package', '/tmp/maka-agent-development.tgz', '--', 'maka', 'runtime-host', 'service', 'update', '--framed', '--managed-root-id', 'a'.repeat(64), diff --git a/apps/desktop/src/main/runtime-host-local-operator.ts b/apps/desktop/src/main/runtime-host-local-operator.ts index d2c22300ce..4b00e73885 100644 --- a/apps/desktop/src/main/runtime-host-local-operator.ts +++ b/apps/desktop/src/main/runtime-host-local-operator.ts @@ -17,10 +17,10 @@ * under the License. */ -import { spawn, type ChildProcess } from 'node:child_process'; +import { execFile, spawn, type ChildProcess } from 'node:child_process'; import { mkdtemp, rm, rmdir, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { delimiter, dirname, isAbsolute, join } from 'node:path'; +import { dirname, isAbsolute, join } from 'node:path'; import { redactSecrets } from '@maka/core/redaction'; import { DEFAULT_PROCESS_TERMINATION_GRACE_MS, @@ -62,6 +62,41 @@ import { const SETUP_TIMEOUT_MS = 10 * 60_000; const SETUP_FRAME_PENDING_MAX = 20 * 1024; const STDERR_MAX_BYTES = 64 * 1024; +const WINDOWS_NPM_RESOLUTION_SCRIPT = String.raw` +const { statSync } = require('node:fs'); +const path = require('node:path').win32; +const candidates = []; +const configuredNode = process.env.npm_node_execpath?.trim(); +const configuredCli = process.env.npm_execpath?.trim(); +if (configuredNode && configuredCli) candidates.push([configuredNode, configuredCli]); +const searchPath = Object.entries(process.env).find(([key]) => key.toUpperCase() === 'PATH')?.[1]; +for (const entry of searchPath?.split(path.delimiter) ?? []) { + const directory = entry.replace(/^"|"$/g, '').trim(); + if (!directory) continue; + candidates.push([ + path.join(directory, 'node.exe'), + path.join(directory, 'node_modules', 'npm', 'bin', 'npm-cli.js'), + ]); +} +let resolved; +for (const [nodePath, cliPath] of candidates) { + try { + if ( + path.isAbsolute(nodePath) && + path.isAbsolute(cliPath) && + statSync(nodePath).isFile() && + statSync(cliPath).isFile() + ) { + resolved = [nodePath, cliPath]; + break; + } + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } +} +if (resolved) process.stdout.write(JSON.stringify(resolved)); +else process.exitCode = 1; +`; type RuntimeHostSetupCompleteFrame = Extract; @@ -665,40 +700,62 @@ function resolveLocalSetupPackage( async function resolveLocalNpmCommand( command: DesktopRuntimeHostLocalSetupCommand, environment: NodeJS.ProcessEnv, + signal: AbortSignal | undefined, + timeoutMs: number, + label: string, ): Promise { if (process.platform !== 'win32' || command.executable !== 'npm') return command; - const configuredNode = environment.npm_node_execpath; - const configuredCli = environment.npm_execpath; - const candidates: Array = []; - if (configuredNode && configuredCli) candidates.push([configuredNode, configuredCli]); - - const path = Object.entries(environment).find(([key]) => key.toUpperCase() === 'PATH')?.[1]; - for (const entry of path?.split(delimiter) ?? []) { - const directory = entry.replace(/^"|"$/gu, '').trim(); - if (!directory) continue; - candidates.push([ - join(directory, 'node.exe'), - join(directory, 'node_modules', 'npm', 'bin', 'npm-cli.js'), - ]); - } - - for (const [nodePath, cliPath] of candidates) { - if (!isAbsolute(nodePath) || !isAbsolute(cliPath)) continue; - if ((await regularFile(nodePath)) && (await regularFile(cliPath))) { - return { executable: nodePath, args: [cliPath, ...command.args] }; - } - } - throw new Error('Local Runtime Host management requires a complete Node.js and npm installation'); -} - -async function regularFile(path: string): Promise { + const timeout = AbortSignal.timeout(timeoutMs); + const lookupSignal = signal ? AbortSignal.any([signal, timeout]) : timeout; + let npmCommand: readonly [string, string]; try { - return (await stat(path)).isFile(); + npmCommand = await locateWindowsNpmCommand(environment, lookupSignal); } catch (error) { - if (isNodeError(error, 'ENOENT')) return false; - throw error; + if (signal?.aborted) throw abortError(signal); + if (timeout.aborted) throw new Error(`${label} timed out`); + throw new Error( + 'Local Runtime Host management requires a complete Node.js and npm installation', + { cause: error }, + ); } + return { executable: npmCommand[0], args: [npmCommand[1], ...command.args] }; +} + +function locateWindowsNpmCommand( + environment: NodeJS.ProcessEnv, + signal: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + execFile( + process.execPath, + ['-e', WINDOWS_NPM_RESOLUTION_SCRIPT], + { + encoding: 'utf8', + env: { ...environment, ELECTRON_RUN_AS_NODE: '1' }, + maxBuffer: 64 * 1024, + signal, + windowsHide: true, + }, + (error, stdout) => { + if (error) return reject(error); + let command: unknown; + try { + command = JSON.parse(stdout); + } catch (parseError) { + return reject(parseError); + } + if ( + !Array.isArray(command) || + command.length !== 2 || + !command.every((path) => typeof path === 'string' && isAbsolute(path)) + ) { + return reject(new Error('The npm resolver returned an invalid command')); + } + resolve([command[0], command[1]]); + }, + ); + }); } function managedTargetArgs(target: DesktopRuntimeHostLocalServiceTarget): string[] { @@ -810,9 +867,20 @@ async function runFramedProcess(input: { readonly inputLine?: string; readonly pendingMaxBytes?: number; }): Promise { + const deadline = Date.now() + input.timeoutMs; input.signal?.throwIfAborted(); - const command = await resolveLocalNpmCommand(input.command, input.environment); + const lookupTimeoutMs = deadline - Date.now(); + if (lookupTimeoutMs <= 0) throw new Error(`${input.label} timed out`); + const command = await resolveLocalNpmCommand( + input.command, + input.environment, + input.signal, + lookupTimeoutMs, + input.label, + ); input.signal?.throwIfAborted(); + const remainingTimeoutMs = deadline - Date.now(); + if (remainingTimeoutMs <= 0) throw new Error(`${input.label} timed out`); return new Promise((resolve, reject) => { const child = input.spawnProcess(command.executable, [...command.args], { ...(input.cwd ? { cwd: input.cwd } : {}), @@ -864,7 +932,10 @@ async function runFramedProcess(input: { ); }; const onAbort = () => stop(abortError(input.signal)); - const timeout = setTimeout(() => stop(new Error(`${input.label} timed out`)), input.timeoutMs); + const timeout = setTimeout( + () => stop(new Error(`${input.label} timed out`)), + remainingTimeoutMs, + ); input.signal?.addEventListener('abort', onAbort, { once: true }); child.stdout?.on('data', (chunk: Buffer) => filter.push(chunk.toString('utf8'))); child.stderr?.on('data', (chunk: Buffer) => {