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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/windows-recovery.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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: |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Comment thread
Mingqwqqaq marked this conversation as resolved.
}, 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<RuntimeHostSshOperatorProcessFactory>[0] | undefined;
const result = await activateRuntimeHostSshOperator(
Expand Down
12 changes: 10 additions & 2 deletions packages/runtime-host/src/client/ssh-operator-activation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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,
Expand Down