diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index 3350f66f66b..39edb6a05a7 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -73,6 +73,7 @@ on: - 'packages/runtime-host/src/__tests__/skill-catalog-repository.test.ts' - 'packages/runtime-host/src/__tests__/skill-catalog-transaction.test.ts' - 'packages/runtime-host/src/__tests__/skill-catalog-two-client-uds.test.ts' + - 'packages/runtime-host/src/__tests__/ssh-operator-activation.test.ts' - 'packages/runtime-host/src/assistant/proactive-matters/files.ts' - 'packages/runtime-host/src/client/client-instance-identity.ts' - 'packages/runtime-host/src/client/host-profile.ts' @@ -220,6 +221,13 @@ jobs: node.exe --test packages/runtime-host/dist/__tests__/control-endpoint.test.js ./scripts/windows-runtime-host-local-ipc-trust.ps1 + # The POSIX command execution case in this suite requires a POSIX host. + - name: Verify Windows SSH operator arguments and activation + shell: pwsh + run: >- + node.exe --test --test-name-pattern="Windows SSH operator|SSH activation" + packages/runtime-host/dist/__tests__/ssh-operator-activation.test.js + - name: Verify Skill catalog on Windows shell: pwsh run: | diff --git a/packages/runtime-host/src/__tests__/ssh-operator-activation.test.ts b/packages/runtime-host/src/__tests__/ssh-operator-activation.test.ts index 927423add19..d2f7ee69c3c 100644 --- a/packages/runtime-host/src/__tests__/ssh-operator-activation.test.ts +++ b/packages/runtime-host/src/__tests__/ssh-operator-activation.test.ts @@ -18,7 +18,7 @@ */ import assert from 'node:assert/strict'; -import { execFile } from 'node:child_process'; +import { execFile, spawnSync } from 'node:child_process'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -58,6 +58,61 @@ const operator = (modulePath: string) => ({ modulePath, }); +test('Windows SSH operator preserves native argv, environment, output and exit status', { + skip: process.platform !== 'win32', +}, async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'maka-operator-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const modulePath = join(directory, 'operator 中文.mjs'); + await writeFile( + modulePath, + `import { readFileSync } from 'node:fs';process.stdout.write(JSON.stringify({args:process.argv.slice(2),env:process.env.MAKA_TEST_VALUE,stdin:readFileSync(0,'utf8')}));process.stderr.write('diagnostic');process.exitCode=37;`, + ); + const args = [ + 'configure', + '--project-root-json', + JSON.stringify({ label: 'My project', path: 'C:\\项目\\' }), + '', + 'space and trailing slash\\', + 'embedded"quote', + 'backslash\\"quote', + '%PATH%', + '!PATH!', + '&|<>', + ]; + const environment = { MAKA_TEST_VALUE: 'literal "value" 中文' }; + const command = runtimeHostSshOperatorRemoteCommand( + { + kind: 'node', + platform: 'win32', + nodePath: process.execPath, + modulePath, + }, + args, + environment, + ); + const encoded = command.split(' -EncodedCommand ')[1]; + assert.ok(encoded); + const result = spawnSync( + 'powershell.exe', + ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], + { + encoding: 'utf8', + input: 'operator input 中文\n', + windowsHide: true, + timeout: 15_000, + }, + ); + assert.ifError(result.error); + assert.equal(result.status, 37, result.stderr); + assert.equal(result.stderr, 'diagnostic'); + assert.deepEqual(JSON.parse(result.stdout), { + args, + env: environment.MAKA_TEST_VALUE, + stdin: 'operator input 中文\n', + }); +}); + test('SSH activation accepts a strict final frame that drains after process exit', async () => { let invocation: Parameters[0] | undefined; const result = await activateRuntimeHostSshOperator( diff --git a/packages/runtime-host/src/client/ssh-operator-activation.ts b/packages/runtime-host/src/client/ssh-operator-activation.ts index 3f49f217cf6..cdd22512187 100644 --- a/packages/runtime-host/src/client/ssh-operator-activation.ts +++ b/packages/runtime-host/src/client/ssh-operator-activation.ts @@ -146,7 +146,10 @@ export function runtimeHostSshOperatorRemoteCommand( if (operator.kind !== 'node') throw new Error('Windows Runtime Host operator must use Node'); const payload = Buffer.from( JSON.stringify({ - ...invocation, + executable: invocation.executable, + // ProcessStartInfo bypasses Windows PowerShell 5.1's lossy native argument + // binder. Encode the argv using the Windows C runtime quoting rules. + argumentLine: invocation.args.map(quoteWindowsArgument).join(' '), environment, modulePath: operator.modulePath, deploymentRoot: win32.dirname(operator.modulePath), @@ -155,17 +158,22 @@ export function runtimeHostSshOperatorRemoteCommand( 'utf8', ).toString('base64'); const script = [ + `$ProgressPreference='SilentlyContinue'`, `$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${payload}'))|ConvertFrom-Json`, `if($p.missingOperatorIsSuccess -and -not (Test-Path -LiteralPath $p.modulePath -PathType Leaf)){if(-not (Test-Path -LiteralPath $p.deploymentRoot)){exit 0}else{exit 1}}`, `foreach($e in $p.environment.psobject.Properties){[Environment]::SetEnvironmentVariable($e.Name,[string]$e.Value,'Process')}`, `$code=1`, - `try{& ([string]$p.executable) @($p.args|ForEach-Object {[string]$_});$code=if($null -eq $LASTEXITCODE){1}else{$LASTEXITCODE}}catch{$code=1}`, + `try{$start=New-Object System.Diagnostics.ProcessStartInfo;$start.FileName=[string]$p.executable;$start.Arguments=[string]$p.argumentLine;$start.UseShellExecute=$false;$child=[System.Diagnostics.Process]::Start($start);try{$child.WaitForExit();$code=$child.ExitCode}finally{$child.Dispose()}}catch{$code=1}`, `exit $code`, ].join(';'); const encoded = Buffer.from(script, 'utf16le').toString('base64'); return `powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand ${encoded}`; } +function quoteWindowsArgument(value: string): string { + return `"${value.replace(/(\\*)"/gu, '$1$1\\"').replace(/(\\+)$/u, '$1$1')}"`; +} + async function waitForActivation( child: RuntimeHostSshOperatorProcess, input: RuntimeHostSshOperatorActivationInput,