mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 00:02:10 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'], {
|
||||
|
||||
@@ -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 ?? '<unnamed>'
|
||||
})
|
||||
}
|
||||
}
|
||||
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] <artifact>...'
|
||||
)
|
||||
}
|
||||
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.'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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('<unnamed>')
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
Generated
+157
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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::<u64>().unwrap_or(0));
|
||||
(
|
||||
parts.next().unwrap_or(0),
|
||||
parts.next().unwrap_or(0),
|
||||
parts.next().unwrap_or(0),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user