fix(orcad): materialize the app's agent-browser driver into the local slot (#34150)

* fix(orcad): materialize the app's agent-browser driver into the local slot

Replaces #34111's ORCA_AGENT_BROWSER_DRIVER hand-off, Windows profile copy and
claimed snapshot store. Packaging now references the app's own
resources/agent-browser-<target> from the orcad template manifest
(packagedBrowser, like packagedRipgrep), so the materialized slot carries it and
every local orcad launch finds it beside orcad-server.js. macOS signs the driver
before hashing; Windows reseals it after SignPath. The resolver now also tries
the musl driver name on Linux.

Refs #33846

* build(orcad): let a checkout keep template drivers with ORCAD_OMIT_AGENT_BROWSER=0
This commit is contained in:
Neil
2026-10-27 01:28:20 -07:00
committed by GitHub
parent 0cff8e0fb7
commit 0ed5f4bb21
21 changed files with 274 additions and 297 deletions
+17 -6
View File
@@ -454,19 +454,28 @@ module.exports = {
assertPackagedWebClient(resourcesDir)
assertPackagedServeSelectionEntry(resourcesDir)
finalizePackagedRipgrep(resourcesDir)
await finalizePackagedOrcadTemplate(resourcesDir, {
platform: context.electronPlatformName,
signMacBinary: (path) =>
signMacStandaloneHelper(path, 'orcad template binary', context.packager)
})
chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName)
for (const filename of readdirSync(resourcesDir)) {
if (!filename.startsWith('agent-browser-')) {
continue
}
// Upstream executable bits vary (notably darwin-x64); restore them so packaged execFile works.
chmodSync(join(resourcesDir, filename), 0o755)
// The orcad template hashes this exact file next, so app signing must not rewrite it later.
if (context.electronPlatformName === 'darwin') {
await signMacStandaloneHelper(
join(resourcesDir, filename),
filename,
context.packager,
resolve(__dirname, '../resources/build/entitlements.mac.plist')
)
}
}
await finalizePackagedOrcadTemplate(resourcesDir, {
platform: context.electronPlatformName,
signMacBinary: (path) =>
signMacStandaloneHelper(path, 'orcad template binary', context.packager)
})
chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName)
if (context.electronPlatformName === 'darwin') {
await signMacComputerUseHelper(join(resourcesDir, 'Orca Computer Use.app'), context.packager)
await signMacStandaloneHelper(
@@ -564,6 +573,8 @@ module.exports = {
signIgnore: [
...bundledRipgrepMacSignIgnore,
...orcadTemplateMacSignIgnore,
// afterPack signs it before the orcad template hashes it.
'/Contents/Resources/agent-browser-darwin-[^/]+$',
...macTerminalHostSignIgnore
],
extendInfo: macUsageDescriptions,
+3 -2
View File
@@ -73,8 +73,9 @@ function buildTargetPackage(target) {
packageDir
],
cwd: root,
// Desktop template omits optional ~10MB browsers per target; seven copies would outweigh the rest.
env: { ...process.env, ORCAD_OMIT_AGENT_BROWSER: '1' },
// Desktop template omits optional ~10MB browsers per target; packaging references the app's own copy instead.
// A checkout has no app copy, so ORCAD_OMIT_AGENT_BROWSER=0 keeps drivers for local Chromium serve.
env: { ...process.env, ORCAD_OMIT_AGENT_BROWSER: process.env.ORCAD_OMIT_AGENT_BROWSER ?? '1' },
stdio: 'inherit',
timeoutMs: null
})
+56
View File
@@ -0,0 +1,56 @@
const { createHash } = require('node:crypto')
const { existsSync, readFileSync, writeFileSync } = require('node:fs')
const { join } = require('node:path')
const { ORCAD_TEMPLATE_MANIFEST_FILENAME } = require('../../src/shared/orcad-artifacts.ts')
const { orcadAgentBrowserNativeName } = require('../../src/shared/orcad-agent-browser-name.ts')
const { verifyPackagedOrcadTemplate } = require('./verify-packaged-orcad-template.cjs')
function sha256(path) {
return createHash('sha256').update(readFileSync(path)).digest('hex')
}
function targetBrowserName(target) {
const [platform, arch] = target.split('-')
return orcadAgentBrowserNativeName(platform, arch, target.endsWith('-musl') ? 'musl' : 'glibc')
}
function manifestPath(resourcesDir) {
return join(resourcesDir, 'orcad-template', ORCAD_TEMPLATE_MANIFEST_FILENAME)
}
/** The template omits drivers for size; point a target at the app's own copy so its slot still gets one (#33846). */
function referencePackagedOrcadBrowser(resourcesDir) {
const path = manifestPath(resourcesDir)
const manifest = JSON.parse(readFileSync(path, 'utf8'))
const referenced = []
for (const [target, entry] of Object.entries(manifest.targets)) {
const browserName = targetBrowserName(target)
if (entry.browserName || !existsSync(join(resourcesDir, browserName))) {
continue
}
entry.browserName = browserName
entry.browserSha256 = sha256(join(resourcesDir, browserName))
entry.packagedBrowser = true
referenced.push(target)
}
if (referenced.length > 0) {
writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`)
}
verifyPackagedOrcadTemplate(resourcesDir)
return referenced
}
/** Reseal referenced drivers the Windows signer rewrote; `signedResourcePaths` are resources-relative. */
function resealSignedPackagedBrowser(resourcesDir, signedResourcePaths) {
const path = manifestPath(resourcesDir)
const manifest = JSON.parse(readFileSync(path, 'utf8'))
const signed = new Set(signedResourcePaths)
for (const entry of Object.values(manifest.targets)) {
if (entry.packagedBrowser && signed.has(entry.browserName)) {
entry.browserSha256 = sha256(join(resourcesDir, entry.browserName))
}
}
writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`)
}
module.exports = { referencePackagedOrcadBrowser, resealSignedPackagedBrowser }
@@ -0,0 +1,69 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { assembleOrcadArtifact } from '../../src/main/orcad/orcad-artifact-materializer.ts'
import { ORCAD_TEMPLATE_MANIFEST_FILENAME } from '../../src/shared/orcad-artifacts.ts'
import { writeOrcadTemplateTestFixture } from './orcad-template-test-fixture.mjs'
const require = createRequire(import.meta.url)
const {
finalizePackagedOrcadTemplate,
resealSignedWindowsApp
} = require('./packaged-orcad-template.cjs')
const roots = []
afterEach(async () => {
vi.restoreAllMocks()
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
async function packagedApp() {
vi.spyOn(console, 'log').mockImplementation(() => {})
const appDir = await mkdtemp(join(tmpdir(), 'orca-packaged-browser-'))
roots.push(appDir)
const resourcesDir = join(appDir, 'resources')
const templateDir = await writeOrcadTemplateTestFixture(resourcesDir)
const driver = join(resourcesDir, 'agent-browser-win32-x64.exe')
await writeFile(driver, 'desktop driver')
await finalizePackagedOrcadTemplate(resourcesDir, { platform: 'win32', signMacBinary: vi.fn() })
return {
appDir,
templateDir,
driver,
manifestPath: join(templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME)
}
}
describe("packaged orcad uses the app's agent-browser driver (#33846)", () => {
it("materializes the host target's slot with the driver the app ships", async () => {
const app = await packagedApp()
const slot = await assembleOrcadArtifact({
templateDir: app.templateDir,
cacheRoot: join(app.appDir, 'cache'),
target: 'win32-x64'
})
expect(await readFile(join(slot, 'agent-browser-win32-x64.exe'), 'utf8')).toBe('desktop driver')
// A target that carries its own copy keeps it.
const manifest = JSON.parse(await readFile(app.manifestPath, 'utf8'))
expect(manifest.targets['linux-x64-glibc'].packagedBrowser).toBeUndefined()
expect(manifest.targets['win32-arm64'].browserName).toBeUndefined()
})
it('reseals the driver after SignPath rewrites it', async () => {
const app = await packagedApp()
await writeFile(app.driver, 'authenticode-signed driver')
const list = join(app.appDir, 'inner-signing-list.txt')
await writeFile(list, 'Orca.exe\r\nresources\\agent-browser-win32-x64.exe\r\n')
resealSignedWindowsApp(app.appDir, list)
const slot = await assembleOrcadArtifact({
templateDir: app.templateDir,
cacheRoot: join(app.appDir, 'cache'),
target: 'win32-x64'
})
expect(await readFile(join(slot, 'agent-browser-win32-x64.exe'), 'utf8')).toBe(
'authenticode-signed driver'
)
})
})
+10 -4
View File
@@ -20,6 +20,10 @@ const {
compactPackagedOrcadRipgrep,
resealSignedPackagedRipgrep
} = require('./compact-packaged-orcad-ripgrep.cjs')
const {
referencePackagedOrcadBrowser,
resealSignedPackagedBrowser
} = require('./packaged-orcad-browser.cjs')
const ORCAD_TEMPLATE_RESOURCE_DIR = 'orcad-template'
const orcadTemplateExtraResource = { from: 'out/orcad-template', to: ORCAD_TEMPLATE_RESOURCE_DIR }
@@ -125,6 +129,7 @@ async function finalizePackagedOrcadTemplate(resourcesDir, options) {
resealOrcadTemplateManifest(templateDir, machO)
}
compactPackagedOrcadRipgrep(resourcesDir)
referencePackagedOrcadBrowser(resourcesDir)
}
/** `inner-signing-list.txt` lines are app-relative Windows paths; keep the template's. */
@@ -145,10 +150,11 @@ function resealSignedWindowsApp(appDir, signedListFile) {
return
}
resealOrcadTemplateManifest(join(resourcesDir, ORCAD_TEMPLATE_RESOURCE_DIR), signed)
resealSignedPackagedRipgrep(
resourcesDir,
signedAppPaths.filter((path) => path.startsWith('resources/')).map((path) => path.slice(10))
)
const signedResources = signedAppPaths
.filter((path) => path.startsWith('resources/'))
.map((path) => path.slice(10))
resealSignedPackagedRipgrep(resourcesDir, signedResources)
resealSignedPackagedBrowser(resourcesDir, signedResources)
verifyPackagedOrcadTemplate(resourcesDir)
console.log(`[packaged-orcad-template] resealed ${signed.length} signed template file(s)`)
}
@@ -115,6 +115,10 @@ function verifyTarget(resourcesDir, templateDir, target, value, schemaVersion) {
`[verify-packaged-orcad-template] ${target} browserName and browserSha256 must both be present`
)
}
const packagedBrowser = Object.hasOwn(targetManifest, 'packagedBrowser')
if (packagedBrowser && (targetManifest.packagedBrowser !== true || !hasBrowserName)) {
throw new Error(`[verify-packaged-orcad-template] ${target} packagedBrowser is invalid`)
}
const targetDir = join(templateDir, ORCAD_TEMPLATE_TARGETS_DIR, target)
for (const filename of expectedFiles) {
verifyFile(
@@ -141,11 +145,13 @@ function verifyTarget(resourcesDir, templateDir, target, value, schemaVersion) {
throw new Error(`[verify-packaged-orcad-template] ${target} browserName is invalid`)
}
verifyFile(
join(targetDir, browserName),
join(packagedBrowser ? resourcesDir : targetDir, browserName),
requireSha256(targetManifest.browserSha256, `${target} browserSha256`),
`${target} browser`
)
inventory.push(browserName)
if (!packagedBrowser) {
inventory.push(browserName)
}
}
requireExactNames(listFiles(targetDir), inventory, `${target} file inventory`)
}
-82
View File
@@ -1,82 +0,0 @@
/** Per-release copies under a profile root; live PID claims keep a copy from cleanup while launches use it. */
import { mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
const COMPLETE_MARKER = '.complete'
const USERS_DIR = '.users'
const TEMPORARY_PREFIX = '.tmp-'
async function exists(path: string): Promise<boolean> {
return stat(path).then(
() => true,
() => false
)
}
/** Keep PID claims outside snapshots so they cannot block publication. */
export const snapshotUsersDir = (root: string, id: string): string => join(root, USERS_DIR, id)
/** Claims `root/id` for `pid`, publishing it from `populate` unless another launch already did. */
export async function publishClaimedSnapshot(
root: string,
id: string,
pid: number,
populate: (directory: string) => Promise<void>
): Promise<string> {
// Claim before checking so concurrent cleanup preserves this snapshot.
await mkdir(snapshotUsersDir(root, id), { recursive: true })
await writeFile(join(snapshotUsersDir(root, id), String(pid)), '')
const snapshotDir = join(root, id)
if (await exists(join(snapshotDir, COMPLETE_MARKER))) {
return snapshotDir
}
const temporary = join(root, `${TEMPORARY_PREFIX}${pid}-${id}`)
await rm(temporary, { recursive: true, force: true })
try {
await mkdir(temporary)
await populate(temporary)
await writeFile(join(temporary, COMPLETE_MARKER), '')
} catch (error) {
// Failed copies must not retain disk space for the service’s lifetime.
await rm(temporary, { recursive: true, force: true }).catch(() => undefined)
throw error
}
try {
await rename(temporary, snapshotDir)
} catch (error) {
// Another launch for this profile finished the same release first.
await rm(temporary, { recursive: true, force: true })
if (!(await exists(join(snapshotDir, COMPLETE_MARKER)))) {
throw error
}
}
return snapshotDir
}
function isLivePidName(name: string, isProcessAlive: (pid: number) => boolean): boolean {
const pid = Number(name)
return Number.isSafeInteger(pid) && pid > 0 && isProcessAlive(pid)
}
export async function removeUnusedSnapshots(
root: string,
keep: string,
isProcessAlive: (pid: number) => boolean
): Promise<void> {
for (const name of await readdir(root).catch(() => [])) {
if (name === keep || name === USERS_DIR) {
continue
}
const inUse = name.startsWith(TEMPORARY_PREFIX)
? isLivePidName(name.slice(TEMPORARY_PREFIX.length).split('-')[0], isProcessAlive)
: (await readdir(snapshotUsersDir(root, name)).catch(() => [])).some((pid) =>
isLivePidName(pid, isProcessAlive)
)
if (!inUse) {
await rm(join(root, name), { recursive: true, force: true }).catch(() => undefined)
await rm(snapshotUsersDir(root, name), { recursive: true, force: true }).catch(
() => undefined
)
}
}
}
+1 -10
View File
@@ -28,7 +28,6 @@ vi.mock('./serve-orcad-launch', async (importOriginal) => ({
import { serveOrcaApp } from './launch'
import { speechRecognizerPackageName } from '../../shared/speech-recognizer-package'
import { orcadAgentBrowserNativeName } from '../../shared/orcad-agent-browser-name'
class FakeChildProcess extends EventEmitter {
stdout = new EventEmitter()
@@ -104,11 +103,6 @@ describe('orca serve host selection', () => {
const recognizer = speechRecognizerPackageName(process.platform, process.arch)
await mkdir(join(appRoot, 'node_modules', recognizer), { recursive: true })
await writeFile(join(appRoot, 'node_modules', recognizer, 'sherpa-onnx.node'), '')
const driverDir = join(appRoot, 'node_modules', 'agent-browser', 'bin')
const driver = join(driverDir, orcadAgentBrowserNativeName(process.platform, process.arch))
await mkdir(driverDir, { recursive: true })
await writeFile(driver, '')
process.env.ORCA_USER_DATA_PATH = join(appRoot, 'profile')
process.env.ORCA_SERVE_SUPERVISOR_APP_ROOT = appRoot
const selection = { kind: 'orcad', runtime: '/node', entry: '/slot/orcad.js', version: '1' }
resolveLocalServeRuntimeMock.mockResolvedValue(selection)
@@ -121,10 +115,7 @@ describe('orca serve host selection', () => {
expect.any(String),
expect.not.objectContaining({ ELECTRON_RUN_AS_NODE: expect.anything() }),
join(appRoot, 'out', 'web'),
process.platform === 'win32' ? undefined : join(appRoot, 'node_modules', recognizer),
process.platform === 'win32'
? expect.stringContaining(join(appRoot, 'profile', 'orcad-agent-browser'))
: driver
process.platform === 'win32' ? undefined : join(appRoot, 'node_modules', recognizer)
)
expect(spawnMock).not.toHaveBeenCalled()
expect(stderr.join('')).toContain('[serve] running on orcad 1')
+1 -3
View File
@@ -31,7 +31,6 @@ import { SERVE_RUNTIME_ELECTRON, SERVE_RUNTIME_ENV } from '../../shared/orcad-lo
import { findBundledWebClientRoot } from '../../shared/bundled-web-client-root'
import { resolveLocalServeRuntime, serveWithOrcad } from './serve-orcad-launch'
import { findAppSpeechRecognizerDir } from './serve-speech-recognizer'
import { stageAppAgentBrowserDriver } from './serve-agent-browser-driver'
import { serveOptionArgs, type ServeOrcaAppArgs } from '../../shared/orcad-serve-argv'
import { waitForRecipeJson } from './serve-recipe-json'
import {
@@ -193,8 +192,7 @@ async function serveWithSelectedRuntime(
userDataPath,
stripElectronRunAsNode(process.env),
findBundledWebClientRoot(resolveAppRoot()),
findAppSpeechRecognizerDir(resolveAppRoot()),
await stageAppAgentBrowserDriver(resolveAppRoot(), userDataPath)
findAppSpeechRecognizerDir(resolveAppRoot())
)
}
if (selection.reason) {
@@ -1,45 +0,0 @@
import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { stageAppAgentBrowserDriver } from './serve-agent-browser-driver'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
describe("finding the app's agent-browser driver (#33846)", () => {
// A running daemon locks its image on Windows; lending the installed copy would block app updates.
it('hands Windows orcad a profile copy outside the app install', async () => {
const resources = mkdtempSync(join(tmpdir(), 'orca-serve-agent-browser-'))
const profile = mkdtempSync(join(tmpdir(), 'orca-serve-agent-browser-profile-'))
roots.push(resources, profile)
mkdirSync(join(resources, 'app.asar.unpacked'))
writeFileSync(join(resources, 'agent-browser-win32-x64.exe'), 'driver')
mkdirSync(join(profile, 'orcad-agent-browser', 'previous-release'), { recursive: true })
// Another live launch still uses its release's copy.
mkdirSync(join(profile, 'orcad-agent-browser', 'live-release'))
mkdirSync(join(profile, 'orcad-agent-browser', '.users', 'live-release'), { recursive: true })
writeFileSync(
join(profile, 'orcad-agent-browser', '.users', 'live-release', String(process.pid)),
''
)
const staged = await stageAppAgentBrowserDriver(
join(resources, 'app.asar.unpacked'),
profile,
'win32',
'x64'
)
expect(staged?.startsWith(join(profile, 'orcad-agent-browser'))).toBe(true)
expect(staged?.endsWith('agent-browser-win32-x64.exe')).toBe(true)
expect(readdirSync(join(profile, 'orcad-agent-browser'))).toEqual(
expect.arrayContaining(['live-release'])
)
expect(readdirSync(join(profile, 'orcad-agent-browser'))).not.toContain('previous-release')
})
})
@@ -1,47 +0,0 @@
import { existsSync } from 'node:fs'
import { copyFile, mkdir, stat } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { orcadAgentBrowserNativeName } from '../../shared/orcad-agent-browser-name'
import { isProcessAlive } from '../../shared/serve-bundle-coordination'
import { publishClaimedSnapshot, removeUnusedSnapshots } from './claimed-snapshot-store'
/** The installed app's agent-browser driver, for orcad's Chromium provider (#33846). Packaged apps ship it in `resources`; a dev checkout keeps it in node_modules. */
export function findAppAgentBrowserDriver(
appRoot: string,
platform: NodeJS.Platform = process.platform,
arch: string = process.arch
): string | undefined {
const name = orcadAgentBrowserNativeName(platform, arch)
return [
join(dirname(appRoot), name),
join(appRoot, 'node_modules', 'agent-browser', 'bin', name)
].find((path) => existsSync(path))
}
/** Windows locks a running image, so orcad's long-lived daemon runs a profile copy that app updates never replace. */
export async function stageAppAgentBrowserDriver(
appRoot: string,
userDataPath: string,
platform: NodeJS.Platform = process.platform,
arch: string = process.arch
): Promise<string | undefined> {
const driver = findAppAgentBrowserDriver(appRoot, platform, arch)
if (!driver || platform !== 'win32') {
return driver
}
const root = join(userDataPath, 'orcad-agent-browser')
try {
const { size, mtimeMs } = await stat(driver)
const id = `${size}-${Math.trunc(mtimeMs)}`
await mkdir(root, { recursive: true })
const snapshot = await publishClaimedSnapshot(root, id, process.pid, (directory) =>
copyFile(driver, join(directory, basename(driver)))
)
// A copy still running for another serve stays locked and survives until a later pass.
await removeUnusedSnapshots(root, id, isProcessAlive)
return join(snapshot, basename(driver))
} catch {
// Without a copy, Chromium stays unavailable rather than locking the installed image.
return undefined
}
}
+2 -6
View File
@@ -14,7 +14,6 @@ import { isSupervisedSpawnHeld } from './serve-supervised-stop'
import { redactingSpawn, shouldRedactSupervisedStdout } from './serve-pairing-redaction'
import { orcadServeArgs, type ServeOrcaAppArgs } from '../../shared/orcad-serve-argv'
import { SPEECH_RECOGNIZER_DIR_ENV } from '../../shared/speech-recognizer-package'
import { ORCAD_AGENT_BROWSER_DRIVER_ENV } from '../../shared/orcad-agent-browser-name'
/** A first run may download and verify the pinned Node; bound it well past that. */
const SELECTION_TIMEOUT_MS = 10 * 60_000
@@ -70,9 +69,7 @@ export function serveWithOrcad(
/** The app's browser client; the slot does not carry one, so orcad serves it from here. */
webClientRoot?: string,
/** orcad’s slot has no speech recognizer; use the app’s copy. */
speechRecognizerDir?: string,
/** orcad's slot has no agent-browser driver; use the app's copy. */
agentBrowserDriver?: string
speechRecognizerDir?: string
): Promise<number> {
const spec: ProcessSpec = {
program: selection.runtime,
@@ -86,8 +83,7 @@ export function serveWithOrcad(
ORCA_USER_DATA: userDataPath,
ORCA_VERSION: selection.version,
// Always set: a value inherited from an Orca terminal would name another install's copy.
[SPEECH_RECOGNIZER_DIR_ENV]: speechRecognizerDir ?? '',
[ORCAD_AGENT_BROWSER_DRIVER_ENV]: agentBrowserDriver ?? ''
[SPEECH_RECOGNIZER_DIR_ENV]: speechRecognizerDir ?? ''
}
}
const spawnChild = shouldRedactSupervisedStdout(args.supervise === true)
+67 -13
View File
@@ -1,20 +1,18 @@
/** Copy supervisors outside the app so updates cannot mix lazily loaded modules from two releases. */
import { createHash } from 'node:crypto'
import { constants, readFileSync, rmSync, statSync } from 'node:fs'
import { cp, mkdir, stat } from 'node:fs/promises'
import { cp, mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
import { cloneMacTree } from '../../main/macos-tree-clone'
import { isProcessAlive } from '../../shared/serve-bundle-coordination'
import {
publishClaimedSnapshot,
removeUnusedSnapshots,
snapshotUsersDir
} from './claimed-snapshot-store'
/** Relocated supervisors can no longer derive the app root from __dirname. */
export const SERVE_SUPERVISOR_APP_ROOT_ENV = 'ORCA_SERVE_SUPERVISOR_APP_ROOT'
const PACKAGED_APP_ROOT_NAME = 'app.asar.unpacked'
const COMPLETE_MARKER = '.complete'
const USERS_DIR = '.users'
const TEMPORARY_PREFIX = '.tmp-'
export type SupervisorRelocationDeps = {
env: NodeJS.ProcessEnv
@@ -57,6 +55,9 @@ async function exists(path: string): Promise<boolean> {
)
}
/** Keep PID claims outside snapshots so they cannot block publication. */
const usersDir = (root: string, id: string): string => join(root, USERS_DIR, id)
async function copyTree(source: string, destination: string): Promise<void> {
// macOS cp -c clones a tree faster than fs.cp’s per-file cloning.
if (process.platform === 'darwin') {
@@ -70,15 +71,70 @@ async function copyTree(source: string, destination: string): Promise<void> {
})
}
function buildSnapshot(resources: string, root: string, id: string, pid: number): Promise<string> {
return publishClaimedSnapshot(root, id, pid, async (directory) => {
async function buildSnapshot(
resources: string,
root: string,
id: string,
pid: number
): Promise<void> {
// Claim before checking so concurrent cleanup preserves this snapshot.
await mkdir(usersDir(root, id), { recursive: true })
await writeFile(join(usersDir(root, id), String(pid)), '')
const snapshotDir = join(root, id)
if (await exists(join(snapshotDir, COMPLETE_MARKER))) {
return
}
const temporary = join(root, `${TEMPORARY_PREFIX}${pid}-${id}`)
await rm(temporary, { recursive: true, force: true })
try {
await mkdir(temporary)
// Bare requires from out/ resolve through Resources/node_modules.
for (const name of [PACKAGED_APP_ROOT_NAME, 'node_modules']) {
if (await exists(join(resources, name))) {
await copyTree(join(resources, name), join(directory, name))
await copyTree(join(resources, name), join(temporary, name))
}
}
})
await writeFile(join(temporary, COMPLETE_MARKER), '')
} catch (error) {
// Failed copies must not retain disk space for the service’s lifetime.
await rm(temporary, { recursive: true, force: true }).catch(() => undefined)
throw error
}
try {
await rename(temporary, snapshotDir)
} catch (error) {
// Another supervisor of this profile finished the same release first.
await rm(temporary, { recursive: true, force: true })
if (!(await exists(join(snapshotDir, COMPLETE_MARKER)))) {
throw error
}
}
}
function isLivePidName(name: string, isProcessAlive: (pid: number) => boolean): boolean {
const pid = Number(name)
return Number.isSafeInteger(pid) && pid > 0 && isProcessAlive(pid)
}
async function removeUnusedSnapshots(
root: string,
keep: string,
isProcessAlive: (pid: number) => boolean
): Promise<void> {
for (const name of await readdir(root).catch(() => [])) {
if (name === keep || name === USERS_DIR) {
continue
}
const inUse = name.startsWith(TEMPORARY_PREFIX)
? isLivePidName(name.slice(TEMPORARY_PREFIX.length).split('-')[0], isProcessAlive)
: (await readdir(usersDir(root, name)).catch(() => [])).some((pid) =>
isLivePidName(pid, isProcessAlive)
)
if (!inUse) {
await rm(join(root, name), { recursive: true, force: true }).catch(() => undefined)
await rm(usersDir(root, name), { recursive: true, force: true }).catch(() => undefined)
}
}
}
/** AppImages keep stable files; re-exec would close their keepalive pipe and release the mount. */
@@ -138,9 +194,7 @@ export async function relocateServeSupervisor(
/** Releases `pid`'s claim on the copy it runs `cliEntry` from, so cleanup can remove that copy. */
export function releaseServeSupervisorSnapshot(cliEntry: string, pid: number): void {
const snapshotDir = resolve(cliEntry, '..', '..', '..', '..')
rmSync(join(snapshotUsersDir(dirname(snapshotDir), basename(snapshotDir)), String(pid)), {
force: true
})
rmSync(join(usersDir(dirname(snapshotDir), basename(snapshotDir)), String(pid)), { force: true })
}
export function execveThisProcess(file: string, args: string[], env: NodeJS.ProcessEnv): void {
@@ -1,52 +0,0 @@
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { arch, platform, tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ORCAD_AGENT_BROWSER_DRIVER_ENV } from '../../shared/orcad-agent-browser-name'
import {
orcadAgentBrowserNativeName,
resolveOrcadAgentBrowserBinary
} from './orcad-agent-browser-binary'
const roots: string[] = []
afterEach(() => {
vi.restoreAllMocks()
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
function tempDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'orcad-agent-browser-'))
roots.push(dir)
return dir
}
function writeDriver(dir: string): string {
mkdirSync(dir, { recursive: true })
const path = join(dir, orcadAgentBrowserNativeName(platform(), arch()))
writeFileSync(path, '')
chmodSync(path, 0o755)
return path
}
describe('resolving the agent-browser driver for orcad (#33846)', () => {
it("uses the app's bundled driver when the materialized slot has none", () => {
vi.spyOn(process, 'cwd').mockReturnValue(tempDir())
const appDriver = writeDriver(join(tempDir(), 'resources'))
expect(resolveOrcadAgentBrowserBinary({})).toBeNull()
expect(resolveOrcadAgentBrowserBinary({ [ORCAD_AGENT_BROWSER_DRIVER_ENV]: appDriver })).toBe(
appDriver
)
})
it("prefers a standalone artifact's own driver over the app's", () => {
const slot = tempDir()
vi.spyOn(process, 'cwd').mockReturnValue(slot)
const slotDriver = writeDriver(join(slot, 'node_modules', 'agent-browser', 'bin'))
const appDriver = writeDriver(join(tempDir(), 'resources'))
expect(resolveOrcadAgentBrowserBinary({ [ORCAD_AGENT_BROWSER_DRIVER_ENV]: appDriver })).toBe(
slotDriver
)
})
})
+12 -16
View File
@@ -1,26 +1,22 @@
import { accessSync, constants, existsSync } from 'node:fs'
import { arch, platform } from 'node:os'
import { dirname, join } from 'node:path'
import { ORCAD_AGENT_BROWSER_DRIVER_ENV } from '../../shared/orcad-agent-browser-name'
import { orcadAgentBrowserNativeName } from '../../shared/orcad-agent-browser-name'
export function orcadAgentBrowserNativeName(
platformName: NodeJS.Platform,
architecture: string
): string {
const ext = platformName === 'win32' ? '.exe' : ''
return `agent-browser-${platformName}-${architecture}${ext}`
/** A slot carries only its own target's driver, so trying both libc names needs no libc probe. */
function hostDriverNames(): string[] {
const names = [orcadAgentBrowserNativeName(platform(), arch())]
if (platform() === 'linux') {
names.push(orcadAgentBrowserNativeName('linux', arch(), 'musl'))
}
return names
}
export function resolveOrcadAgentBrowserBinary(
environment: NodeJS.ProcessEnv = process.env
): string | null {
const name = orcadAgentBrowserNativeName(platform(), arch())
const appDriver = environment[ORCAD_AGENT_BROWSER_DRIVER_ENV]
const candidates = [
export function resolveOrcadAgentBrowserBinary(): string | null {
const candidates = hostDriverNames().flatMap((name) => [
join(dirname(process.argv[1] ?? __filename), name),
join(process.cwd(), 'node_modules', 'agent-browser', 'bin', name),
...(appDriver ? [appDriver] : [])
]
join(process.cwd(), 'node_modules', 'agent-browser', 'bin', name)
])
for (const candidate of candidates) {
if (!existsSync(candidate)) {
continue
@@ -354,6 +354,22 @@ describe('assembleOrcadArtifact', () => {
expect(readFileSync(join(first, 'orcad.js'), 'utf8')).toBe('corrupted-cache-entry')
})
// Desktop templates omit drivers; the app's resources copy must still reach the slot (#33846).
it("copies the app's own driver into the slot when the template references it", async () => {
const fixture = createTemplate()
renameSync(
join(fixture.templateDir, ORCAD_TEMPLATE_TARGETS_DIR, TARGET, 'agent-browser-linux-x64'),
join(dirname(fixture.templateDir), 'agent-browser-linux-x64')
)
rewriteManifest(fixture.templateDir, (manifest) => {
Object.assign(manifest.targets[TARGET]!, { packagedBrowser: true })
})
const artifactDir = await assembleOrcadArtifact({ ...fixture, target: TARGET })
expect(readFileSync(join(artifactDir, 'agent-browser-linux-x64'), 'utf8')).toBe('browser')
})
it('rejects an optional browser without a matching manifest checksum', async () => {
const fixture = createTemplate()
rewriteManifest(fixture.templateDir, (manifest) => {
@@ -195,7 +195,10 @@ function artifactSources(
...required,
{
filename: targetManifest.browserName,
path: join(targetDir, targetManifest.browserName),
path: join(
targetManifest.packagedBrowser ? dirname(templateDir) : targetDir,
targetManifest.browserName
),
executable: true
}
]
@@ -10,7 +10,7 @@ import {
resolveInstalledElectronExecutable,
resolveOrcadBrowserProvider
} from './orcad-browser-provider'
import { orcadAgentBrowserNativeName } from './orcad-agent-browser-binary'
import { orcadAgentBrowserNativeName } from '../../shared/orcad-agent-browser-name'
import {
runtimeBrowserUnavailableCause,
setRuntimeBrowserUnavailableCause
+1 -3
View File
@@ -219,9 +219,7 @@ export async function resolveOrcadBrowserProvider(
return declined(electronFailure ?? { reason: 'unconfigured' })
}
const agentBrowserPath = options.resolveAgentBrowserBinary
? options.resolveAgentBrowserBinary()
: resolveOrcadAgentBrowserBinary(environment)
const agentBrowserPath = (options.resolveAgentBrowserBinary ?? resolveOrcadAgentBrowserBinary)()
if (!agentBrowserPath) {
return declined({ reason: 'driver_missing' })
}
+6 -1
View File
@@ -9,11 +9,16 @@ const TemplateTargetSchema = z
.string()
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u)
.optional(),
browserSha256: Sha256Schema.optional()
browserSha256: Sha256Schema.optional(),
/** The driver is the app's own `resources/<browserName>`, not a template copy. */
packagedBrowser: z.literal(true).optional()
})
.refine((target) => Boolean(target.browserName) === Boolean(target.browserSha256), {
message: 'browserName and browserSha256 must either both be present or both be absent'
})
.refine((target) => !target.packagedBrowser || Boolean(target.browserName), {
message: 'packagedBrowser requires browserName'
})
export const OrcadTemplateManifestSchema = z.object({
schemaVersion: z.union([z.literal(3), z.literal(4)]),
commonSha256: z.record(z.string(), Sha256Schema),
-3
View File
@@ -8,6 +8,3 @@ export function orcadAgentBrowserNativeName(
platformName === 'linux' && linuxLibc === 'musl' ? 'linux-musl' : platformName
return `agent-browser-${platformToken}-${architecture}${ext}`
}
/** Set by `orca serve` for orcad: the app's bundled driver, since desktop orcad slots omit it. */
export const ORCAD_AGENT_BROWSER_DRIVER_ENV = 'ORCA_AGENT_BROWSER_DRIVER'