From 471bc9d8ceec7f84070a169fcac9268a46907c40 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 20 Aug 2026 00:16:04 -0700 Subject: [PATCH] Ship the WSL transcript helper with the Windows relay (STA-4831) (#15529) --- config/scripts/build-relay.mjs | 102 +++++++++++++----- ...package-electron-runtime-contract.test.mjs | 14 +-- .../scripts/relay-artifact-manifest.test.mjs | 81 ++++++++++++++ package.json | 2 +- src/main/ssh/ssh-remote-commands.test.ts | 66 ++++++++++++ src/main/ssh/ssh-remote-commands.ts | 40 +++---- .../ssh-system-transport.integration.test.ts | 11 +- src/shared/relay-artifacts.ts | 57 ++++++++++ 8 files changed, 315 insertions(+), 58 deletions(-) create mode 100644 config/scripts/relay-artifact-manifest.test.mjs create mode 100644 src/shared/relay-artifacts.ts diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index 23ad74ab47a..125538a7236 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -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], diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index cb54f25031e..a539dc2b717 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -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) diff --git a/config/scripts/relay-artifact-manifest.test.mjs b/config/scripts/relay-artifact-manifest.test.mjs new file mode 100644 index 00000000000..2796a361778 --- /dev/null +++ b/config/scripts/relay-artifact-manifest.test.mjs @@ -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) + } + }) +}) diff --git a/package.json b/package.json index ceac925f0b2..3fd332f5fe1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/ssh/ssh-remote-commands.test.ts b/src/main/ssh/ssh-remote-commands.test.ts index 18738a930c6..4e75720121b 100644 --- a/src/main/ssh/ssh-remote-commands.test.ts +++ b/src/main/ssh/ssh-remote-commands.test.ts @@ -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( diff --git a/src/main/ssh/ssh-remote-commands.ts b/src/main/ssh/ssh-remote-commands.ts index 603b6356a1f..4fa1445969d 100644 --- a/src/main/ssh/ssh-remote-commands.ts +++ b/src/main/ssh/ssh-remote-commands.ts @@ -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('; ') ) } diff --git a/src/main/ssh/ssh-system-transport.integration.test.ts b/src/main/ssh/ssh-system-transport.integration.test.ts index 6199dfeef52..6e45077698a 100644 --- a/src/main/ssh/ssh-system-transport.integration.test.ts +++ b/src/main/ssh/ssh-system-transport.integration.test.ts @@ -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'), ` diff --git a/src/shared/relay-artifacts.ts b/src/shared/relay-artifacts.ts new file mode 100644 index 00000000000..1801667364c --- /dev/null +++ b/src/shared/relay-artifacts.ts @@ -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 + ) +}