From ca4a05a5dfeec72fc72275473fdf539489397323 Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 17:32:24 -0700 Subject: [PATCH] fix(ssh): package and verify the Windows terminal provider before relay startup --- config/scripts/build-relay.mjs | 2 + config/scripts/build-windows-conpty.test.mjs | 62 ++++++++++++- .../scripts/relay-artifact-manifest.test.mjs | 13 +++ config/scripts/relay-conpty-packaging.mjs | 25 +++++ .../ssh/ssh-relay-deploy-windows-pipe.test.ts | 3 + src/main/ssh/ssh-relay-deploy.ts | 3 +- src/relay/relay.ts | 2 + .../windows-relay-conpty-admission.test.ts | 93 +++++++++++++++++++ src/relay/windows-relay-conpty-admission.ts | 29 ++++++ src/shared/relay-artifacts.ts | 8 ++ src/shared/relay-optional-artifacts.test.ts | 28 ++++++ 11 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 config/scripts/relay-conpty-packaging.mjs create mode 100644 src/relay/windows-relay-conpty-admission.test.ts create mode 100644 src/relay/windows-relay-conpty-admission.ts diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index 88ddd9c11c3..8578861b9e5 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -3,6 +3,7 @@ import { build } from 'esbuild' import { bundledParcelWatcherPlugin } from './parcel-watcher-bundle.mjs' import { readWindowsWatcherArtifact } from './windows-watcher-artifact.mjs' +import { stageWindowsRelayConpty } from './relay-conpty-packaging.mjs' import { createHash } from 'node:crypto' import { copyFileSync, @@ -107,6 +108,7 @@ for (const platform of RELAY_BUILD_PLATFORMS) { const watcher = readWindowsWatcherArtifact(platform.slice('win32-'.length)) copyFileSync(watcher.binary, join(outDir, RELAY_WINDOWS_WATCHER_FILENAME)) copyFileSync(watcher.license, join(outDir, RELAY_WINDOWS_WATCHER_LICENSE)) + await stageWindowsRelayConpty(platform, outDir) } await build({ diff --git a/config/scripts/build-windows-conpty.test.mjs b/config/scripts/build-windows-conpty.test.mjs index 7d50fa13645..ebc3331a253 100644 --- a/config/scripts/build-windows-conpty.test.mjs +++ b/config/scripts/build-windows-conpty.test.mjs @@ -1,5 +1,13 @@ import { createHash } from 'node:crypto' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -23,6 +31,8 @@ vi.mock('./zip-extractor-command.mjs', () => ({ })) vi.mock('./script-child-process.mjs', () => ({ runProcessSync: vi.fn() })) import { runProcessSync } from './script-child-process.mjs' +import { stageWindowsRelayConpty } from './relay-conpty-packaging.mjs' +import { RELAY_WINDOWS_CONPTY_FILENAMES } from '../../src/shared/relay-artifacts.ts' import { downloadConptyArchive, materializeWindowsConpty, @@ -145,3 +155,53 @@ describe('independent ConPTY payload builder', () => { await expect(downloadConptyArchive(join(root, 'download'), fetcher)).rejects.toThrow('HTTP 404') }) }) + +describe('Windows relay provider packaging', () => { + it.each(['x64', 'arm64'])('stages the verified %s pair and metadata flat', async (arch) => { + mkdirSync(output) + await stageWindowsRelayConpty(`win32-${arch}`, output, { cacheDir, fetcher }) + expect(readdirSync(output).sort()).toEqual([...RELAY_WINDOWS_CONPTY_FILENAMES].sort()) + verifyConptyDirectory(output, arch) + expect(readFileSync(join(output, 'conpty-LICENSE.txt'), 'utf8')).toContain('MIT License') + expect(JSON.parse(readFileSync(join(output, 'conpty.json'), 'utf8'))).toMatchObject({ + arch, + archiveSha256: fixture.archiveHash, + files: fixture.files[arch] + }) + }) + + it.each(['conpty.dll', 'OpenConsole.exe'])( + 'rejects corrupt %s and removes staging', + async (filename) => { + mkdirSync(output) + fixture.files.x64[filename] = digest('wrong binary') + await expect( + stageWindowsRelayConpty('win32-x64', output, { cacheDir, fetcher }) + ).rejects.toThrow('checksum mismatch') + expect(readdirSync(output)).toEqual([]) + } + ) + + it('rejects a missing companion and removes staging', async () => { + mkdirSync(output) + const extract = vi.mocked(runProcessSync).getMockImplementation() + vi.mocked(runProcessSync).mockImplementation((command) => { + const result = extract(command) + rmSync(join(command.args[1], 'build', 'native', 'runtimes', 'x64', 'OpenConsole.exe')) + return result + }) + await expect( + stageWindowsRelayConpty('win32-x64', output, { cacheDir, fetcher }) + ).rejects.toThrow() + expect(readdirSync(output)).toEqual([]) + }) + + it.each(['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64'])( + 'does not touch %s packaging', + async (platform) => { + await stageWindowsRelayConpty(platform, output, { cacheDir, fetcher }) + expect(existsSync(output)).toBe(false) + expect(fetcher).not.toHaveBeenCalled() + } + ) +}) diff --git a/config/scripts/relay-artifact-manifest.test.mjs b/config/scripts/relay-artifact-manifest.test.mjs index 7177b9821f8..fba7a91e985 100644 --- a/config/scripts/relay-artifact-manifest.test.mjs +++ b/config/scripts/relay-artifact-manifest.test.mjs @@ -12,11 +12,14 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { RELAY_BUILD_PLATFORMS, RELAY_VERSION_FILENAME, + RELAY_WINDOWS_CONPTY_FILENAMES, isWindowsRelayPlatform, relayArtifactFilenames, relayOptionalArtifactFilenames } from '../../src/shared/relay-artifacts.ts' +import { verifyConptyDirectory } from './build-windows-conpty.mjs' + const projectDir = resolve(import.meta.dirname, '../..') // Its own tree: building into out/relay would clobber a developer's build and // race the suites that read it. @@ -35,6 +38,16 @@ afterAll(() => { }) describe('packaged relay artifact manifest', () => { + it.each(['x64', 'arm64'])('ships the pinned %s provider as required flat artifacts', (arch) => { + const outDir = join(relayOutDir, `win32-${arch}`) + verifyConptyDirectory(outDir, arch) + for (const filename of RELAY_WINDOWS_CONPTY_FILENAMES) { + expect(relayArtifactFilenames(true)).toContain(filename) + expect(relayArtifactFilenames(false)).not.toContain(filename) + expect(relayOptionalArtifactFilenames(true)).not.toContain(filename) + } + }) + it.each([...RELAY_BUILD_PLATFORMS])('emits exactly the declared artifacts for %s', (platform) => { const outDir = join(relayOutDir, platform) const expected = relayArtifactFilenames(isWindowsRelayPlatform(platform)) diff --git a/config/scripts/relay-conpty-packaging.mjs b/config/scripts/relay-conpty-packaging.mjs new file mode 100644 index 00000000000..6ff8913055f --- /dev/null +++ b/config/scripts/relay-conpty-packaging.mjs @@ -0,0 +1,25 @@ +import { copyFileSync, mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { + isWindowsRelayPlatform, + RELAY_WINDOWS_CONPTY_FILENAMES +} from '../../src/shared/relay-artifacts.ts' +import { materializeWindowsConpty, verifyConptyDirectory } from './build-windows-conpty.mjs' + +export async function stageWindowsRelayConpty(platform, outputDir, options = {}) { + if (!isWindowsRelayPlatform(platform)) { + return + } + const arch = platform.slice('win32-'.length) + const staging = mkdtempSync(join(outputDir, '.conpty-')) + try { + await materializeWindowsConpty(arch, staging, options) + for (const filename of RELAY_WINDOWS_CONPTY_FILENAMES) { + const source = filename === 'conpty-LICENSE.txt' ? 'LICENSE.txt' : filename + copyFileSync(join(staging, source), join(outputDir, filename)) + } + verifyConptyDirectory(outputDir, arch) + } finally { + rmSync(staging, { recursive: true, force: true }) + } +} diff --git a/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts b/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts index 7fe1140306b..fac1dee16e5 100644 --- a/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts +++ b/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts @@ -195,6 +195,9 @@ describe('deployAndLaunchRelay on Windows remotes', () => { expect(launchScript).toContain('--endpoint-dir') expect(launchScript).toContain("& 'C:/Program Files/Orca/bun.exe'") expect(launchScript).toContain('--launch-error-file') + expect(launchScript).toContain( + "$env:BUN_CONPTY_LIBRARY = 'C:/Users/me user/.orca-remote/relay-0.1.0+abcdef012345/conpty.dll'; & 'C:/Program Files/Orca/bun.exe'" + ) expect(launchScript).not.toMatch(/Invoke-CimMethod|cmd\.exe|\$env:PATH/) expect(launchScript).toContain('Set-Location') expect(launchScript).toContain( diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index ae37470dae1..93b8d6a050b 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -1485,6 +1485,7 @@ function windowsRelayLaunchCommand( ripgrepPath?: string ): string { const relayScript = joinRemotePath(hostPlatform, remoteDir, 'relay.js') + const provider = joinRemotePath(hostPlatform, remoteDir, 'conpty.dll') const args = [ '--no-env-file', '--config=NUL', @@ -1508,7 +1509,7 @@ function windowsRelayLaunchCommand( return commandInRemoteDirectory( hostPlatform, remoteDir, - `& ${powerShellLiteral(nodePath)} ${args.map(powerShellLiteral).join(' ')}; if ($LASTEXITCODE -ne 0) { throw "Bun detached launch failed with exit $LASTEXITCODE" }` + `$env:BUN_CONPTY_LIBRARY = ${powerShellLiteral(provider)}; & ${powerShellLiteral(nodePath)} ${args.map(powerShellLiteral).join(' ')}; if ($LASTEXITCODE -ne 0) { throw "Bun detached launch failed with exit $LASTEXITCODE" }` ) } diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 8919a87d8b4..6244cc405e6 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -11,9 +11,11 @@ import { runRelayDaemon } from './relay-daemon' import { relayLogLine } from './relay-diagnostic-log' import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' import { launchDetachedWindowsRelay } from './windows-detached-launch' +import { assertWindowsRelayConptyProvider } from './windows-relay-conpty-admission' async function main(): Promise { const options = parseRelayLaunchOptions(process.argv) + assertWindowsRelayConptyProvider(options) if (options.connectMode) { runRelayConnectChannel(options.sockPath, readRelayEndpointCredential(options.credentialFile)) return diff --git a/src/relay/windows-relay-conpty-admission.test.ts b/src/relay/windows-relay-conpty-admission.test.ts new file mode 100644 index 00000000000..dac35078bf9 --- /dev/null +++ b/src/relay/windows-relay-conpty-admission.test.ts @@ -0,0 +1,93 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { assertWindowsRelayConptyProvider } from './windows-relay-conpty-admission' + +vi.mock('../shared/windows-conpty-release', async () => { + const { createHash } = await import('node:crypto') + return { + WINDOWS_CONPTY_FILES: Object.fromEntries( + ['x64', 'arm64'].map((arch) => [ + arch, + Object.fromEntries( + ['conpty.dll', 'OpenConsole.exe'].map((file) => [ + file, + createHash('sha256').update(`${arch}/${file}`).digest('hex') + ]) + ) + ]) + ) + } +}) +const platform = Object.getOwnPropertyDescriptor(process, 'platform')! +const arch = Object.getOwnPropertyDescriptor(process, 'arch')! +const argv = process.argv +const roots: string[] = [] +const daemon = { connectMode: false, cliMode: false } +beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + Object.defineProperty(process, 'arch', { value: 'x64' }) +}) +afterEach(() => { + Object.defineProperty(process, 'platform', platform) + Object.defineProperty(process, 'arch', arch) + process.argv = argv + vi.unstubAllEnvs() + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) +}) +function fixture(architecture = 'x64'): string { + const root = mkdtempSync(join(tmpdir(), 'orca-relay-conpty-')) + roots.push(root) + writeFileSync(join(root, 'relay.js'), '') + for (const file of ['conpty.dll', 'OpenConsole.exe']) { + writeFileSync(join(root, file), `${architecture}/${file}`) + } + process.argv = [process.execPath, join(root, 'relay.js')] + vi.stubEnv('BUN_CONPTY_LIBRARY', join(root, 'conpty.dll')) + return root +} +describe('Windows relay provider admission', () => { + it.each(['x64', 'arm64'])( + 'admits the adjacent qualified %s pair without changing the selector', + (architecture) => { + fixture(architecture) + Object.defineProperty(process, 'arch', { value: architecture }) + const initial = process.env.BUN_CONPTY_LIBRARY + expect(() => assertWindowsRelayConptyProvider(daemon)).not.toThrow() + expect(process.env.BUN_CONPTY_LIBRARY).toBe(initial) + } + ) + it.each([undefined, '', 'relative/conpty.dll'])( + 'rejects absent or relative selector %s', + (selector) => { + fixture() + vi.stubEnv('BUN_CONPTY_LIBRARY', selector) + expect(() => assertWindowsRelayConptyProvider(daemon)).toThrow('before starting Bun') + } + ) + it('rejects a qualified provider from another installation', () => { + const foreign = fixture() + fixture() + vi.stubEnv('BUN_CONPTY_LIBRARY', join(foreign, 'conpty.dll')) + expect(() => assertWindowsRelayConptyProvider(daemon)).toThrow('this relay installation') + }) + it.each(['conpty.dll', 'OpenConsole.exe'])('rejects corrupt and missing %s', (file) => { + const root = fixture() + writeFileSync(join(root, file), 'corrupt') + expect(() => assertWindowsRelayConptyProvider(daemon)).toThrow('identity mismatch') + rmSync(join(root, file)) + expect(() => assertWindowsRelayConptyProvider(daemon)).toThrow() + }) + it('rejects the wrong architecture', () => { + fixture('arm64') + expect(() => assertWindowsRelayConptyProvider(daemon)).toThrow('identity mismatch') + }) + it('does not gate reconnect, CLI bridging, or POSIX on a Windows provider', () => { + vi.stubEnv('BUN_CONPTY_LIBRARY', undefined) + expect(() => assertWindowsRelayConptyProvider({ ...daemon, connectMode: true })).not.toThrow() + expect(() => assertWindowsRelayConptyProvider({ ...daemon, cliMode: true })).not.toThrow() + Object.defineProperty(process, 'platform', { value: 'linux' }) + expect(() => assertWindowsRelayConptyProvider(daemon)).not.toThrow() + }) +}) diff --git a/src/relay/windows-relay-conpty-admission.ts b/src/relay/windows-relay-conpty-admission.ts new file mode 100644 index 00000000000..67e8ef077f3 --- /dev/null +++ b/src/relay/windows-relay-conpty-admission.ts @@ -0,0 +1,29 @@ +import { realpathSync } from 'node:fs' +import { dirname, isAbsolute } from 'node:path' +import { resolveWindowsConptyProvider } from '../main/windows/windows-conpty-provider' +import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' +import type { RelayLaunchOptions } from './relay-launch-options' + +/** The native provider reads the initial environment; changing process.env here is too late. */ +export function assertWindowsRelayConptyProvider( + options: Pick +): void { + if (process.platform !== 'win32' || options.connectMode || options.cliMode) { + return + } + const entry = process.argv[1] + const selector = process.env.BUN_CONPTY_LIBRARY + if (!entry || !selector || !isAbsolute(selector)) { + throw new Error( + 'Windows relay requires its bundled terminal provider. Launch through Orca SSH deployment, ' + + 'or set BUN_CONPTY_LIBRARY to the absolute adjacent conpty.dll path before starting Bun.' + ) + } + const expected = resolveWindowsConptyProvider(dirname(realpathSync(entry)), process.arch) + if ( + normalizeRuntimePathForComparison(realpathSync(selector)) !== + normalizeRuntimePathForComparison(realpathSync(expected)) + ) { + throw new Error('Windows relay terminal provider must belong to this relay installation') + } +} diff --git a/src/shared/relay-artifacts.ts b/src/shared/relay-artifacts.ts index 28727ca8f30..9172b6f9d90 100644 --- a/src/shared/relay-artifacts.ts +++ b/src/shared/relay-artifacts.ts @@ -50,6 +50,13 @@ export const RELAY_WINDOWS_PROCESS_TREE_FILENAME = 'windows-process-tree.node' export const RELAY_WINDOWS_WATCHER_FILENAME = 'parcel-watcher.node' export const RELAY_WINDOWS_WATCHER_LICENSE = 'parcel-watcher-LICENSE' export const RELAY_OPENCODE_SQLITE_READER_FILENAME = 'opencode-sqlite-reader.cjs' +export const RELAY_WINDOWS_CONPTY_FILENAMES = [ + 'conpty.dll', + 'OpenConsole.exe', + 'conpty-LICENSE.txt', + 'conpty.json', + 'Microsoft.Windows.Console.ConPTY.nuspec' +] as const export const RELAY_ARTIFACTS: readonly RelayArtifact[] = [ { filename: 'relay.js' }, @@ -57,6 +64,7 @@ export const RELAY_ARTIFACTS: readonly RelayArtifact[] = [ { filename: 'relay-watcher.js', daemonServiceChild: true }, { filename: RELAY_WINDOWS_WATCHER_FILENAME, windowsOnly: true }, { filename: RELAY_WINDOWS_WATCHER_LICENSE, windowsOnly: true }, + ...RELAY_WINDOWS_CONPTY_FILENAMES.map((filename) => ({ filename, windowsOnly: true })), { filename: 'relay-ai-vault-service.js', daemonServiceChild: true }, { filename: RELAY_OPENCODE_SQLITE_READER_FILENAME }, { filename: 'managed-hook-runtime.js' }, diff --git a/src/shared/relay-optional-artifacts.test.ts b/src/shared/relay-optional-artifacts.test.ts index 6b41d032048..a47967eb27d 100644 --- a/src/shared/relay-optional-artifacts.test.ts +++ b/src/shared/relay-optional-artifacts.test.ts @@ -1,11 +1,39 @@ import { describe, expect, it } from 'vitest' import { RELAY_WINDOWS_PROCESS_TREE_FILENAME, + RELAY_WINDOWS_CONPTY_FILENAMES, relayArtifactFilenames, relayOptionalArtifactFilenames } from './relay-artifacts' describe('optional relay artifacts', () => { + it('requires the complete Windows provider without metadata name collisions', () => { + const windows = relayArtifactFilenames(true) + expect(RELAY_WINDOWS_CONPTY_FILENAMES).toEqual([ + 'conpty.dll', + 'OpenConsole.exe', + 'conpty-LICENSE.txt', + 'conpty.json', + 'Microsoft.Windows.Console.ConPTY.nuspec' + ]) + for (const filename of RELAY_WINDOWS_CONPTY_FILENAMES) { + expect(windows).toContain(filename) + expect(relayOptionalArtifactFilenames(true)).not.toContain(filename) + } + expect(new Set(windows).size).toBe(windows.length) + }) + + it('preserves the POSIX required artifact order and content', () => { + expect(relayArtifactFilenames(false)).toEqual([ + 'relay.js', + 'relay-watcher.js', + 'relay-ai-vault-service.js', + 'opencode-sqlite-reader.cjs', + 'managed-hook-runtime.js', + 'wsl-transcript-fs-process-entry.js' + ]) + }) + it('keeps the process-table addon out of the required set', () => { // The remote install probe requires every name this returns. Demanding an // artifact only a Windows build machine can emit would make a correct relay