From d2dbe2c385c30fb02be12dceb71fc5f19a560ecc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:49:03 -0700 Subject: [PATCH] fix(windows): replace the managed CLI launcher with a native one (#24094) * docs(security): add the antivirus clearance path for future releases Every AV false positive here has been handled one vendor and one shipped version at a time. Document the programs that clear future releases instead -- signer and product enrollment rather than per-build sample submission -- and add a script that reports an RC's current detection state by hash, so a verdict is found before users meet it in an issue report. Hash lookup only by default; --upload transmits the artifact and stays manual. * fix(windows): replace the managed CLI launcher with a native one resources\bin\orca.exe was a csc-compiled MSIL assembly: a small, freshly compiled .NET image in a user-writable directory that mutates environment variables and proxies a child process. That is the shape .NET dropper heuristics are trained on, and every verdict against it named the family -- MSILHeracles from two vendors, Wacatac!ml from a third. Signing the file does not change its shape, so signing never cleared it. Rebuild it in Rust. Same resolution, same environment contract, same argv passthrough that keeps newline-bearing orchestration bodies intact (#8374), and the child still inherits our environment block rather than an explicit map, so a block carrying both PATH and Path survives (#12046). The PE now carries publisher, version, icon and an asInvoker manifest from build.rs. Refs #23383 * ci(windows): install the Rust toolchain before building the CLI launcher The hosted runners happen to ship cargo, but a real Windows dev box does not -- verified on our own Windows QA host, where cargo and rustc were both absent. Relying on the image means a future image change fails deep inside electron-builder's native hook instead of at an obvious step. --- .github/workflows/pr.yml | 20 ++- .github/workflows/release-cut.yml | 14 ++ .gitignore | 2 + AGENTS.md | 2 +- config/scripts/build-windows-cli-launcher.mjs | 87 +++++----- .../build-windows-cli-launcher.test.mjs | 56 +++--- config/scripts/pr-code-change-scope.test.mjs | 2 +- .../scan-release-artifacts-antivirus.mjs | 164 ++++++++++++++++++ .../scan-release-artifacts-antivirus.test.mjs | 96 ++++++++++ .../antivirus-prerelease-clearance.md | 117 +++++++++++++ native/windows-cli-launcher/Cargo.lock | 157 +++++++++++++++++ native/windows-cli-launcher/Cargo.toml | 25 +++ .../windows-cli-launcher/OrcaCliLauncher.cs | 137 --------------- native/windows-cli-launcher/build.rs | 63 +++++++ native/windows-cli-launcher/src/main.rs | 102 +++++++++++ src/main/cli/windows-launcher-asset.test.ts | 20 +-- 16 files changed, 844 insertions(+), 220 deletions(-) create mode 100644 config/scripts/scan-release-artifacts-antivirus.mjs create mode 100644 config/scripts/scan-release-artifacts-antivirus.test.mjs create mode 100644 docs/reference/antivirus-prerelease-clearance.md create mode 100644 native/windows-cli-launcher/Cargo.lock create mode 100644 native/windows-cli-launcher/Cargo.toml delete mode 100644 native/windows-cli-launcher/OrcaCliLauncher.cs create mode 100644 native/windows-cli-launcher/build.rs create mode 100644 native/windows-cli-launcher/src/main.rs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 5e06fc6869e..96079a165d4 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1159,9 +1159,27 @@ jobs: - name: Cache Windows CLI launcher uses: actions/cache@v5 with: - path: native/windows-cli-launcher/.build + # Why the cargo directories ride along: a hit on .build skips the build + # entirely, but a miss otherwise recompiles the resource crate from a + # cold registry. + path: | + native/windows-cli-launcher/.build + native/windows-cli-launcher/target + ~/.cargo/registry key: windows-cli-launcher-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/windows-cli-launcher/**', 'config/scripts/build-windows-cli-launcher.mjs', 'resources/build/icon.ico', 'package.json') }} + # Why an explicit step rather than trusting the runner image: the packaged + # CLI launcher is a native Rust binary, and a host without cargo fails deep + # inside electron-builder's native hook instead of here. + - name: Ensure the Rust toolchain for the Windows CLI launcher + shell: pwsh + run: | + if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { + rustup toolchain install stable --profile minimal + rustup default stable + } + cargo --version + - name: Build package inputs run: pnpm run build:release:parallel diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 01f90060c5e..39684a5a53a 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1395,6 +1395,20 @@ jobs: node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=x64 node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=arm64 + # Why an explicit step rather than trusting the runner image: the packaged + # CLI launcher is a native Rust binary, and a Windows host without cargo + # fails deep inside electron-builder's native hook instead of here. Real + # Windows dev machines do not have it by default either. + - name: Ensure the Rust toolchain for the Windows CLI launcher + if: matrix.platform == 'win' + shell: pwsh + run: | + if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { + rustup toolchain install stable --profile minimal + rustup default stable + } + cargo --version + # Why ORCA_POSTHOG_WRITE_KEY here: this is the only build that # produces a published binary, so this is the only place the secret # needs to be in scope. The key is a PostHog *project* API key, not diff --git a/.gitignore b/.gitignore index 5e05c6d3050..65bb5b211b2 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ out/ /build/ release/ native/**/.build/ +native/windows-cli-launcher/target/ # node-gyp output for the vendored Windows registry addon; generated per host and ABI. native/windows-registry/build/ native/windows-registry/bin/ @@ -122,6 +123,7 @@ docs/** !docs/reference/agent-session-search-contract.md !docs/reference/agent-status-store.md !docs/reference/antigravity-readiness-evidence.md +!docs/reference/antivirus-prerelease-clearance.md !docs/reference/git-compatibility.md !docs/reference/headless-linux-server.md !docs/reference/ime-regression-checklist.md diff --git a/AGENTS.md b/AGENTS.md index 5aa9295c71e..f991769cc1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,7 +80,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh - **Windows process enumeration**: read the table through `src/main/windows/windows-process-table.ts`, never by forking `powershell.exe`. See [`docs/reference/windows-process-enumeration.md`](./docs/reference/windows-process-enumeration.md). - **Windows MSYS/Git Bash panes**: their children break away from the per-PTY job unless it is created without `JOB_OBJECT_LIMIT_BREAKAWAY_OK`, and a `conpty.node` built before that fix passes every existing gate. Before changing the per-PTY job or debugging `windows-msys-job.win32.test.ts`, read [`docs/reference/windows-msys-job-breakaway.md`](./docs/reference/windows-msys-job-breakaway.md). - **Windows daemon-host relocation**: the terminal daemon runs from a copy of the app runtime under `%LOCALAPPDATA%`, which is what survives an auto-update. Before touching that copy, its exe name, or the NSIS uninstall macro, read [`docs/reference/windows-daemon-host-relocation.md`](./docs/reference/windows-daemon-host-relocation.md). -- **Windows EDR signal**: don't add `-ExecutionPolicy Bypass`, `-EncodedCommand`, `cmd.exe /c` with escaped free text, per-operation interpreter spawning, or runtime `Add-Type` compilation without reading [`docs/reference/windows-edr-posture.md`](./docs/reference/windows-edr-posture.md) first — behavioural EDR scores each of those, and being signed does not clear them. +- **Windows EDR signal**: don't add `-ExecutionPolicy Bypass`, `-EncodedCommand`, `cmd.exe /c` with escaped free text, per-operation interpreter spawning, or runtime `Add-Type` compilation without reading [`docs/reference/windows-edr-posture.md`](./docs/reference/windows-edr-posture.md) first — behavioural EDR scores each of those, and being signed does not clear them. For file verdicts on the bytes we ship — antivirus false positives, and the vendor programs that clear a release before users meet the detection — see [`docs/reference/antivirus-prerelease-clearance.md`](./docs/reference/antivirus-prerelease-clearance.md). - **WSL commands**: build argv with `buildWslExecArgs` (always `--exec` — under `--`, `wsl.exe` expands `$name` in every argument and silently rewrites the script), and fence anything whose stdout you parse with `buildWslCapturedLoginShellCommand`, because the interactive login shell prints the distro banner to stdout. See [`docs/reference/wsl-command-execution.md`](./docs/reference/wsl-command-execution.md). - **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc. diff --git a/config/scripts/build-windows-cli-launcher.mjs b/config/scripts/build-windows-cli-launcher.mjs index cc4e5ad3d0f..b93005600e9 100644 --- a/config/scripts/build-windows-cli-launcher.mjs +++ b/config/scripts/build-windows-cli-launcher.mjs @@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' @@ -23,37 +23,22 @@ export function shouldReuseCompiledWindowsCliLauncher(outputPath, fingerprint) { ) } -export function windowsCliLauncherVersionSource(version) { - const match = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.exec(version) +/** + * The four-part numeric version Windows records in the PE. A prerelease suffix + * survives only in ProductVersion, which is a free-form string. + */ +export function windowsCliLauncherFileVersion(version) { + const match = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.exec(version ?? '') if (!match || match.slice(1).some((part) => Number(part) > 65534)) { throw new Error(`Invalid Windows CLI launcher version: ${version}`) } - const fileVersion = `${match.slice(1).join('.')}.0` - return [ - 'using System.Reflection;', - `[assembly: AssemblyVersion("${fileVersion}")]`, - `[assembly: AssemblyFileVersion("${fileVersion}")]`, - `[assembly: AssemblyInformationalVersion("${version}")]`, - '' - ].join('\n') + return `${match.slice(1).join('.')}.0` } function defaultOutputPath(projectRoot) { return join(projectRoot, 'native', 'windows-cli-launcher', '.build', 'orca.exe') } -function findFrameworkCompiler(env) { - const windowsDirectory = env.WINDIR ?? env.SystemRoot - if (!windowsDirectory) { - return null - } - const candidates = [ - join(windowsDirectory, 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe'), - join(windowsDirectory, 'Microsoft.NET', 'Framework', 'v4.0.30319', 'csc.exe') - ] - return candidates.find((candidate) => existsSync(candidate)) ?? null -} - function readArg(name) { const index = process.argv.indexOf(name) return index !== -1 ? process.argv[index + 1] : undefined @@ -69,59 +54,69 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) } const repoRoot = resolve(import.meta.dirname, '../..') - const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs') - const manifestPath = join(repoRoot, 'native', 'windows-cli-launcher', 'app.manifest') + const crateRoot = join(repoRoot, 'native', 'windows-cli-launcher') + const manifestPath = join(crateRoot, 'Cargo.toml') const iconPath = join(repoRoot, 'resources', 'build', 'icon.ico') const { version } = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')) - const versionSource = windowsCliLauncherVersionSource(version) + // Throws on a version the PE cannot represent, before anything is compiled. + windowsCliLauncherFileVersion(version) const fingerprint = windowsCliLauncherFingerprint( [ - sourcePath, + join(crateRoot, 'src', 'main.rs'), + join(crateRoot, 'build.rs'), manifestPath, + join(crateRoot, 'app.manifest'), iconPath, join(repoRoot, 'config/scripts/build-windows-cli-launcher.mjs') ], version ) const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot) - const compilerPath = findFrameworkCompiler(process.env) - - if (!compilerPath) { - throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.') - } mkdirSync(dirname(outputPath), { recursive: true }) if (shouldReuseCompiledWindowsCliLauncher(outputPath, fingerprint)) { console.log(`[native-build] reusing Windows CLI launcher at ${outputPath}`) process.exit(0) } - const versionPath = join(dirname(outputPath), 'OrcaCliLauncher.Version.cs') - writeFileSync(versionPath, versionSource) + rmSync(`${outputPath}.sha256`, { force: true }) + const targetDirectory = join(crateRoot, 'target') const result = spawnSync( - compilerPath, + 'cargo', [ - '/nologo', - '/target:exe', - '/optimize+', - '/warnaserror+', - `/win32manifest:${manifestPath}`, - `/win32icon:${iconPath}`, - `/out:${outputPath}`, - sourcePath, - versionPath + 'build', + '--release', + '--locked', + '--manifest-path', + manifestPath, + '--target-dir', + targetDirectory ], - { cwd: repoRoot, stdio: 'inherit' } + { + cwd: crateRoot, + stdio: 'inherit', + env: { + ...process.env, + ORCA_LAUNCHER_VERSION: version, + ORCA_LAUNCHER_ICON: iconPath + } + } ) if (result.signal) { process.kill(process.pid, result.signal) } if (result.error) { - throw result.error + // Why named explicitly: a bare ENOENT here reads as a missing source file + // rather than a host without the toolchain the packaged CLI needs. + throw new Error( + `Unable to run cargo for the Windows CLI launcher: ${result.error.message}. Install Rust (https://rustup.rs) and retry.` + ) } if (result.status !== 0) { process.exit(result.status ?? 1) } + + copyFileSync(join(targetDirectory, 'release', 'orca.exe'), outputPath) writeFileSync(`${outputPath}.sha256`, fingerprint) } diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index 1e0f363acd0..e83b57f65e7 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -14,8 +14,8 @@ import { spawnSync } from 'node:child_process' import { describe, expect, it } from 'vitest' import { shouldReuseCompiledWindowsCliLauncher, - windowsCliLauncherFingerprint, - windowsCliLauncherVersionSource + windowsCliLauncherFileVersion, + windowsCliLauncherFingerprint } from './build-windows-cli-launcher.mjs' const itCrossHost = process.platform === 'win32' ? it.skip : it @@ -35,22 +35,25 @@ function removeFixtureTree(path) { } } } -// Why: cold csc.exe startup exceeds Vitest's 5s unit budget on hosted Windows; -// keep the larger allowance scoped to the real compiler integration test. +// Why: a cold cargo build compiles the resource crate and links from scratch, +// which far exceeds Vitest's 5s unit budget on a hosted Windows runner. Later +// cases reuse the shared target directory, so only the first pays it. function itWindows(name, test) { const runner = process.platform === 'win32' ? it : it.skip - runner(name, { timeout: 15_000 }, test) + runner(name, { timeout: 300_000 }, test) } describe('Windows CLI launcher', () => { it('reuses restored builds only while all embedded inputs and the release version match', () => { const root = mkdtempSync(join(tmpdir(), 'orca-cli-launcher-reuse-')) try { - const inputs = ['source.cs', 'app.manifest', 'icon.ico', 'build.mjs'].map((name) => { - const path = join(root, name) - writeFileSync(path, name) - return path - }) + const inputs = ['main.rs', 'build.rs', 'Cargo.toml', 'app.manifest', 'icon.ico'].map( + (name) => { + const path = join(root, name) + writeFileSync(path, name) + return path + } + ) const outputPath = join(root, 'orca.exe') const fingerprint = windowsCliLauncherFingerprint(inputs, '1.4.214') expect(shouldReuseCompiledWindowsCliLauncher(outputPath, fingerprint)).toBe(false) @@ -88,12 +91,11 @@ describe('Windows CLI launcher', () => { } }) - it('keeps prerelease identity while emitting a valid Windows numeric version', () => { - const source = windowsCliLauncherVersionSource('1.4.214-daily.202609281300') - expect(source).toContain('AssemblyFileVersion("1.4.214.0")') - expect(source).toContain('AssemblyInformationalVersion("1.4.214-daily.202609281300")') + it('reduces a prerelease to the numeric version Windows can record', () => { + expect(windowsCliLauncherFileVersion('1.4.214-daily.202609281300')).toBe('1.4.214.0') + expect(windowsCliLauncherFileVersion('1.4.214')).toBe('1.4.214.0') for (const version of ['1.4.65535', '1.4', '1.4.214"', undefined]) { - expect(() => windowsCliLauncherVersionSource(version)).toThrow('Invalid Windows') + expect(() => windowsCliLauncherFileVersion(version)).toThrow('Invalid Windows') } }) @@ -156,18 +158,28 @@ describe('Windows CLI launcher', () => { } }) - itCrossHost('never materializes the child environment block from ProcessStartInfo', () => { - // Why: both ProcessStartInfo env properties copy the process block into a case-insensitive - // dictionary that throws when the inherited block holds PATH and Path (stablyai/orca#12046). + itCrossHost('never hands the child an explicit environment map', () => { + // Why: setting any entry on the child's environment makes the spawn build its own + // block from a case-insensitive map, which collapses an inherited PATH and Path + // into one entry and killed the CLI (stablyai/orca#12046). Mutating this process + // and leaving the map untouched passes the block through verbatim. const source = readFileSync( - join(projectRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs'), + join(projectRoot, 'native', 'windows-cli-launcher', 'src', 'main.rs'), 'utf8' ) const code = source.replace(/^\s*\/\/.*$/gm, '') - expect(code).not.toContain('EnvironmentVariables') - expect(code).not.toContain('startInfo.Environment') - expect(code).toContain('Environment.SetEnvironmentVariable') + expect(code).not.toMatch(/\.envs?\(/u) + expect(code).not.toContain('env_clear') + expect(code).toContain('env::set_var') + }) + + itCrossHost('never reintroduces a managed launcher alongside the native one', () => { + // Why: the MSIL image is what vendors flagged (stablyai/orca#23383). A stray .cs + // left in the crate would compile back into the shape the rewrite removed. + expect( + existsSync(join(projectRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')) + ).toBe(false) }) itWindows('preserves a multiline argument from PowerShell through the native launcher', () => { diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 6232c9a4b8e..01cb077b179 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -194,7 +194,7 @@ describe('per-job path classification', () => { }) it('runs native package jobs only for the platform that ships the changed native', () => { - expectClassification(['native/windows-cli-launcher/OrcaCliLauncher.cs'], { + expectClassification(['native/windows-cli-launcher/src/main.rs'], { package_windows: true }) expectClassification(['native/computer-use-linux/runtime.py'], { diff --git a/config/scripts/scan-release-artifacts-antivirus.mjs b/config/scripts/scan-release-artifacts-antivirus.mjs new file mode 100644 index 00000000000..3c5715b829d --- /dev/null +++ b/config/scripts/scan-release-artifacts-antivirus.mjs @@ -0,0 +1,164 @@ +#!/usr/bin/env node +/** + * Report the current antivirus detection state of built release artifacts, so an + * RC can be checked before users meet the verdict in an issue report. See + * docs/reference/antivirus-prerelease-clearance.md. + * + * Hash lookup only by default: nothing leaves the machine but a SHA-256. The + * `--upload` flag transmits the artifact itself, which distributes it to partner + * vendors — intended for clearance work, never for an automated path. + */ + +import { createHash } from 'node:crypto' +import { createReadStream, openAsBlob } from 'node:fs' +import { stat } from 'node:fs/promises' +import { basename } from 'node:path' +import { pathToFileURL } from 'node:url' + +const VIRUSTOTAL_FILES_ENDPOINT = 'https://www.virustotal.com/api/v3/files' + +export async function hashFile(filePath) { + const hash = createHash('sha256') + for await (const chunk of createReadStream(filePath)) { + hash.update(chunk) + } + return hash.digest('hex') +} + +/** + * The engines that call an artifact malicious or suspicious. Every other + * category (undetected, type-unsupported, timeout, failure) is silence, not a + * clean bill of health, so it is counted but never reported as a verdict. + */ +export function summarizeEngineVerdicts(analysisResults) { + const flagged = [] + let scanned = 0 + for (const [engine, result] of Object.entries(analysisResults ?? {})) { + scanned += 1 + if (result?.category === 'malicious' || result?.category === 'suspicious') { + flagged.push({ + engine, + category: result.category, + detection: result.result ?? '' + }) + } + } + flagged.sort((left, right) => left.engine.localeCompare(right.engine)) + return { scanned, flagged } +} + +export function formatArtifactReport({ name, sha256, known, scanned, flagged }) { + if (!known) { + return `${name}\n sha256 ${sha256}\n no report yet — this build has never been scanned` + } + if (flagged.length === 0) { + return `${name}\n sha256 ${sha256}\n clean across ${scanned} engines` + } + const lines = flagged.map( + (entry) => ` ${entry.category.padEnd(10)} ${entry.engine}: ${entry.detection}` + ) + return [ + name, + ` sha256 ${sha256}`, + ` ${flagged.length} of ${scanned} engines flag this build:`, + ...lines + ].join('\n') +} + +/** True when any artifact carries a verdict a user could hit. */ +export function hasAnyDetection(reports) { + return reports.some((report) => report.flagged.length > 0) +} + +async function fetchExistingReport(sha256, apiKey) { + const response = await fetch(`${VIRUSTOTAL_FILES_ENDPOINT}/${sha256}`, { + headers: { 'x-apikey': apiKey } + }) + if (response.status === 404) { + return null + } + if (!response.ok) { + throw new Error(`VirusTotal lookup failed for ${sha256}: ${response.status}`) + } + const body = await response.json() + return body?.data?.attributes?.last_analysis_results ?? {} +} + +// Why a separate upload URL above 32MB: the direct endpoint rejects larger +// files, and every Orca installer is far past that. +async function uploadArtifact(filePath, apiKey) { + const { size } = await stat(filePath) + let endpoint = VIRUSTOTAL_FILES_ENDPOINT + if (size > 32 * 1024 * 1024) { + const urlResponse = await fetch(`${VIRUSTOTAL_FILES_ENDPOINT}/upload_url`, { + headers: { 'x-apikey': apiKey } + }) + if (!urlResponse.ok) { + throw new Error(`Could not obtain a VirusTotal upload URL: ${urlResponse.status}`) + } + endpoint = (await urlResponse.json())?.data + } + // openAsBlob streams from disk; buffering a 200MB installer would not fit. + const form = new FormData() + form.append('file', await openAsBlob(filePath), basename(filePath)) + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'x-apikey': apiKey }, + body: form + }) + if (!response.ok) { + throw new Error(`VirusTotal upload failed for ${filePath}: ${response.status}`) + } +} + +export async function scanArtifacts(filePaths, { apiKey, upload = false } = {}) { + const reports = [] + for (const filePath of filePaths) { + const sha256 = await hashFile(filePath) + let analysisResults = await fetchExistingReport(sha256, apiKey) + if (analysisResults === null && upload) { + await uploadArtifact(filePath, apiKey) + analysisResults = null + } + const { scanned, flagged } = summarizeEngineVerdicts(analysisResults) + reports.push({ + name: basename(filePath), + sha256, + known: analysisResults !== null, + scanned, + flagged + }) + } + return reports +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const args = process.argv.slice(2) + const upload = args.includes('--upload') + const filePaths = args.filter((arg) => !arg.startsWith('--')) + if (filePaths.length === 0) { + throw new Error( + 'Usage: node config/scripts/scan-release-artifacts-antivirus.mjs [--upload] ...' + ) + } + const apiKey = process.env.VIRUSTOTAL_API_KEY + if (!apiKey) { + // Why not a failure: this runs alongside release steps that must not break + // before the secret is provisioned. + console.log('VIRUSTOTAL_API_KEY is not set; skipping the antivirus detection report.') + process.exit(0) + } + + const reports = await scanArtifacts(filePaths, { apiKey, upload }) + for (const report of reports) { + console.log(formatArtifactReport(report)) + } + if (hasAnyDetection(reports)) { + // Why a warning and not an exit code: these are third-party ML classifiers, + // so a hard gate hands them the power to fail our releases. A human decides. + console.log( + '\nAt least one engine flags a shipped artifact. Decide before publishing, and submit ' + + 'for clearance per docs/reference/antivirus-prerelease-clearance.md.' + ) + } +} diff --git a/config/scripts/scan-release-artifacts-antivirus.test.mjs b/config/scripts/scan-release-artifacts-antivirus.test.mjs new file mode 100644 index 00000000000..ab373c67b3d --- /dev/null +++ b/config/scripts/scan-release-artifacts-antivirus.test.mjs @@ -0,0 +1,96 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + formatArtifactReport, + hasAnyDetection, + hashFile, + summarizeEngineVerdicts +} from './scan-release-artifacts-antivirus.mjs' + +describe('antivirus detection report', () => { + it('hashes an artifact with the same digest the issue reports quote', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-av-scan-')) + try { + const artifact = join(directory, 'orca-windows-setup.exe') + await writeFile(artifact, 'orca', 'utf8') + // Lowercase hex sha256, so a reporter's `shasum -a 256` output and ours + // compare directly — that comparison is what makes a submission credible. + expect(await hashFile(artifact)).toBe( + 'e0c924608fdcda8536bd9cc86b0fce0ab2d54ecc1e8ed9673624c39cde7f7820' + ) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('counts only malicious and suspicious categories as verdicts', () => { + const { scanned, flagged } = summarizeEngineVerdicts({ + Microsoft: { category: 'malicious', result: 'Trojan:Win32/Wacatac.B!ml' }, + Bitdefender: { category: 'suspicious', result: 'Gen:Variant.MSILHeracles' }, + Kaspersky: { category: 'undetected', result: null }, + ESET: { category: 'type-unsupported', result: null }, + Avast: { category: 'timeout', result: null } + }) + + expect(scanned).toBe(5) + expect(flagged).toEqual([ + { engine: 'Bitdefender', category: 'suspicious', detection: 'Gen:Variant.MSILHeracles' }, + { engine: 'Microsoft', category: 'malicious', detection: 'Trojan:Win32/Wacatac.B!ml' } + ]) + }) + + it('treats a missing results payload as nothing scanned rather than clean', () => { + expect(summarizeEngineVerdicts(undefined)).toEqual({ scanned: 0, flagged: [] }) + expect(summarizeEngineVerdicts(null)).toEqual({ scanned: 0, flagged: [] }) + }) + + it('names an unnamed detection instead of printing undefined', () => { + const { flagged } = summarizeEngineVerdicts({ Sophos: { category: 'malicious' } }) + expect(flagged[0].detection).toBe('') + }) + + it('distinguishes an unscanned build from a clean one', () => { + const unscanned = formatArtifactReport({ + name: 'orca-windows-setup.exe', + sha256: 'a'.repeat(64), + known: false, + scanned: 0, + flagged: [] + }) + expect(unscanned).toContain('never been scanned') + + const clean = formatArtifactReport({ + name: 'orca-windows-setup.exe', + sha256: 'a'.repeat(64), + known: true, + scanned: 70, + flagged: [] + }) + expect(clean).toContain('clean across 70 engines') + }) + + it('reports every flagging engine and its detection name', () => { + const report = formatArtifactReport({ + name: 'orca.exe', + sha256: 'b'.repeat(64), + known: true, + scanned: 70, + flagged: [ + { engine: 'TrendMicro', category: 'malicious', detection: 'Trojan.MSIL.MSILHERACLES' } + ] + }) + + expect(report).toContain('1 of 70 engines flag this build') + expect(report).toContain('TrendMicro: Trojan.MSIL.MSILHERACLES') + }) + + it('flags the release when any single artifact carries a verdict', () => { + const clean = { flagged: [] } + const flagged = { flagged: [{ engine: 'Microsoft' }] } + + expect(hasAnyDetection([clean, clean])).toBe(false) + expect(hasAnyDetection([clean, flagged])).toBe(true) + }) +}) diff --git a/docs/reference/antivirus-prerelease-clearance.md b/docs/reference/antivirus-prerelease-clearance.md new file mode 100644 index 00000000000..9efcfb3aaa0 --- /dev/null +++ b/docs/reference/antivirus-prerelease-clearance.md @@ -0,0 +1,117 @@ +# Antivirus clearance for future releases + +Orca collects a steady stream of antivirus and EDR false positives — see the +tracking issue for the current grouping. This document covers the part of that +problem worth engineering effort: **stopping the next release from being +flagged.** + +Clearing a *historic* release is explicitly not a goal. A user sitting on a +flagged build should update to a cleared one, not wait for a vendor to whitelist +a version we no longer ship. Retroactive submissions cost the same effort per +vendor and expire the moment we cut a new version. + +For the behavioural side of the problem — the process-tree shapes EDR scores, +which no whitelist fixes — read +[`windows-edr-posture.md`](./windows-edr-posture.md) instead. This document is +about file verdicts on the bytes we ship. + +## The two mechanisms, and only one of them scales + +**Sample submission** clears one build. You send the flagged file to a vendor's +analyst portal, they confirm it is clean, and the verdict is dropped from their +next definition update. This is reactive and per-release: cutting a new version +produces new bytes, new hashes, and a fresh chance of the same heuristic firing. +Doing this every release, across every vendor, is not sustainable. + +**Signer and product whitelisting** clears every future build. The vendor +records the publisher identity or enrolls the product in a dynamic allowlist, and +subsequent releases inherit that trust without another submission. Enrollment is +one-time work per vendor, and it is the only lever that scales with our release +cadence. + +Prefer enrollment. Use submission only to clear a live incident while enrollment +is pending, and only for a release users are actually expected to install. + +## Prerequisites that make enrollment possible + +None of these programs will accept an unsigned or anonymous binary, so these +come first: + +1. **Every shipped PE is Authenticode-signed, and CI fails the release if not.** + Done as of v1.4.217 — the release workflow requires a valid SignPath + Foundation signature on the inner binaries and no longer fails open. +2. **Every shipped PE carries real provenance** — company, product, version, + description, and an explicit `asInvoker` manifest. An anonymous binary scores + worse than an identified one, and several portals reject submissions that + carry no version metadata. +3. **One stable signer identity.** Vendor allowlists key on the certificate + subject. Rotating signers resets accrued reputation, so a certificate change + is a re-enrollment event, not a transparent swap. + +## Vendor programs + +Enrollment state is deliberately left as a task here rather than asserted — fill +each in as it is confirmed, and record the account that owns it so a lapsed +enrollment is traceable. + +| Vendor | Mechanism | Scope | State | +| ------------------------- | ---------------------------------------------------------------------------- | ------------------------ | ----- | +| **VirusTotal** | Monitor — paid; builds rescanned daily, developer and vendor both notified | ~70 engines at once | TODO | +| **Microsoft** | Defender Security Intelligence submission, as a software developer | Defender, Defender FP EP | TODO | +| **Microsoft** | Trusted Signing, or an EV certificate, for SmartScreen and Smart App Control | Reputation gates | TODO | +| **Kaspersky** | Whitelist Program — vendors submit builds for the Dynamic Allowlist | Endpoint, all platforms | TODO | +| **Trend Micro** | Certified Safe Software Service — pre-release software whitelisting | Endpoint, Virus Buster | TODO | +| **Bitdefender** | False-positive submission for software vendors | Endpoint, ATD | TODO | +| **Avast / AVG / Norton** | Gen Digital false-positive and whitelisting channels | Consumer suites | TODO | +| **ESET** | False-positive sample submission | Endpoint | TODO | +| **Tencent iOA** | No public developer channel found; needs a support relationship | iOA, macOS and Windows | TODO | + +VirusTotal Monitor is the highest-leverage single entry, because it is the only +channel built for exactly this workflow: uploads sit in a private store, get +rescanned daily against every engine's current signatures, and when one flags a +file **both we and that vendor are notified automatically**. Pre-publish upload +is a supported use, which is precisely the future-release posture we want. It is +a paid service, monetised on developers and free to the antivirus vendors. + +Be honest about its limit: VirusTotal states plainly that Monitor is not a free +pass to get a file whitelisted. Vendors sometimes keep a detection. What it +reliably buys is *early notice and a real contact path* instead of discovering a +verdict from a user's issue report weeks later. + +Do not treat a plain VirusTotal *scan* as equivalent. A scan tells us a verdict +exists; Monitor is what routes it to someone who can drop it. + +If the subscription is not worth it, the free fallback is the community-maintained +false-positive contact directory (`yaronelh/False-Positive-Center` on GitHub), +which collects the submission addresses and forms each vendor actually reads. +That replaces the hardest part of a submission — finding the right contact — but +keeps the per-release effort that Monitor removes. + +## Where this lands in the release flow + +The check belongs at RC time, not after a stable cut — a verdict discovered after +publication is a verdict users already hit. + +`config/scripts/scan-release-artifacts-antivirus.mjs` reports the current +detection state of built artifacts by hash. Run it against an RC's artifacts, and +treat any engine verdict as a release-blocking question rather than an automatic +stop: these are third-party ML classifiers, so a hard gate on their output would +fail the release for reasons outside our control. Read the report, decide, and +submit if the flagged build is one we intend to ship. + +The script looks up hashes by default and never transmits artifact bytes. Passing +`--upload` sends the file to VirusTotal, which distributes samples to partner +vendors — that is the intended outcome for clearance work, but it is a +publication, so it stays opt-in and out of any automated path. + +## What not to do + +- **Do not ask users to add exclusions** as the resolution. It suppresses the + symptom on one machine, and in several reports here the exclusion did not even + hold because the detection was behavioural rather than path-based. +- **Do not dispute a verdict without a sample.** Every report worth acting on in + this project came with a hash that we verified bit-identical to the published + release asset. That verification is what makes a submission credible. +- **Do not chase a vendor whose detection we cannot reproduce or name.** Route + those back to the reporter for the detection string and the exact flagged path + first. diff --git a/native/windows-cli-launcher/Cargo.lock b/native/windows-cli-launcher/Cargo.lock new file mode 100644 index 00000000000..6342f1b41e4 --- /dev/null +++ b/native/windows-cli-launcher/Cargo.lock @@ -0,0 +1,157 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "orca-cli-launcher" +version = "0.0.0" +dependencies = [ + "winresource", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "winresource" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +dependencies = [ + "toml", + "version_check", +] diff --git a/native/windows-cli-launcher/Cargo.toml b/native/windows-cli-launcher/Cargo.toml new file mode 100644 index 00000000000..27f793b796a --- /dev/null +++ b/native/windows-cli-launcher/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "orca-cli-launcher" +version = "0.0.0" +edition = "2021" +publish = false + +# Why the bin is named `orca`: it ships as resources\bin\orca.exe, and the PE's +# OriginalFilename must match the file users actually invoke. +[[bin]] +name = "orca" +path = "src/main.rs" + +[build-dependencies] +winresource = "0.1" + +# Why: this is a launcher, not a program. Everything here trims the PE toward the +# shape of an ordinary small native utility -- no panic machinery, no debug +# sections, one codegen unit -- which is also what keeps it out of the +# heuristic bucket the previous managed build sat in. +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/native/windows-cli-launcher/OrcaCliLauncher.cs b/native/windows-cli-launcher/OrcaCliLauncher.cs deleted file mode 100644 index 3357c7b1596..00000000000 --- a/native/windows-cli-launcher/OrcaCliLauncher.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Text; -using System.Reflection; -using System.Runtime.InteropServices; - -[assembly: AssemblyTitle("Orca CLI Launcher")] -[assembly: AssemblyDescription("Command-line launcher for Orca")] -[assembly: AssemblyCompany("Stably AI")] -[assembly: AssemblyProduct("Orca")] -[assembly: AssemblyCopyright("Copyright © Stably AI")] -[assembly: ComVisible(false)] - -internal static class OrcaCliLauncher -{ - private static int Main(string[] args) - { - try - { - string launcherDirectory = Path.GetDirectoryName(typeof(OrcaCliLauncher).Assembly.Location); - string resourcesDirectory = Directory.GetParent(launcherDirectory).FullName; - string appDirectory = Directory.GetParent(resourcesDirectory).FullName; - string electronPath = Path.Combine(appDirectory, "Orca.exe"); - string cliPath = Path.Combine( - resourcesDirectory, - "app.asar.unpacked", - "out", - "cli", - "index.js" - ); - - if (!File.Exists(electronPath)) - { - Console.Error.WriteLine("Unable to locate Orca.exe next to \"{0}\"", resourcesDirectory); - return 1; - } - - if (!File.Exists(cliPath)) - { - Console.Error.WriteLine("Unable to locate the Orca CLI entrypoint at \"{0}\"", cliPath); - return 1; - } - - ProcessStartInfo startInfo = new ProcessStartInfo - { - FileName = electronPath, - Arguments = BuildArguments(cliPath, args), - UseShellExecute = false - }; - - // Why: launching without cmd.exe preserves embedded newlines while matching the - // packaged batch launcher's Electron-as-Node environment contract. - // Why: ProcessStartInfo's env copy rejects duplicate PATH/Path keys; mutating this - // short-lived process preserves the native block for child inheritance (#12046). - MoveEnvironmentVariable("NODE_OPTIONS", "ORCA_NODE_OPTIONS"); - MoveEnvironmentVariable("NODE_REPL_EXTERNAL_MODULE", "ORCA_NODE_REPL_EXTERNAL_MODULE"); - Environment.SetEnvironmentVariable("ELECTRON_RUN_AS_NODE", "1"); - Environment.SetEnvironmentVariable("ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER", "1"); - string requestedCliCommand = Environment.GetEnvironmentVariable("ORCA_CLI_COMMAND"); - Environment.SetEnvironmentVariable( - "ORCA_CLI_COMMAND", - requestedCliCommand == "orca-ide" ? "orca-ide" : "orca" - ); - - using (Process child = Process.Start(startInfo)) - { - child.WaitForExit(); - return child.ExitCode; - } - } - catch (Exception error) - { - Console.Error.WriteLine("Unable to start the Orca CLI: {0}", error.Message); - return 1; - } - } - - private static void MoveEnvironmentVariable(string sourceName, string targetName) - { - string value = Environment.GetEnvironmentVariable(sourceName); - Environment.SetEnvironmentVariable(sourceName, null); - // Why: a null value clears the target, matching the previous unconditional Remove. - Environment.SetEnvironmentVariable(targetName, value); - } - - private static string BuildArguments(string cliPath, string[] args) - { - StringBuilder commandLine = new StringBuilder(QuoteArgument(cliPath)); - foreach (string arg in args) - { - commandLine.Append(' '); - commandLine.Append(QuoteArgument(arg)); - } - return commandLine.ToString(); - } - - private static string QuoteArgument(string value) - { - bool requiresQuotes = value.Length == 0; - for (int index = 0; index < value.Length && !requiresQuotes; index += 1) - { - requiresQuotes = value[index] == '"' || Char.IsWhiteSpace(value[index]); - } - if (!requiresQuotes) - { - return value; - } - - StringBuilder quoted = new StringBuilder("\""); - int backslashCount = 0; - foreach (char character in value) - { - if (character == '\\') - { - backslashCount += 1; - continue; - } - - if (character == '"') - { - quoted.Append('\\', backslashCount * 2 + 1); - quoted.Append('"'); - } - else - { - quoted.Append('\\', backslashCount); - quoted.Append(character); - } - backslashCount = 0; - } - - quoted.Append('\\', backslashCount * 2); - quoted.Append('"'); - return quoted.ToString(); - } -} diff --git a/native/windows-cli-launcher/build.rs b/native/windows-cli-launcher/build.rs new file mode 100644 index 00000000000..300c1727e24 --- /dev/null +++ b/native/windows-cli-launcher/build.rs @@ -0,0 +1,63 @@ +//! Embeds the Windows PE version block, application manifest, and icon. +//! +//! Why this matters beyond cosmetics: an anonymous binary with no publisher, +//! no version, and no declared execution level scores worse under antivirus +//! heuristics than an identified one, and several vendor submission portals +//! reject a sample that carries no version metadata at all. + +use std::env; + +fn main() { + println!("cargo:rerun-if-changed=app.manifest"); + println!("cargo:rerun-if-env-changed=ORCA_LAUNCHER_VERSION"); + println!("cargo:rerun-if-env-changed=ORCA_LAUNCHER_ICON"); + + if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + + // Set by config/scripts/build-windows-cli-launcher.mjs from package.json, so + // the launcher always reports the release it shipped in. + let version = env::var("ORCA_LAUNCHER_VERSION") + .expect("ORCA_LAUNCHER_VERSION must be set; build through build-windows-cli-launcher.mjs"); + let (major, minor, patch) = numeric_version_parts(&version); + + let mut resource = winresource::WindowsResource::new(); + resource.set("ProductName", "Orca"); + resource.set("FileDescription", "Orca CLI Launcher"); + resource.set("CompanyName", "Stably AI"); + resource.set( + "LegalCopyright", + "Copyright (C) Stably AI. All rights reserved.", + ); + resource.set("InternalName", "orca.exe"); + resource.set("OriginalFilename", "orca.exe"); + resource.set("FileVersion", &format!("{major}.{minor}.{patch}.0")); + resource.set("ProductVersion", &version); + resource.set_version_info( + winresource::VersionInfo::FILEVERSION, + (major << 48) | (minor << 32) | (patch << 16), + ); + resource.set_version_info( + winresource::VersionInfo::PRODUCTVERSION, + (major << 48) | (minor << 32) | (patch << 16), + ); + resource.set_manifest_file("app.manifest"); + if let Ok(icon) = env::var("ORCA_LAUNCHER_ICON") { + resource.set_icon(&icon); + } + resource + .compile() + .expect("failed to compile Windows resources"); +} + +/// Strips any prerelease or build suffix; the PE numeric version accepts digits only. +fn numeric_version_parts(version: &str) -> (u64, u64, u64) { + let base = version.split(['-', '+']).next().unwrap_or_default(); + let mut parts = base.split('.').map(|part| part.parse::().unwrap_or(0)); + ( + parts.next().unwrap_or(0), + parts.next().unwrap_or(0), + parts.next().unwrap_or(0), + ) +} diff --git a/native/windows-cli-launcher/src/main.rs b/native/windows-cli-launcher/src/main.rs new file mode 100644 index 00000000000..00282fe455e --- /dev/null +++ b/native/windows-cli-launcher/src/main.rs @@ -0,0 +1,102 @@ +//! Launches the Orca CLI by running the packaged Electron binary as Node. +//! +//! Why a native binary rather than the `.cmd` alone: `cmd.exe` reparses `%*` and +//! executes or truncates embedded newlines, so orchestration message bodies +//! cannot survive it (#8374). `orca.cmd` refuses those subcommands and defers +//! here. +//! +//! Why not a managed assembly: a small freshly-compiled MSIL image in a +//! user-writable directory that mutates environment variables and proxies a +//! child process is the shape antivirus MSIL heuristics are trained on, and it +//! was flagged as exactly that across several vendors (#23383). + +use std::env; +use std::path::{Path, PathBuf}; +use std::process::{exit, Command}; + +fn main() { + let launcher = match env::current_exe() { + Ok(path) => path, + Err(error) => fail(&format!("Unable to start the Orca CLI: {error}")), + }; + + let Some(resources_directory) = launcher.parent().and_then(Path::parent) else { + fail(&format!( + "Unable to locate Orca.exe next to \"{}\"", + launcher.display() + )) + }; + let Some(app_directory) = resources_directory.parent() else { + fail(&format!( + "Unable to locate Orca.exe next to \"{}\"", + resources_directory.display() + )) + }; + + let electron_path = app_directory.join("Orca.exe"); + if !electron_path.is_file() { + fail(&format!( + "Unable to locate Orca.exe next to \"{}\"", + resources_directory.display() + )); + } + + let cli_path: PathBuf = resources_directory + .join("app.asar.unpacked") + .join("out") + .join("cli") + .join("index.js"); + if !cli_path.is_file() { + fail(&format!( + "Unable to locate the Orca CLI entrypoint at \"{}\"", + cli_path.display() + )); + } + + // Why mutate this process rather than hand the child an environment map: + // an explicit map collapses a block carrying both PATH and Path into one + // entry, which is what killed the CLI in #12046. Leaving the map untouched + // makes the child inherit our block verbatim. + move_environment_variable("NODE_OPTIONS", "ORCA_NODE_OPTIONS"); + move_environment_variable( + "NODE_REPL_EXTERNAL_MODULE", + "ORCA_NODE_REPL_EXTERNAL_MODULE", + ); + env::set_var("ELECTRON_RUN_AS_NODE", "1"); + env::set_var("ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER", "1"); + let requested_command = env::var("ORCA_CLI_COMMAND").unwrap_or_default(); + env::set_var( + "ORCA_CLI_COMMAND", + if requested_command == "orca-ide" { + "orca-ide" + } else { + "orca" + }, + ); + + // Each argument stays its own argv entry, so a body holding newlines reaches + // the CLI intact. + let mut command = Command::new(&electron_path); + command.arg(&cli_path).args(env::args_os().skip(1)); + + match command.status() { + Ok(status) => exit(status.code().unwrap_or(1)), + Err(error) => fail(&format!("Unable to start the Orca CLI: {error}")), + } +} + +fn move_environment_variable(source_name: &str, target_name: &str) { + match env::var_os(source_name) { + // A missing source clears the target, so a stale value cannot leak in. + None => env::remove_var(target_name), + Some(value) => { + env::remove_var(source_name); + env::set_var(target_name, value); + } + } +} + +fn fail(message: &str) -> ! { + eprintln!("{message}"); + exit(1) +} diff --git a/src/main/cli/windows-launcher-asset.test.ts b/src/main/cli/windows-launcher-asset.test.ts index 2eec4fee577..56ba51d4a2c 100644 --- a/src/main/cli/windows-launcher-asset.test.ts +++ b/src/main/cli/windows-launcher-asset.test.ts @@ -13,19 +13,15 @@ describe('packaged Windows CLI launcher asset', () => { }) it('marks the packaged child and propagates its exact exit status', () => { - const sourcePath = join(process.cwd(), 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs') + const sourcePath = join(process.cwd(), 'native', 'windows-cli-launcher', 'src', 'main.rs') const source = readFileSync(sourcePath, 'utf8') - // Why: the marker and command name must ride the launcher's own environment, never - // ProcessStartInfo's case-insensitive copy of a PATH/Path block (stablyai/orca#12046). - expect(source).toContain( - 'Environment.SetEnvironmentVariable("ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER", "1");' - ) - expect(source).toContain( - 'string requestedCliCommand = Environment.GetEnvironmentVariable("ORCA_CLI_COMMAND");' - ) - expect(source).toContain('requestedCliCommand == "orca-ide" ? "orca-ide" : "orca"') - expect(source).toContain('child.WaitForExit();') - expect(source).toContain('return child.ExitCode;') + // Why: the marker and command name must ride the launcher's own environment, never an + // explicit child map, whose case-insensitive keys collapse PATH and Path (stablyai/orca#12046). + expect(source).toContain('env::set_var("ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER", "1")') + expect(source).toContain('env::var("ORCA_CLI_COMMAND")') + expect(source).toContain('if requested_command == "orca-ide"') + expect(source).toContain('command.status()') + expect(source).toContain('exit(status.code().unwrap_or(1))') }) })