mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
* fix(codex): stop blocking the main thread on trust grants (#16441) Codex hook trust was granted by blocking the Electron main thread on `spawnSync` of a bundled ELECTRON_RUN_AS_NODE entry for the whole app-server deadline: 15s native, 35s WSL, ~45s on the real-home path (rebase inspect + repair + grant). Cold start and every Codex pane launch showed "Not Responding"; the reported event-loop gap was 15,049 ms. The subprocess only ever existed to donate an event loop to a deliberately blocked parent — `runCodexHookTrustGrantSession` was already the real async implementation. Make the callers async and the fork is unnecessary, so the bridge, the forked entry and its envelope are deleted along with their build/knip/tsconfig registrations. The CLI `agent hooks prepare-codex` handler is already async, so it awaits the in-process session and saves a process spawn per managed-home shell. `resolveCodexTrustGrantHost` is async too; the WSL identity probe moves from `execFileSync` to `runProcess`, dropping that file from the child-process import allowlist. Status reads keep a synchronous native-only stamp path. Two invariants that held only because the lane blocked: - Overlapping capability probes were impossible by construction. `GitCapabilityCache`'s dedupe engine is extracted to a shared `CapabilityProbeCache` and `CodexAppServerCapabilityCache` now inherits it, so concurrent launches against a cold host share one app-server session instead of one each. - Two grants on one `config.toml` could not interleave capture and restore. A reentrant per-file lane now serializes the whole install sequence (managed, WSL runtime, real-home ensure, legacy sweep) and the grant and rebase inside it. Cold-start work moves off the critical path: retained-home reconciliation (N sequential sessions) is fire-and-forget behind the daemon provider, and the startup real-home ensure chains into managed hook reconciliation instead of blocking app init. Every preserved semantic is unchanged: never throws, the ORCA_DISABLE_CODEX_TRUST_RPC kill switch, ledger hits, backfill-pending and cooldown fallbacks, config rollback on every failure path, pre-grant self-computed trust removal, the verify-failure taxonomy, diagnostics and telemetry. * fix(codex): widen the trust-config lane to every config.toml writer Review follow-ups on #16441's async trust grant: - `markCodexProjectTrusted` now runs inside the runtime+system config.toml lanes, so a project-trust write can no longer land inside a hook grant's capture->restore window and be silently reverted. Its callers await it. - `install`/`refreshRuntimeUserHooks`/`remove` hold the system config.toml lane as well as the runtime one — they promote approvals into ~/.codex/config.toml and mirror it back. Lock order is runtime-before-system everywhere. - The real-home ensure chain resumes after a rejection instead of returning the same rejected promise to every later pane launch, and resolving the real home is now inside the module's never-throws boundary. - `buildSpawnEnv` awaits inside a cancelable pending-spawn registration, so shutdown during the (now long) env build stops the PTY from launching. `prepareLocalPtySpawn` generalizes into `awaitCancelableLocalPtySpawn`. - CapabilityProbeCache drops the test-only `nowMs` passthrough; its probe backstop comment now describes what it actually guards. - Preflight is a plain async function; the trust dispatch in orca-runtime collapses into one `markWorkspaceTrustedForAgent`. * test(codex): exercise the trust-config lane under real concurrency The async grant makes two pane launches overlap for the first time. These drive the real modules end to end on real files: a rollback swallowing a sibling's grant, a markCodexProjectTrusted write landing inside a capture -> restore window, shared capability-probe dedupe on a cold host, the host-scoped transient cooldown, and reentrancy from inside an installer. Each was verified to fail against a deliberately broken implementation (lane removed, dedupe disabled, cooldown made global, reentrancy pass- through disabled). * test(codex): stop hook-service suites spawning the developer's real codex The forked grant bundle never existed under vitest, so the RPC lane was unreachable in tests on main. Running it in-process makes these suites spawn a real `codex app-server` when one is installed: 38 spawns and two failures in hook-service-runtime-trust-repair on a machine with codex, green in CI where there is none. Stand in for the missing binary so both environments exercise the same fallback lane. * docs(codex): scope the trust-RPC kill switch comment to what it actually gates The comment read as though the flag forces the fallback lane everywhere. It gates the managed grant only: the real-home rebase still runs its own inspect/repair app-server sessions when Orca's insertion shifts a user's hook positions, and never reads the flag. Verified by exercise, not by reading — with the flag set, both inspect-user-hook-trust and repair-user-hook-trust still ran. Pre-existing: main has no check there either, it just blocked the main thread while doing it. Widening the flag to cover the rebase is a follow-up; this only stops the comment promising something the constant does not do.
321 lines
13 KiB
TypeScript
321 lines
13 KiB
TypeScript
import { isBuiltin } from 'node:module'
|
|
import { resolve } from 'node:path'
|
|
import { defineConfig, type UserConfig } from 'electron-vite'
|
|
import react from '@vitejs/plugin-react'
|
|
import tailwindcss from '@tailwindcss/vite'
|
|
import { createBootstrapFatalExitBanner } from './config/build-plugins/bootstrap-fatal-exit-banner'
|
|
import { createPlainNodeEntryGuardPlugin } from './config/build-plugins/plain-node-entry-guard'
|
|
import packageJson from './package.json' with { type: 'json' }
|
|
|
|
const BUNDLED_MAIN_DEPENDENCIES = new Set([
|
|
'@xterm/headless',
|
|
'@xterm/addon-serialize',
|
|
'psl',
|
|
// Why: Windows NSIS deploys app.asar before external resources; bootstrap must
|
|
// not race the later resources/node_modules copy.
|
|
'zod'
|
|
])
|
|
const EXTERNAL_MAIN_DEPENDENCIES = Object.keys(packageJson.dependencies).filter(
|
|
(dependency) => !BUNDLED_MAIN_DEPENDENCIES.has(dependency)
|
|
)
|
|
|
|
function isExternalMainModule(source: string): boolean {
|
|
if (isBuiltin(source) || source === 'electron' || source.startsWith('electron/')) {
|
|
return true
|
|
}
|
|
return EXTERNAL_MAIN_DEPENDENCIES.some(
|
|
(dependency) => source === dependency || source.startsWith(`${dependency}/`)
|
|
)
|
|
}
|
|
|
|
// Why: the telemetry transport is gated by two compile-time constants that
|
|
// only the official CI release workflow sets. Contributor / `pnpm dev` /
|
|
// third-party rebuilds must substitute literal `null` at these sites so
|
|
// `IS_OFFICIAL_BUILD` in `src/main/telemetry/client.ts` evaluates `false`
|
|
// at module load and the track() wrapper short-circuits to console-mirror.
|
|
// The substitution happens at compile time — there is no runtime env-var
|
|
// fallback — so a curious contributor cannot spoof transmission with a
|
|
// shell export.
|
|
//
|
|
// CI injects real values via GitHub Actions secrets
|
|
// (ORCA_BUILD_IDENTITY='stable' | 'rc', ORCA_POSTHOG_WRITE_KEY=phc_...);
|
|
// every other build path resolves these env vars to undefined, which the
|
|
// JSON.stringify below folds to the literal `null`. Ambient declarations
|
|
// for the two constants live in `src/types/build-constants.d.ts`.
|
|
const orcaBuildIdentity = process.env.ORCA_BUILD_IDENTITY
|
|
const ORCA_BUILD_IDENTITY_LITERAL =
|
|
orcaBuildIdentity === 'stable' || orcaBuildIdentity === 'rc'
|
|
? JSON.stringify(orcaBuildIdentity)
|
|
: 'null'
|
|
const orcaPostHogWriteKey = process.env.ORCA_POSTHOG_WRITE_KEY
|
|
const ORCA_POSTHOG_WRITE_KEY_LITERAL =
|
|
typeof orcaPostHogWriteKey === 'string' && orcaPostHogWriteKey.length > 0
|
|
? JSON.stringify(orcaPostHogWriteKey)
|
|
: 'null'
|
|
const orcaDiagnosticsTokenUrl = process.env.ORCA_DIAGNOSTICS_TOKEN_URL
|
|
const ORCA_DIAGNOSTICS_TOKEN_URL_LITERAL =
|
|
typeof orcaDiagnosticsTokenUrl === 'string' && orcaDiagnosticsTokenUrl.length > 0
|
|
? JSON.stringify(orcaDiagnosticsTokenUrl)
|
|
: 'null'
|
|
|
|
function createStartupDiagnosticsBanner(chunkName: string): string {
|
|
return `
|
|
;(() => {
|
|
const env = typeof process !== 'undefined' ? process.env : undefined
|
|
const mode = env?.ORCA_STARTUP_DIAGNOSTICS
|
|
if (mode !== '1' && mode !== 'trace') {
|
|
return
|
|
}
|
|
const safeJson = (value) => {
|
|
try {
|
|
return JSON.stringify(value)
|
|
} catch {
|
|
return '"<unserializable>"'
|
|
}
|
|
}
|
|
let closeSync
|
|
let diagnosticFileDescriptor
|
|
let openSync
|
|
let writeSync
|
|
try {
|
|
const fs = require('node:fs')
|
|
closeSync = fs.closeSync
|
|
openSync = fs.openSync
|
|
writeSync = fs.writeSync
|
|
} catch {
|
|
closeSync = undefined
|
|
openSync = undefined
|
|
writeSync = undefined
|
|
}
|
|
const diagnosticFile = env?.ORCA_STARTUP_DIAGNOSTICS_FILE
|
|
if (typeof diagnosticFile === 'string' && diagnosticFile.length > 0 && typeof openSync === 'function') {
|
|
try {
|
|
diagnosticFileDescriptor = openSync(diagnosticFile, 'a', 0o600)
|
|
} catch {
|
|
diagnosticFileDescriptor = undefined
|
|
}
|
|
}
|
|
const writeLine = (message) => {
|
|
try {
|
|
const line = message.endsWith('\\n') ? message : message + '\\n'
|
|
if (typeof writeSync === 'function') {
|
|
writeSync(2, line)
|
|
if (typeof diagnosticFileDescriptor === 'number') {
|
|
writeSync(diagnosticFileDescriptor, line)
|
|
}
|
|
}
|
|
} catch {
|
|
// Diagnostics must never affect startup.
|
|
}
|
|
}
|
|
const chunkName = ${JSON.stringify(chunkName)}
|
|
writeLine('[bootstrap] bundle-enter chunk=' + safeJson(chunkName) + ' pid=' + process.pid + ' ppid=' + process.ppid + ' execPath=' + safeJson(process.execPath) + ' argv=' + safeJson(process.argv) + ' electronRunAsNode=' + safeJson(env?.ELECTRON_RUN_AS_NODE ?? null))
|
|
if (!globalThis.__ORCA_BOOTSTRAP_EXIT_LOG_INSTALLED__) {
|
|
globalThis.__ORCA_BOOTSTRAP_EXIT_LOG_INSTALLED__ = true
|
|
process.once('exit', (code) => {
|
|
writeLine('[bootstrap] process-exit code=' + code)
|
|
if (typeof closeSync === 'function' && typeof diagnosticFileDescriptor === 'number') {
|
|
try {
|
|
closeSync(diagnosticFileDescriptor)
|
|
} catch {
|
|
// Diagnostics must never affect shutdown.
|
|
}
|
|
}
|
|
})
|
|
process.on('uncaughtExceptionMonitor', (error, origin) => {
|
|
const message = error && typeof error === 'object' && 'stack' in error ? error.stack : error
|
|
writeLine('[bootstrap] uncaught-exception origin=' + safeJson(origin) + ' error=' + safeJson(String(message)))
|
|
})
|
|
process.on('unhandledRejection', (reason) => {
|
|
const message = reason && typeof reason === 'object' && 'stack' in reason ? reason.stack : reason
|
|
writeLine('[bootstrap] unhandled-rejection error=' + safeJson(String(message)))
|
|
})
|
|
}
|
|
if (mode === 'trace' && !globalThis.__ORCA_BOOTSTRAP_REQUIRE_TRACE_INSTALLED__) {
|
|
globalThis.__ORCA_BOOTSTRAP_REQUIRE_TRACE_INSTALLED__ = true
|
|
try {
|
|
const Module = require('node:module')
|
|
const originalLoad = Module._load
|
|
const parsedTraceLimit = Number(env?.ORCA_STARTUP_DIAGNOSTICS_TRACE_LIMIT ?? 20000)
|
|
const traceLimit = Number.isFinite(parsedTraceLimit) && parsedTraceLimit > 0 ? parsedTraceLimit : 20000
|
|
let traceLineCount = 0
|
|
let traceLimitReported = false
|
|
const writeTraceLine = (message) => {
|
|
if (traceLineCount >= traceLimit) {
|
|
if (!traceLimitReported) {
|
|
traceLimitReported = true
|
|
writeLine('[bootstrap] require-trace-limit-reached limit=' + safeJson(traceLimit))
|
|
}
|
|
return
|
|
}
|
|
traceLineCount += 1
|
|
writeLine(message)
|
|
}
|
|
Module._load = function (request, parent, isMain) {
|
|
const parentName = parent && parent.filename ? parent.filename : null
|
|
writeTraceLine('[bootstrap] require-start request=' + safeJson(request) + ' parent=' + safeJson(parentName) + ' isMain=' + safeJson(Boolean(isMain)))
|
|
try {
|
|
const result = Reflect.apply(originalLoad, this, arguments)
|
|
writeTraceLine('[bootstrap] require-ok request=' + safeJson(request))
|
|
return result
|
|
} catch (error) {
|
|
const message = error && typeof error === 'object' && 'stack' in error ? error.stack : error
|
|
writeTraceLine('[bootstrap] require-error request=' + safeJson(request) + ' error=' + safeJson(String(message)))
|
|
throw error
|
|
}
|
|
}
|
|
} catch (error) {
|
|
writeLine('[bootstrap] require-trace-install-error error=' + safeJson(String(error)))
|
|
}
|
|
}
|
|
})();
|
|
`
|
|
}
|
|
|
|
function createMainBootstrapPlugin() {
|
|
return {
|
|
name: 'orca-main-bootstrap',
|
|
generateBundle(_options, bundle) {
|
|
const mainChunk = bundle['index.js']
|
|
if (!mainChunk || mainChunk.type !== 'chunk') {
|
|
return
|
|
}
|
|
|
|
// Why: source guards and diagnostics run after Rollup's generated require
|
|
// prelude, too late to handle a missing bootstrap dependency.
|
|
mainChunk.code =
|
|
createBootstrapFatalExitBanner() +
|
|
createStartupDiagnosticsBanner(mainChunk.fileName) +
|
|
mainChunk.code
|
|
}
|
|
}
|
|
}
|
|
|
|
export const electronViteConfig: UserConfig = {
|
|
main: {
|
|
build: {
|
|
// Why: daemon-entry.js is asar-unpacked so child_process.fork() can
|
|
// execute it from disk. Node's module resolution from the unpacked
|
|
// directory cannot reach into app.asar; startup-critical pure JS must
|
|
// also survive a partially copied Windows resources tree.
|
|
externalizeDeps: {
|
|
exclude: [...BUNDLED_MAIN_DEPENDENCIES]
|
|
},
|
|
rollupOptions: {
|
|
// Why: native dependencies must resolve from packaged node_modules,
|
|
// while the unpacked daemon needs its pure-JS xterm graph bundled.
|
|
external: isExternalMainModule,
|
|
input: {
|
|
index: resolve('src/main/index.ts'),
|
|
// Why: sandboxed webview preloads cannot load Rollup helper chunks.
|
|
'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'),
|
|
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
|
|
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
|
|
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
|
|
'stt-worker': resolve('src/main/speech/stt-worker.ts'),
|
|
'warp-theme-parser-worker': resolve('src/main/warp-themes/warp-theme-parser-worker.ts'),
|
|
'session-scanner-opencode-sqlite-worker-entry': resolve(
|
|
'src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts'
|
|
),
|
|
'session-scanner-worker-entry': resolve(
|
|
'src/main/ai-vault/session-scanner-worker-entry.ts'
|
|
),
|
|
'session-scanner-service-entry': resolve(
|
|
'src/main/ai-vault/session-scanner-service-entry.ts'
|
|
),
|
|
'wsl-transcript-fs-process-entry': resolve(
|
|
'src/main/native-chat/wsl-transcript-fs-process-entry.ts'
|
|
),
|
|
// Why: libuv spawns processes inline on the calling loop, so the port
|
|
// scan's probe commands run on a worker thread instead of the UI one.
|
|
'port-scan-command-worker-entry': resolve(
|
|
'src/main/ports/port-scan-command-worker-entry.ts'
|
|
),
|
|
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
|
|
// can't take down the main process (issue #7547).
|
|
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
|
|
// Why: a worker thread survives the macOS 26 AppKit main-thread deadlock
|
|
// without paying for another Electron process.
|
|
'main-thread-hang-watchdog-entry': resolve(
|
|
'src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts'
|
|
),
|
|
// Why: electron-vite cleans out/main in dev. The dev CLI imports
|
|
// this path for `orca agent hooks ...`, so it must survive rebuilds.
|
|
'agent-hooks/managed-agent-hook-controls': resolve(
|
|
'src/main/agent-hooks/managed-agent-hook-controls.ts'
|
|
),
|
|
// Why: account import mutates the user's macOS Keychain from the CLI.
|
|
'claude-accounts/keychain': resolve('src/main/claude-accounts/keychain.ts')
|
|
},
|
|
// Why: Rolldown's SSR default is ESM, but Electron and sidecar launchers
|
|
// consume these stable CommonJS paths.
|
|
output: {
|
|
format: 'cjs',
|
|
entryFileNames: '[name].js',
|
|
chunkFileNames: 'chunks/[name]-[hash].js'
|
|
},
|
|
plugins: [createMainBootstrapPlugin(), createPlainNodeEntryGuardPlugin()]
|
|
}
|
|
},
|
|
// Why: compile-time substitution for the telemetry gate. See the block
|
|
// above for the full rationale.
|
|
define: {
|
|
ORCA_BUILD_IDENTITY: ORCA_BUILD_IDENTITY_LITERAL,
|
|
ORCA_POSTHOG_WRITE_KEY: ORCA_POSTHOG_WRITE_KEY_LITERAL,
|
|
ORCA_DIAGNOSTICS_TOKEN_URL: ORCA_DIAGNOSTICS_TOKEN_URL_LITERAL
|
|
},
|
|
// Why: @xterm/headless declares "exports": null in package.json, which
|
|
// prevents Vite's default resolver from finding the CJS entry. Point
|
|
// directly at the published main file so the bundler can inline it.
|
|
resolve: {
|
|
alias: {
|
|
'@xterm/headless': resolve('node_modules/@xterm/headless/lib-headless/xterm-headless.js'),
|
|
'@xterm/addon-serialize': resolve(
|
|
'node_modules/@xterm/addon-serialize/lib/addon-serialize.js'
|
|
)
|
|
}
|
|
}
|
|
},
|
|
preload: {
|
|
build: {
|
|
externalizeDeps: {
|
|
exclude: ['@electron-toolkit/preload', 'zod']
|
|
}
|
|
}
|
|
},
|
|
renderer: {
|
|
resolve: {
|
|
alias: {
|
|
'@renderer': resolve('src/renderer/src'),
|
|
'@': resolve('src/renderer/src')
|
|
}
|
|
},
|
|
plugins: [react(), tailwindcss()],
|
|
worker: {
|
|
format: 'es'
|
|
},
|
|
build: {
|
|
manifest: true,
|
|
modulePreload: { polyfill: true },
|
|
target: 'es2020',
|
|
// Why: the pop-out dashboard is a second top-level window with its own
|
|
// React root. It gets its own HTML entry so it can boot independently of
|
|
// the main window while reusing the same preload/window.api. `index` must
|
|
// stay listed — overriding input otherwise drops electron-vite's default
|
|
// renderer entry.
|
|
rollupOptions: {
|
|
// Why: shared chunks must never import an HTML entry whose module mounts
|
|
// a different React root.
|
|
preserveEntrySignatures: 'strict',
|
|
input: {
|
|
index: resolve('src/renderer/index.html'),
|
|
popout: resolve('src/renderer/popout.html'),
|
|
web: resolve('src/renderer/web-index.html')
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export default defineConfig(electronViteConfig)
|