Ship the WSL transcript helper with the Windows relay (STA-4831) (#15529)

This commit is contained in:
OrcaWin
2026-08-20 00:16:04 -07:00
committed by GitHub
parent 9d06b3ba93
commit 471bc9d8ce
8 changed files with 315 additions and 58 deletions
+74 -28
View File
@@ -10,8 +10,22 @@
*/
import { build } from 'esbuild'
import { createHash } from 'node:crypto'
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { join } from 'node:path'
import {
RELAY_BUILD_PLATFORMS,
RELAY_VERSION_FILENAME,
isWindowsRelayPlatform,
relayArtifactFilenames
} from '../../src/shared/relay-artifacts.ts'
const __dirname = import.meta.dirname
// Why: the script lives under config/scripts, so go two levels up to reach the repo root.
@@ -19,6 +33,13 @@ const ROOT = join(__dirname, '..', '..')
const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts')
const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts')
const AI_VAULT_SERVICE_ENTRY = join(ROOT, 'src', 'relay', 'ai-vault-service-entry.ts')
const WSL_TRANSCRIPT_FS_PROCESS_ENTRY = join(
ROOT,
'src',
'main',
'native-chat',
'wsl-transcript-fs-process-entry.ts'
)
const MANAGED_HOOK_RUNTIME_ENTRY = join(
ROOT,
'src',
@@ -35,19 +56,17 @@ const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join(
NODE_PTY_CONSOLE_LIST_PATCH_FILENAME
)
const PLATFORMS = [
'linux-x64',
'linux-arm64',
'darwin-x64',
'darwin-arm64',
'win32-x64',
'win32-arm64'
]
// Why: lets the packaging contract test build into a temp tree instead of
// clobbering a developer's out/relay or racing tests that read it.
const OUT_ROOT = process.env.ORCA_RELAY_OUT_ROOT ?? join(ROOT, 'out', 'relay')
const RELAY_VERSION = '0.1.0'
for (const platform of PLATFORMS) {
const outDir = join(ROOT, 'out', 'relay', platform)
for (const platform of RELAY_BUILD_PLATFORMS) {
const outDir = join(OUT_ROOT, platform)
// Why: a stale companion left by an earlier build would otherwise satisfy the
// manifest check and be hashed into .version, shipping mixed-generation bytes.
rmSync(outDir, { recursive: true, force: true })
mkdirSync(outDir, { recursive: true })
await build({
@@ -67,7 +86,7 @@ for (const platform of PLATFORMS) {
}
})
if (platform.startsWith('win32-')) {
if (isWindowsRelayPlatform(platform)) {
copyFileSync(
NODE_PTY_CONSOLE_LIST_PATCH_SOURCE,
join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)
@@ -104,6 +123,23 @@ for (const platform of PLATFORMS) {
}
})
// Why beside the service: the spawn resolves this child next to its own
// bundle, and a relay host has no desktop out/main to fall back to.
await build({
entryPoints: [WSL_TRANSCRIPT_FS_PROCESS_ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, 'wsl-transcript-fs-process-entry.js'),
external: ['electron'],
sourcemap: false,
minify: true,
define: {
'process.env.NODE_ENV': '"production"'
}
})
await build({
entryPoints: [MANAGED_HOOK_RUNTIME_ENTRY],
bundle: true,
@@ -121,24 +157,34 @@ for (const platform of PLATFORMS) {
}
})
// Why: include a content hash so the deploy check detects code changes
// even when RELAY_VERSION hasn't been bumped. Hash every executable module
// so a companion-only change always deploys beside the matching relay host.
const relayContent = readFileSync(join(outDir, 'relay.js'))
const watcherContent = readFileSync(join(outDir, 'relay-watcher.js'))
const aiVaultServiceContent = readFileSync(join(outDir, 'relay-ai-vault-service.js'))
const managedHookRuntimeContent = readFileSync(join(outDir, 'managed-hook-runtime.js'))
// Why: include a content hash so the deploy check detects code changes even
// when RELAY_VERSION hasn't been bumped. Hashing the whole manifest means a
// companion-only change still selects a fresh immutable relay directory.
const expected = relayArtifactFilenames(isWindowsRelayPlatform(platform))
const hash = createHash('sha256')
.update(relayContent)
.update(watcherContent)
.update(aiVaultServiceContent)
.update(managedHookRuntimeContent)
// Why: changing the remote node-pty patch must select a fresh immutable Windows relay directory.
if (platform.startsWith('win32-')) {
hash.update(readFileSync(join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)))
for (const filename of expected) {
const artifactPath = join(outDir, filename)
if (!existsSync(artifactPath)) {
throw new Error(
`Relay ${platform} declares ${filename} in RELAY_ARTIFACTS but never emitted it. ` +
'Add the build step, or drop it from src/shared/relay-artifacts.ts.'
)
}
hash.update(readFileSync(artifactPath))
}
const contentHash = hash.digest('hex').slice(0, 12)
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${contentHash}`)
// Close the loop: an artifact emitted here but absent from the manifest would
// ship unhashed and unprobed — exactly how the WSL helper went missing.
const emitted = readdirSync(outDir).filter((name) => name !== RELAY_VERSION_FILENAME)
const undeclared = emitted.filter((name) => !expected.includes(name))
if (undeclared.length > 0) {
throw new Error(
`Relay ${platform} emitted undeclared artifacts: ${undeclared.join(', ')}. ` +
'Add them to RELAY_ARTIFACTS in src/shared/relay-artifacts.ts.'
)
}
writeFileSync(join(outDir, RELAY_VERSION_FILENAME), `${RELAY_VERSION}+${contentHash}`)
console.log(`Built relay for ${platform}${outDir}/relay.js`)
}
@@ -149,7 +195,7 @@ for (const platform of PLATFORMS) {
// Windows app via the same out/relay extraResources mapping.
{
const wslEntry = join(ROOT, 'src', 'relay', 'wsl-agent-hook-relay.ts')
const outDir = join(ROOT, 'out', 'relay', 'wsl')
const outDir = join(OUT_ROOT, 'wsl')
mkdirSync(outDir, { recursive: true })
await build({
entryPoints: [wslEntry],
@@ -3,6 +3,7 @@ import { createRequire } from 'node:module'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { relayArtifactFilenames } from '../../src/shared/relay-artifacts.ts'
const projectDir = resolve(import.meta.dirname, '../..')
const require = createRequire(import.meta.url)
@@ -201,14 +202,15 @@ describe('Electron runtime package contract', () => {
expect(relayBuild).toContain("'parcel-watcher-process-entry.ts'")
expect(relayBuild).toContain("outfile: join(outDir, 'relay-watcher.js')")
expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-watcher.js'))")
expect(relayBuild).toContain("outfile: join(outDir, 'relay-ai-vault-service.js')")
expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-ai-vault-service.js'))")
expect(builderConfig).toContain("from: 'out/relay'")
expect(remoteCommands).toContain("joinRemotePath(host, remoteRelayDir, 'relay-watcher.js')")
expect(remoteCommands).toContain(
"joinRemotePath(host, remoteRelayDir, 'relay-ai-vault-service.js')"
)
// Hashing and remote install probing are manifest-driven, so the contract
// is that both companions are declared once and that both sites read it.
expect(relayArtifactFilenames(true)).toContain('relay-watcher.js')
expect(relayArtifactFilenames(true)).toContain('relay-ai-vault-service.js')
expect(relayBuild).toContain('relayArtifactFilenames(')
expect(remoteCommands).toContain('relayArtifactFilenames(')
const assertRelayGate = (steps, publishStepName) => {
const names = steps.map((step) => step.name)
@@ -0,0 +1,81 @@
// Packaged-relay contract: what `build:relay` actually writes to disk.
//
// Asserts against a real build, not the source tree — the unit suites cannot see
// this gap, because the WSL transcript dispatcher runs in-process under vitest
// and never forks.
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
RELAY_BUILD_PLATFORMS,
RELAY_VERSION_FILENAME,
isWindowsRelayPlatform,
relayArtifactFilenames
} from '../../src/shared/relay-artifacts.ts'
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.
const relayOutDir = mkdtempSync(join(tmpdir(), 'orca-relay-contract-'))
beforeAll(() => {
execFileSync('node', [join(projectDir, 'config', 'scripts', 'build-relay.mjs')], {
cwd: projectDir,
stdio: 'pipe',
env: { ...process.env, ORCA_RELAY_OUT_ROOT: relayOutDir }
})
}, 120_000)
afterAll(() => {
rmSync(relayOutDir, { recursive: true, force: true })
})
describe('packaged relay artifact manifest', () => {
it.each([...RELAY_BUILD_PLATFORMS])('emits exactly the declared artifacts for %s', (platform) => {
const outDir = join(relayOutDir, platform)
const expected = relayArtifactFilenames(isWindowsRelayPlatform(platform))
for (const filename of expected) {
expect(existsSync(join(outDir, filename)), `${platform}/${filename} missing`).toBe(true)
}
// Exactly, not merely at least: an undeclared artifact ships unhashed and
// unprobed, which is the same gap in the other direction.
const emitted = readdirSync(outDir)
.filter((name) => name !== RELAY_VERSION_FILENAME)
.sort()
expect(emitted).toEqual([...expected].sort())
})
it.each([...RELAY_BUILD_PLATFORMS])('hashes every declared artifact for %s', (platform) => {
const outDir = join(relayOutDir, platform)
const hash = createHash('sha256')
for (const filename of relayArtifactFilenames(isWindowsRelayPlatform(platform))) {
hash.update(readFileSync(join(outDir, filename)))
}
const version = readFileSync(join(outDir, RELAY_VERSION_FILENAME), 'utf8')
// A companion left out of the hash lets a changed relay reuse an existing
// immutable remote directory, serving a mixed-generation install forever.
expect(version.split('+')[1]).toBe(hash.digest('hex').slice(0, 12))
})
it('ships the WSL transcript helper beside the service that forks it', () => {
for (const platform of RELAY_BUILD_PLATFORMS) {
const outDir = join(relayOutDir, platform)
const service = readFileSync(join(outDir, 'relay-ai-vault-service.js'), 'utf8')
// The bundled service reaches the fork, so the entry must sit beside it:
// the spawn resolves the child relative to its own bundle directory.
expect(service, `${platform} service no longer forks the helper`).toContain(
'wsl-transcript-fs-process-entry.js'
)
expect(
existsSync(join(outDir, 'wsl-transcript-fs-process-entry.js')),
`${platform} forks a helper it does not ship`
).toBe(true)
}
})
})
+1 -1
View File
@@ -54,7 +54,7 @@
"dev": "pnpm run ensure:electron-runtime && node config/scripts/run-electron-vite-dev.mjs",
"dev-stable-name": "pnpm run ensure:electron-runtime && node config/scripts/run-electron-vite-dev.mjs --stable-name",
"dev:web": "vite --config vite.web.config.ts --host 127.0.0.1",
"build:relay": "node config/scripts/build-relay.mjs",
"build:relay": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/build-relay.mjs",
"build:computer-macos": "node config/scripts/build-computer-macos.mjs",
"build:keyboard-layout-macos": "node config/scripts/build-keyboard-layout-macos.mjs",
"build:notification-status-macos": "node config/scripts/build-notification-status-macos.mjs",
+66
View File
@@ -4,6 +4,7 @@ import {
mkdirSync,
mkdtempSync,
readdirSync,
writeFileSync,
rmSync,
statSync,
utimesSync
@@ -30,6 +31,10 @@ import {
relayLivenessProbeCommand
} from './ssh-remote-commands'
import { getRemoteHostPlatform } from './ssh-remote-platform'
import {
RELAY_INSTALL_COMPLETE_FILENAME,
relayArtifactFilenames
} from '../../shared/relay-artifacts'
const posix = getRemoteHostPlatform('linux-x64')
const windows = getRemoteHostPlatform('win32-x64')
@@ -120,6 +125,67 @@ describe('ssh remote command builders', () => {
expect(probe).toContain('relay-ai-vault-service.js')
})
it('requires every declared relay artifact before calling an install complete', () => {
const posixProbe = probeRelayInstalledCommand(posix, '/home/me/relay')
for (const filename of relayArtifactFilenames(false)) {
expect(posixProbe, `POSIX probe ignores ${filename}`).toContain(filename)
}
expect(posixProbe).toContain(RELAY_INSTALL_COMPLETE_FILENAME)
// A POSIX relay must not be asked for the Windows-only node-pty patch.
expect(posixProbe).not.toContain('node-pty-1.1.0-console-list-agent-patch.cjs')
const windowsProbe = decodePowerShellCommand(
probeRelayInstalledCommand(windows, 'C:/Users/me/relay')
)
for (const filename of relayArtifactFilenames(true)) {
expect(windowsProbe, `Windows probe ignores ${filename}`).toContain(filename)
}
expect(windowsProbe).toContain(RELAY_INSTALL_COMPLETE_FILENAME)
})
// Stage a complete install, then remove one companion: the probe must flip.
function stageRelayInstall(isWindows: boolean): string {
const dir = mkdtempSync(join(tmpdir(), 'orca-relay-probe-'))
for (const filename of relayArtifactFilenames(isWindows)) {
writeFileSync(join(dir, filename), '')
}
writeFileSync(join(dir, RELAY_INSTALL_COMPLETE_FILENAME), '')
return dir
}
it.skipIf(!powerShellExecutable)(
'rejects a Windows install missing only the WSL transcript helper',
async () => {
const dir = stageRelayInstall(true)
const probe = (): string => decodePowerShellCommand(probeRelayInstalledCommand(windows, dir))
try {
expect((await runPowerShellCommand(powerShellExecutable!, probe())).trim()).toBe('OK')
// The exact shape a pre-STA-4831 relay left behind: everything but this.
rmSync(join(dir, 'wsl-transcript-fs-process-entry.js'))
expect((await runPowerShellCommand(powerShellExecutable!, probe())).trim()).toBe('MISSING')
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
)
it.skipIf(process.platform === 'win32')(
'rejects a POSIX install missing only the WSL transcript helper',
async () => {
const dir = stageRelayInstall(false)
const probe = (): string => probeRelayInstalledCommand(posix, dir)
try {
expect((await runShellCommand(probe())).trim()).toBe('OK')
rmSync(join(dir, 'wsl-transcript-fs-process-entry.js'))
expect((await runShellCommand(probe())).trim()).toBe('MISSING')
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
)
it('uses encoded PowerShell for Windows deploy commands', () => {
expect(readRemoteHomeCommand(windows)).toContain('powershell.exe')
expect(makeRemoteDirectoryCommand(windows, 'C:/Users/me/.orca-remote')).toContain(
+20 -20
View File
@@ -1,3 +1,7 @@
import {
RELAY_INSTALL_COMPLETE_FILENAME,
relayArtifactFilenames
} from '../../shared/relay-artifacts'
import type { RemoteHostPlatform } from './ssh-remote-platform'
import { isWindowsRemoteHost, joinRemotePath, remoteDirname } from './ssh-remote-platform'
import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell'
@@ -85,35 +89,31 @@ export function writeRemoteEmptyFileCommand(host: RemoteHostPlatform, remotePath
)
}
/**
* A partial install must read as MISSING, so every file the manifest ships is
* probed — not a hand-kept subset. A relay that advertises the AI Vault title
* service but lacks the WSL transcript helper would otherwise pass this probe
* and then answer WSL title requests with silence.
*/
export function probeRelayInstalledCommand(
host: RemoteHostPlatform,
remoteRelayDir: string
): string {
const relayJs = joinRemotePath(host, remoteRelayDir, 'relay.js')
const relayWatcherJs = joinRemotePath(host, remoteRelayDir, 'relay-watcher.js')
const relayAiVaultServiceJs = joinRemotePath(host, remoteRelayDir, 'relay-ai-vault-service.js')
const managedHookRuntimeJs = joinRemotePath(host, remoteRelayDir, 'managed-hook-runtime.js')
const installComplete = joinRemotePath(host, remoteRelayDir, '.install-complete')
const required = [
...relayArtifactFilenames(isWindowsRemoteHost(host)),
RELAY_INSTALL_COMPLETE_FILENAME
].map((filename) => joinRemotePath(host, remoteRelayDir, filename))
if (!isWindowsRemoteHost(host)) {
return (
`test -d ${shellEscape(remoteRelayDir)} ` +
`&& test -f ${shellEscape(relayJs)} ` +
`&& test -f ${shellEscape(relayWatcherJs)} ` +
`&& test -f ${shellEscape(relayAiVaultServiceJs)} ` +
`&& test -f ${shellEscape(managedHookRuntimeJs)} ` +
`&& test -f ${shellEscape(installComplete)} ` +
`&& echo OK || echo MISSING`
)
const fileTests = required.map((path) => `&& test -f ${shellEscape(path)} `).join('')
return `test -d ${shellEscape(remoteRelayDir)} ${fileTests}&& echo OK || echo MISSING`
}
return powerShellCommand(
[
`$dir = ${powerShellLiteral(remoteRelayDir)}`,
`$relay = ${powerShellLiteral(relayJs)}`,
`$watcher = ${powerShellLiteral(relayWatcherJs)}`,
`$aiVaultService = ${powerShellLiteral(relayAiVaultServiceJs)}`,
`$managedHooks = ${powerShellLiteral(managedHookRuntimeJs)}`,
`$complete = ${powerShellLiteral(installComplete)}`,
"if ((Test-Path -LiteralPath $dir -PathType Container) -and (Test-Path -LiteralPath $relay -PathType Leaf) -and (Test-Path -LiteralPath $watcher -PathType Leaf) -and (Test-Path -LiteralPath $aiVaultService -PathType Leaf) -and (Test-Path -LiteralPath $managedHooks -PathType Leaf) -and (Test-Path -LiteralPath $complete -PathType Leaf)) { 'OK' } else { 'MISSING' }"
`$required = @(${required.map((path) => powerShellLiteral(path)).join(', ')})`,
'$ok = Test-Path -LiteralPath $dir -PathType Container',
'foreach ($f in $required) { if (-not (Test-Path -LiteralPath $f -PathType Leaf)) { $ok = $false } }',
"if ($ok) { 'OK' } else { 'MISSING' }"
].join('; ')
)
}
@@ -11,6 +11,7 @@ import { deployAndLaunchRelay } from './ssh-relay-deploy'
import { SshChannelMultiplexer } from './ssh-channel-multiplexer'
import { uploadDirectoryViaSystemSsh } from './ssh-system-fallback'
import type { SshTarget } from '../../shared/ssh-types'
import { relayArtifactFilenames } from '../../shared/relay-artifacts'
const RELAY_VERSION = '0.1.0+systemtransport'
@@ -54,9 +55,13 @@ exec /bin/sh -c "$cmd"
}
function writeFakeRelay(dir: string): void {
writeFileSync(join(dir, 'relay-watcher.js'), '')
writeFileSync(join(dir, 'relay-ai-vault-service.js'), '')
writeFileSync(join(dir, 'managed-hook-runtime.js'), '')
// Stage every companion the manifest declares — relay.js gets real content
// below — so adding one cannot silently fail the completeness probe here.
for (const filename of relayArtifactFilenames(false)) {
if (filename !== 'relay.js') {
writeFileSync(join(dir, filename), '')
}
}
writeFileSync(
join(dir, 'relay.js'),
`
+57
View File
@@ -0,0 +1,57 @@
/**
* What a packaged relay directory must contain, declared once. The build, the
* content hash, and the remote install probe all read this list; they used to
* keep three of their own, which is how the WSL helper reached the desktop app
* but never a relay.
*
* Order is load-bearing: the hash concatenates these files in sequence.
*
* Keep this file erasable-only TypeScript — build-relay.mjs imports it directly
* under Node's type stripping, which rejects enums, namespaces, and parameter
* properties.
*/
/** Every platform the relay is bundled for; each gets the full artifact set. */
export const RELAY_BUILD_PLATFORMS = [
'linux-x64',
'linux-arm64',
'darwin-x64',
'darwin-arm64',
'win32-x64',
'win32-arm64'
] as const
export type RelayBuildPlatform = (typeof RELAY_BUILD_PLATFORMS)[number]
export function isWindowsRelayPlatform(platform: string): boolean {
return platform.startsWith('win32-')
}
export type RelayArtifact = {
filename: string
/** Only Windows relays ship it; other hosts must neither receive nor probe it. */
windowsOnly?: boolean
}
export const RELAY_ARTIFACTS: readonly RelayArtifact[] = [
{ filename: 'relay.js' },
{ filename: 'relay-watcher.js' },
{ filename: 'relay-ai-vault-service.js' },
{ filename: 'managed-hook-runtime.js' },
// Forked by the AI Vault title reader; without it a relay answers every WSL
// title request with no title and no error.
{ filename: 'wsl-transcript-fs-process-entry.js' },
{ filename: 'node-pty-1.1.0-console-list-agent-patch.cjs', windowsOnly: true }
]
/** Written after the artifacts, so it is never an input to its own hash. */
export const RELAY_VERSION_FILENAME = '.version'
/** Written last by the installer; its absence means a torn install. */
export const RELAY_INSTALL_COMPLETE_FILENAME = '.install-complete'
export function relayArtifactFilenames(isWindows: boolean): string[] {
return RELAY_ARTIFACTS.filter((artifact) => !artifact.windowsOnly || isWindows).map(
(artifact) => artifact.filename
)
}