[codex] fix Windows desktop browser packaging (#1219)

* fix windows hermes home fallback

* bundle Hermes desktop browser runtime

* bundle desktop channel dependencies

* avoid matrix e2ee build dependency

* fix windows npm shim execution

* fix bundled agent-browser chrome packaging

* fix agent-browser chrome fallback copy

* fix windows agent-browser home lookup

* copy agent-browser chrome after install

* fix browser output decoding on windows

---------

Co-authored-by: xingzhi <chuzihao.czh@alibaba-inc.com>
This commit is contained in:
ekko
2026-06-01 21:35:26 +08:00
committed by GitHub
parent 90929d0bfb
commit c27a12f56c
9 changed files with 668 additions and 50 deletions
+52 -4
View File
@@ -1,5 +1,5 @@
import { app } from 'electron'
import { existsSync } from 'node:fs'
import { existsSync, readdirSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { homedir, platform, arch } from 'node:os'
@@ -31,6 +31,43 @@ export function pythonDir(): string {
return resolve(app.getAppPath(), 'resources', 'python', `${osLabel}-${archLabel}`)
}
export function bundledAgentBrowserHome(): string {
return join(pythonDir(), 'agent-browser')
}
function browserExecutableNames(): Set<string> {
if (isWin) return new Set(['chrome.exe'])
if (platform() === 'darwin') return new Set(['Google Chrome for Testing', 'Google Chrome', 'Chromium', 'chrome'])
return new Set(['chrome', 'chromium', 'chromium-browser'])
}
export function bundledBrowserExecutable(): string | undefined {
const names = browserExecutableNames()
const stack = [join(bundledAgentBrowserHome(), 'browsers'), bundledAgentBrowserHome()].filter(existsSync)
const visited = new Set<string>()
while (stack.length > 0) {
const dir = stack.pop()
if (!dir || visited.has(dir)) continue
visited.add(dir)
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const path = join(dir, entry.name)
if (entry.isFile() && names.has(entry.name)) return path
if (entry.isDirectory()) stack.push(path)
}
}
return undefined
}
export function pythonBinDir(): string {
const dir = pythonDir()
return isWin ? join(dir, 'Scripts') : join(dir, 'bin')
@@ -72,12 +109,23 @@ export function hermesHome(): string {
const override = process.env.HERMES_HOME?.trim()
if (override) return resolve(override)
const defaultHome = resolve(homedir(), '.hermes')
if (isWin) {
const localAppData = process.env.LOCALAPPDATA?.trim() || process.env.APPDATA?.trim()
if (localAppData) return resolve(localAppData, 'hermes')
const candidates = [
process.env.LOCALAPPDATA,
process.env.APPDATA,
]
.map(value => value?.trim())
.filter((value): value is string => !!value)
.map(value => resolve(value, 'hermes'))
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate
}
}
return resolve(homedir(), '.hermes')
return defaultHome
}
export function tokenFile(): string {
+90 -4
View File
@@ -6,10 +6,21 @@ import { dirname, delimiter, join } from 'node:path'
import { randomBytes } from 'node:crypto'
import { promisify } from 'node:util'
import { app } from 'electron'
import { webuiServerEntry, webuiDir, hermesBin, webUiHome, hermesHome, tokenFile, pythonDir } from './paths'
import {
bundledBrowserExecutable,
webuiServerEntry,
webuiDir,
hermesBin,
webUiHome,
hermesHome,
tokenFile,
pythonDir,
} from './paths'
const DEFAULT_PORT = 8748
const DEFAULT_READY_TIMEOUT_MS = 30_000
const AGENT_BRIDGE_STARTED_MARKER = '[bootstrap] agent bridge started'
const AGENT_BRIDGE_FAILED_MARKER = '[bootstrap] agent bridge failed to start'
const execFileAsync = promisify(execFile)
let serverProc: ChildProcess | null = null
@@ -47,6 +58,60 @@ function readyTimeoutMs(): number {
return envPositiveInt('HERMES_DESKTOP_READY_TIMEOUT_MS') || DEFAULT_READY_TIMEOUT_MS
}
function createAgentBridgeStartupTracker(): {
observe: (chunk: Buffer) => void
wait: (timeoutMs: number) => Promise<void>
} {
let output = ''
let state: 'pending' | 'started' | 'failed' = 'pending'
let resolveReady: (() => void) | null = null
let rejectReady: ((err: Error) => void) | null = null
const settle = (nextState: 'started' | 'failed') => {
if (state !== 'pending') return
state = nextState
if (nextState === 'started') {
resolveReady?.()
} else {
rejectReady?.(new Error('Agent bridge failed to start'))
}
}
const observe = (chunk: Buffer) => {
if (state !== 'pending') return
output = (output + chunk.toString('utf-8')).slice(-4096)
if (output.includes(AGENT_BRIDGE_STARTED_MARKER)) {
settle('started')
} else if (output.includes(AGENT_BRIDGE_FAILED_MARKER)) {
settle('failed')
}
}
const wait = (timeoutMs: number) => {
if (state === 'started') return Promise.resolve()
if (state === 'failed') return Promise.reject(new Error('Agent bridge failed to start'))
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
if (state !== 'pending') return
state = 'failed'
reject(new Error(`Agent bridge did not become ready within ${timeoutMs}ms`))
}, timeoutMs)
resolveReady = () => {
clearTimeout(timer)
resolve()
}
rejectReady = (err) => {
clearTimeout(timer)
reject(err)
}
})
}
return { observe, wait }
}
function ensureToken(): string {
if (cachedToken) return cachedToken
const file = tokenFile()
@@ -231,17 +296,22 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
const bundledPython = isWin
? join(pythonDir(), 'python.exe')
: join(pythonDir(), 'bin', 'python3')
const bundledNodeBin = isWin
? join(pythonDir(), 'node')
: join(pythonDir(), 'node', 'bin')
const bridgePort = await getFreeTcpPort()
const workerPortBase = await getFreeTcpPortInRange(20000, 59000)
const loginShellPath = await getLoginShellPath()
const nvmNodeBinPaths = getNvmNodeBinPaths()
const runtimePath = mergePathEntries(
dirname(hermesBin()),
bundledNodeBin,
loginShellPath,
nvmNodeBinPaths,
process.env.PATH,
COMMON_USER_BIN_DIRS.join(delimiter),
)
const browserExecutable = process.env.AGENT_BROWSER_EXECUTABLE_PATH?.trim() || bundledBrowserExecutable()
// Run via Electron's "run as Node" mode — Electron binary doubles as Node.
const env: NodeJS.ProcessEnv = {
@@ -256,11 +326,18 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
HERMES_AGENT_BRIDGE_PYTHON: bundledPython,
HERMES_AGENT_CLI_PYTHON: bundledPython,
HERMES_AGENT_ROOT: pythonDir(),
AGENT_BROWSER_HOME: process.env.AGENT_BROWSER_HOME?.trim() || join(agentHome, 'agent-browser'),
...(browserExecutable ? { AGENT_BROWSER_EXECUTABLE_PATH: browserExecutable } : {}),
PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || join(pythonDir(), 'ms-playwright'),
// Force TCP loopback for the agent bridge. The default `ipc:///tmp/...`
// unix socket is rejected on macOS in some EDR/sandbox setups (silent
// SIGKILL of the bridge child within ~150ms). TCP on 127.0.0.1 works
// identically and avoids the issue cross-platform.
HERMES_AGENT_BRIDGE_ENDPOINT: `tcp://127.0.0.1:${bridgePort}`,
// Desktop opens the UI as soon as the Web UI HTTP server is ready, while
// the Python bridge starts in the background. Let the first chat/context
// request wait for broker readiness instead of failing during cold start.
HERMES_AGENT_BRIDGE_CONNECT_RETRY_MS: process.env.HERMES_AGENT_BRIDGE_CONNECT_RETRY_MS ?? '120000',
// Force TCP for worker endpoints too (upstream #1106). Same EDR/sandbox
// reason as above — default ipc:// unix sockets in /tmp get killed.
HERMES_AGENT_BRIDGE_WORKER_TRANSPORT: 'tcp',
@@ -278,8 +355,9 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
// HERMES_HOME/.env or by configuring per-platform allowlists.
GATEWAY_ALLOW_ALL_USERS: process.env.GATEWAY_ALLOW_ALL_USERS ?? 'true',
// Keep the bundled Hermes Agent, bridge, gateway, and Web UI path helpers
// on the same data directory. Native Windows uses %LOCALAPPDATA%\hermes;
// macOS/Linux keep the standard ~/.hermes layout.
// on the same data directory. Native Windows uses an existing
// %LOCALAPPDATA%\hermes or %APPDATA%\hermes; otherwise all platforms keep
// the standard ~/.hermes layout.
HERMES_HOME: agentHome,
HERMES_WEB_UI_HOME: home,
HERMES_WEBUI_STATE_DIR: home,
@@ -295,10 +373,14 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
windowsHide: true,
})
const bridgeStartup = createAgentBridgeStartupTracker()
serverProc.stdout?.on('data', (chunk: Buffer) => {
bridgeStartup.observe(chunk)
process.stdout.write(`[webui] ${chunk}`)
})
serverProc.stderr?.on('data', (chunk: Buffer) => {
bridgeStartup.observe(chunk)
process.stderr.write(`[webui] ${chunk}`)
})
serverProc.on('exit', (code, signal) => {
@@ -309,7 +391,11 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
}
})
await waitForReady(port, readyTimeoutMs())
const timeoutMs = readyTimeoutMs()
await Promise.all([
waitForReady(port, timeoutMs),
bridgeStartup.wait(timeoutMs),
])
return getServerUrl(port)
}