Files
orca/config/scripts/build-relay.mjs
T
OrcaWinandm4air 6d1a97ef98 fix(ssh): launch the Windows relay outside sshd's job so standard users work (#24224)
* fix(ssh): launch the Windows relay outside sshd's job without WMI

Win32-OpenSSH kills a session's job on close but allows breakaway. relay.js
gains a one-shot launcher mode that starts the detached relay with
CREATE_BREAKAWAY_FROM_JOB through the staged process-tree addon, so a standard
user no longer needs a WMI Remote Enable grant. WMI stays as the fallback for a
relay without the addon, and a refusal there is named. The Windows SSH-host
lanes drop their WMI grant and assert the breakaway route and adoption.

* fix(ssh): find runtime holds without WMI on a standard-user Windows host

The store GC read held runtimes through Get-CimInstance Win32_Process, which
WMI refuses to a standard user's SSH logon, so the pass kept every runtime.
On a refusal it now reads this account's own process image paths through
Get-Process.

* build(relay): ship the Windows relay launcher addon in every desktop package

macOS and Linux packages carried Windows relays without windows-process-tree.node,
so a legacy-runtime relay they uploaded to a Windows SSH host could not launch
outside sshd's job and fell back to WMI, which a standard user is refused.

A reusable Windows job now compiles the x64 and arm64 addons once and uploads
them; release-cut, release-mac-build, and the hourly/daily/adhoc mac builds
download them before build:release and require both arches. Staging now rejects
a binary with the wrong PE machine, the ReadProcessMemory import, or no
spawnOutsideJob export, so a stale pre-launcher build cannot ship.

* ci(ssh): run the Windows SSH-host lanes when the relay process-tree build scripts change

The staging and gyp-rebuild scripts decide which windows-process-tree addon the
relay ships, so a change to either must re-prove the Windows host cells.

* test(ci): find the mac orcad-template download by artifact name

The release mac job now also downloads the relay Windows process-tree addons, so
the first download-artifact step is no longer the template's.

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-10-01 05:32:09 -07:00

335 lines
11 KiB
JavaScript

