diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index 3350f66f66b..1b490060db5 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -61,6 +61,9 @@ on: - 'scripts/clean-build.mjs' - 'scripts/clean-paths.mjs' - 'scripts/windows-runtime-host-local-ipc-trust.ps1' + # The desktop launcher executes cmd.exe with native console code pages. + - 'apps/desktop/src/main/opencli-chrome.ts' + - 'apps/desktop/src/main/__tests__/opencli-chrome.test.ts' # Every file in the import closure of the suites below that branches on # `process.platform === 'win32'`. Generated, not curated: # `scripts/windows-package-source-closure.test.mjs` recomputes this set @@ -184,6 +187,10 @@ jobs: - name: Build test artifacts run: npm.cmd run build:test + - name: Verify Windows opencli launchers + shell: pwsh + run: node.exe --test apps/desktop/dist/main/__tests__/opencli-chrome.test.js + - name: Verify managed dependency alternate streams shell: pwsh run: | diff --git a/apps/desktop/src/main/__tests__/opencli-chrome.test.ts b/apps/desktop/src/main/__tests__/opencli-chrome.test.ts index e221765b292..f81ca22a142 100644 --- a/apps/desktop/src/main/__tests__/opencli-chrome.test.ts +++ b/apps/desktop/src/main/__tests__/opencli-chrome.test.ts @@ -18,8 +18,8 @@ */ import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { test } from 'node:test'; @@ -45,10 +45,67 @@ test('Windows launchers set Node mode before starting the entry', () => { assert.equal(launchers.command, join(dir, 'opencli-mcp.cmd')); assert.equal( readFileSync(launchers.host, 'utf8'), - '@echo off\r\nset ELECTRON_RUN_AS_NODE=1\r\n"C:\\Maka\\Maka.exe" "C:\\Maka\\main.js" host\r\n', + '@echo off\r\nsetlocal DisableDelayedExpansion\r\nfor /f "tokens=2 delims=:." %%c in (\'chcp\') do set "MAKA_OPENCLI_CODE_PAGE=%%c"\r\nchcp 65001 >nul\r\nset ELECTRON_RUN_AS_NODE=1\r\n"C:\\Maka\\Maka.exe" "C:\\Maka\\main.js" host\r\nset "MAKA_OPENCLI_EXIT_CODE=%ERRORLEVEL%"\r\nchcp %MAKA_OPENCLI_CODE_PAGE% >nul\r\nexit /b %MAKA_OPENCLI_EXIT_CODE%\r\n', ); }); +test('Windows launchers preserve Unicode and batch metacharacters without adding protocol output', { skip: process.platform !== 'win32' }, (t) => { + const root = mkdtempSync(join(tmpdir(), 'maka-opencli-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const directory = join(root, '中文 %MAKA_LAUNCHER_TEST% !MAKA_LAUNCHER_TEST! & files'); + mkdirSync(directory); + const entry = join(directory, 'entry.mjs'); + writeFileSync(entry, 'console.log(JSON.stringify({ args: process.argv.slice(2), node: process.env.ELECTRON_RUN_AS_NODE })); process.exitCode = 23;'); + writeOpencliLaunchers(directory, 'win32', process.execPath, entry); + for (const page of [936, 65001]) { + for (const [file, mode] of [['opencli-mcp.cmd', 'stdio'], ['opencli-mcp-host.cmd', 'host']]) { + // CALL returns to the same console, where the caller must retain its code + // page as well as the child's exit status. Keep diagnostics off stdout. + writeFileSync(join(directory, 'caller.cmd'), `@echo off\r\nchcp ${page} >nul\r\ncall ${file}\r\nset "childExit=%errorlevel%"\r\nchcp >code-page.txt\r\nexit /b %childExit%\r\n`); + const result = spawnSync(process.env.ComSpec ?? 'cmd.exe', ['/d', '/v:on', '/s', '/c', 'caller.cmd'], { + cwd: directory, + env: { ...process.env, MAKA_LAUNCHER_TEST: 'expanded' }, + encoding: 'utf8', + windowsHide: true, + windowsVerbatimArguments: true, + timeout: 10_000, + }); + assert.ifError(result.error); + assert.equal(result.status, 23, result.stderr); + assert.equal(result.stderr, ''); + assert.deepEqual(JSON.parse(result.stdout), { args: [mode], node: '1' }); + assert.equal(Number(readFileSync(join(directory, 'code-page.txt'), 'utf8').match(/:\s*(\d+)/u)?.[1]), page); + } + } +}); + +test('Windows launchers restore code pages reported with localized trailing punctuation', { skip: process.platform !== 'win32' }, (t) => { + const directory = mkdtempSync(join(tmpdir(), 'maka-opencli-locale-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const entry = join(directory, 'entry.mjs'); + writeFileSync(entry, 'process.stdout.write("protocol"); process.exitCode = 23;'); + for (const page of [850, 936, 65001]) { + const launchers = writeOpencliLaunchers(directory, 'win32', process.execPath, entry); + // Simulate only the localized query response; cmd.exe still executes the + // generated parser, changes the real code page and restores it on return. + const script = readFileSync(launchers.command, 'utf8'); + assert.ok(script.includes("in ('chcp')")); + writeFileSync(launchers.command, script.replace("in ('chcp')", `in ('echo Aktive Codepage: ${page}.')`)); + writeFileSync(join(directory, 'caller.cmd'), `@echo off\r\nchcp ${page} >nul\r\ncall opencli-mcp.cmd\r\nset "childExit=%errorlevel%"\r\nchcp >code-page.txt\r\nexit /b %childExit%\r\n`); + const result = spawnSync(process.env.ComSpec ?? 'cmd.exe', ['/d', '/v:on', '/c', 'caller.cmd'], { + cwd: directory, + encoding: 'utf8', + windowsHide: true, + timeout: 10_000, + }); + assert.ifError(result.error); + assert.equal(result.status, 23, result.stderr); + assert.equal(result.stderr, ''); + assert.equal(result.stdout, 'protocol'); + assert.equal(Number(readFileSync(join(directory, 'code-page.txt'), 'utf8').match(/:\s*(\d+)/u)?.[1]), page); + } +}); + test('the host manifest goes to Chrome and existing browsers, never over another live install', () => { const root = mkdtempSync(join(tmpdir(), 'maka-opencli-')); const host = join(root, 'opencli-mcp-host'); diff --git a/apps/desktop/src/main/opencli-chrome.ts b/apps/desktop/src/main/opencli-chrome.ts index bc4fba2d0d7..32bac933ccf 100644 --- a/apps/desktop/src/main/opencli-chrome.ts +++ b/apps/desktop/src/main/opencli-chrome.ts @@ -64,7 +64,13 @@ export function writeOpencliLaunchers( const write = (name: string, args: string): string => { if (platform === 'win32') { const file = join(dir, `${name}.cmd`); - writeFileSync(file, `@echo off\r\nset ELECTRON_RUN_AS_NODE=1\r\n"${executable}" "${entry}" ${args}\r\n`); + // cmd reads batch files using its console code page, even when paths are + // quoted. Keep UTF-8 paths literal through both batch expansion phases. + // Unlike environment variables, the console code page outlives SETLOCAL. + // Restore it before returning, without losing the child's exit status. + // chcp's localized response can end the numeric page with a period. + const quotePath = (path: string) => `"${path.replaceAll('%', '%%')}"`; + writeFileSync(file, `@echo off\r\nsetlocal DisableDelayedExpansion\r\nfor /f "tokens=2 delims=:." %%c in ('chcp') do set "MAKA_OPENCLI_CODE_PAGE=%%c"\r\nchcp 65001 >nul\r\nset ELECTRON_RUN_AS_NODE=1\r\n${quotePath(executable)} ${quotePath(entry)} ${args}\r\nset "MAKA_OPENCLI_EXIT_CODE=%ERRORLEVEL%"\r\nchcp %MAKA_OPENCLI_CODE_PAGE% >nul\r\nexit /b %MAKA_OPENCLI_EXIT_CODE%\r\n`); return file; } const file = join(dir, name);