[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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user