#!/usr/bin/env node
/**
* Bundle the relay daemon and its crash-isolated watcher child per platform.
*
* The relay runs on remote hosts via `node relay.js`, so both outputs use
* self-contained CommonJS bundles with no external dependencies beyond
* Node.js built-ins. Native addons (node-pty, @parcel/watcher) are
* marked external and expected to be installed on the remote or
* gracefully degraded.
*/
import { build } from 'esbuild'
import { createHash } from 'node:crypto'
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { join } from 'node:path'
import {
RELAY_BUILD_PLATFORMS,
RELAY_VERSION_FILENAME,
RELAY_OPENCODE_SQLITE_READER_FILENAME,
relayOptionalArtifactFilenames,
isWindowsRelayPlatform,
relayArtifactFilenames
} from '../../src/shared/relay-artifacts.ts'
import {
parseRequiredRelayAddonArches,
stageRelayWindowsProcessTreeAddon
} from './relay-windows-process-tree-staging.mjs'
const __dirname = import.meta.dirname
// Why: the script lives under config/scripts, so go two levels up to reach the repo root.
const ROOT = join(__dirname, '..', '..')
const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts')
const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts')
const AI_VAULT_SERVICE_ENTRY = join(ROOT, 'src', 'relay', 'ai-vault-service-entry.ts')
const OPENCODE_SQLITE_READER_ENTRY = join(
ROOT,
'src',
'main',
'ai-vault',
'session-scanner-opencode-sqlite-process-entry.ts'
)
const WSL_TRANSCRIPT_FS_PROCESS_ENTRY = join(
ROOT,
'src',
'main',
'native-chat',
'wsl-transcript-fs-process-entry.ts'
)
const MANAGED_HOOK_RUNTIME_ENTRY = join(
ROOT,
'src',
'main',
'agent-hooks',
'managed-hook-runtime.ts'
)
const JSONC_PARSER_ESM_ENTRY = join(ROOT, 'node_modules', 'jsonc-parser', 'lib', 'esm', 'main.js')
const NODE_PTY_CONSOLE_LIST_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs'
const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join(
ROOT,
'config',
'relay-assets',
NODE_PTY_CONSOLE_LIST_PATCH_FILENAME
)
const NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME = 'node-pty-1.1.0-windows-pty-teardown-patch.cjs'
const NODE_PTY_WINDOWS_TEARDOWN_PATCH_SOURCE = join(
ROOT,
'config',
'relay-assets',
NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME
)
const NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME = 'node-pty-1.1.0-master-cloexec-patch.cjs'
const NODE_PTY_MASTER_CLOEXEC_PATCH_SOURCE = join(
ROOT,
'config',
'relay-assets',
NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME
)
// Written by build-windows-process-tree-relay-addon.mjs on Windows, or downloaded
// from CI's relay-windows-process-tree artifact on other OSes.
const WINDOWS_PROCESS_TREE_BUILD_DIR = join(ROOT, '.build', 'windows-process-tree')
// Per-arch rather than a flag because arm64 needs the MSVC ARM64 cross toolset,
// an optional VS component: where it is absent that relay should fall back to
// the scan, not fail the release the x64 relay is riding on.
const REQUIRED_ADDON_ARCHES = parseRequiredRelayAddonArches(
process.env.ORCA_REQUIRE_RELAY_NATIVE_ADDONS
)
// Why: lets the packaging contract test build into a temp tree instead of
// clobbering a developer's out/relay or racing tests that read it.
const OUT_ROOT = process.env.ORCA_RELAY_OUT_ROOT ?? join(ROOT, 'out', 'relay')
const RELAY_VERSION = '0.1.0'
async function buildRelayBundles(outDir) {
await build({
entryPoints: [RELAY_ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, 'relay.js'),
// Native addons cannot be bundled — they must exist on the remote host.
// The relay gracefully degrades when they are absent.
external: ['node-pty', '@parcel/watcher', 'electron'],
sourcemap: false,
minify: true,
define: {
'process.env.NODE_ENV': '"production"'
}
})
await build({
entryPoints: [WATCHER_ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, 'relay-watcher.js'),
external: ['@parcel/watcher'],
sourcemap: false,
minify: true,
define: {
'process.env.NODE_ENV': '"production"'
}
})
await build({
entryPoints: [AI_VAULT_SERVICE_ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, 'relay-ai-vault-service.js'),
external: ['electron'],
sourcemap: false,
minify: true,
define: {
'process.env.NODE_ENV': '"production"'
}
})
await build({
entryPoints: [OPENCODE_SQLITE_READER_ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, RELAY_OPENCODE_SQLITE_READER_FILENAME),
external: ['electron'],
sourcemap: false,
minify: true,
define: { 'process.env.NODE_ENV': '"production"' }
})
// Why beside the service: the spawn resolves this child next to its own
// bundle, and a relay host has no desktop out/main to fall back to.
await build({
entryPoints: [WSL_TRANSCRIPT_FS_PROCESS_ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, 'wsl-transcript-fs-process-entry.js'),
external: ['electron'],
sourcemap: false,
minify: true,
define: {
'process.env.NODE_ENV': '"production"'
}
})
await build({
entryPoints: [MANAGED_HOOK_RUNTIME_ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, 'managed-hook-runtime.js'),
// Why: jsonc-parser's default UMD build keeps relative dynamic requires
// that break after bundling; its ESM entry is equivalent and self-contained.
alias: { 'jsonc-parser': JSONC_PARSER_ESM_ENTRY },
sourcemap: false,
minify: true,
define: {
'process.env.NODE_ENV': '"production"'
}
})
}
let bundledSourceDir
let bundledFilenames = []
for (const platform of RELAY_BUILD_PLATFORMS) {
const outDir = join(OUT_ROOT, platform)
// Why: a stale companion left by an earlier build would otherwise satisfy the
// manifest check and be hashed into .version, shipping mixed-generation bytes.
rmSync(outDir, { recursive: true, force: true })
mkdirSync(outDir, { recursive: true })
// The JavaScript selects its host at runtime; only native addons and patches vary.
if (bundledSourceDir) {
for (const filename of bundledFilenames) {
copyFileSync(join(bundledSourceDir, filename), join(outDir, filename))
}
} else {
await buildRelayBundles(outDir)
bundledSourceDir = outDir
bundledFilenames = readdirSync(outDir)
}
if (isWindowsRelayPlatform(platform)) {
copyFileSync(
NODE_PTY_CONSOLE_LIST_PATCH_SOURCE,
join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)
)
copyFileSync(
NODE_PTY_WINDOWS_TEARDOWN_PATCH_SOURCE,
join(outDir, NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME)
)
}
copyFileSync(
NODE_PTY_MASTER_CLOEXEC_PATCH_SOURCE,
join(outDir, NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME)
)
stageRelayWindowsProcessTreeAddon({
platform,
outDir,
buildDir: WINDOWS_PROCESS_TREE_BUILD_DIR,
requiredArches: REQUIRED_ADDON_ARCHES
})
// Why: include a content hash so the deploy check detects code changes even
// when RELAY_VERSION hasn't been bumped. Hashing the whole manifest means a
// companion-only change still selects a fresh immutable relay directory.
const expected = relayArtifactFilenames(isWindowsRelayPlatform(platform))
const hash = createHash('sha256')
for (const filename of expected) {
const artifactPath = join(outDir, filename)
if (!existsSync(artifactPath)) {
throw new Error(
`Relay ${platform} declares ${filename} in RELAY_ARTIFACTS but never emitted it. ` +
'Add the build step, or drop it from src/shared/relay-artifacts.ts.'
)
}
hash.update(readFileSync(artifactPath))
}
// Why hashed only when present: a relay carrying the native addon answers
// differently from one that falls back to the scan, so the two must not share
// an immutable directory -- but a build without it is still valid.
for (const filename of relayOptionalArtifactFilenames(isWindowsRelayPlatform(platform))) {
const artifactPath = join(outDir, filename)
if (existsSync(artifactPath)) {
hash.update(readFileSync(artifactPath))
}
}
const contentHash = hash.digest('hex').slice(0, 12)
// Close the loop: an artifact emitted here but absent from the manifest would
// ship unhashed and unprobed — exactly how the WSL helper went missing.
const emitted = readdirSync(outDir).filter((name) => name !== RELAY_VERSION_FILENAME)
const declared = [
...expected,
...relayOptionalArtifactFilenames(isWindowsRelayPlatform(platform))
]
const undeclared = emitted.filter((name) => !declared.includes(name))
if (undeclared.length > 0) {
throw new Error(
`Relay ${platform} emitted undeclared artifacts: ${undeclared.join(', ')}. ` +
'Add them to RELAY_ARTIFACTS in src/shared/relay-artifacts.ts.'
)
}
writeFileSync(join(outDir, RELAY_VERSION_FILENAME), `${RELAY_VERSION}+${contentHash}`)
console.log(`Built relay for ${platform} → ${outDir}/relay.js`)
}
// WSL agent-hook relay: a hooks-only guest receiver launched inside WSL
// distros via wsl.exe. Pure Node built-ins (no node-pty/@parcel/watcher),
// so a single platform-independent bundle suffices; it ships inside the
// Windows app via the same out/relay extraResources mapping.
{
const wslHookEntry = join(ROOT, 'src', 'relay', 'wsl-agent-hook-relay.ts')
const wslBrowserNetworkEntry = join(ROOT, 'src', 'relay', 'wsl-browser-network-relay.ts')
const outDir = join(OUT_ROOT, 'wsl')
mkdirSync(outDir, { recursive: true })
await build({
entryPoints: [wslHookEntry],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, 'wsl-agent-hook-relay.js'),
sourcemap: false,
minify: true,
define: {
'process.env.NODE_ENV': '"production"'
}
})
const content = readFileSync(join(outDir, 'wsl-agent-hook-relay.js'))
const hash = createHash('sha256').update(content).digest('hex').slice(0, 12)
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`)
console.log(`Built WSL hook relay → ${outDir}/wsl-agent-hook-relay.js`)
await build({
entryPoints: [wslBrowserNetworkEntry],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, 'wsl-browser-network-relay.js'),
sourcemap: false,
minify: true,
define: {
'process.env.NODE_ENV': '"production"'
}
})
const browserNetworkContent = readFileSync(join(outDir, 'wsl-browser-network-relay.js'))
const browserNetworkHash = createHash('sha256')
.update(browserNetworkContent)
.digest('hex')
.slice(0, 12)
writeFileSync(join(outDir, '.browser-network-version'), `${RELAY_VERSION}+${browserNetworkHash}`)
console.log(`Built WSL browser network relay → ${outDir}/wsl-browser-network-relay.js`)
}
console.log('Relay build complete.')