From 0ed5f4bb21ff58022eb2de96703900566ed8f4dc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:28:20 -0700 Subject: [PATCH] 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- 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 --- config/electron-builder.config.cjs | 23 ++++-- config/scripts/build-orcad-template.mjs | 5 +- config/scripts/packaged-orcad-browser.cjs | 56 +++++++++++++ .../scripts/packaged-orcad-browser.test.mjs | 69 ++++++++++++++++ config/scripts/packaged-orcad-template.cjs | 14 +++- .../verify-packaged-orcad-template.cjs | 10 ++- src/cli/runtime/claimed-snapshot-store.ts | 82 ------------------- src/cli/runtime/launch-serve-runtime.test.ts | 11 +-- src/cli/runtime/launch.ts | 4 +- .../serve-agent-browser-driver.test.ts | 45 ---------- src/cli/runtime/serve-agent-browser-driver.ts | 47 ----------- src/cli/runtime/serve-orcad-launch.ts | 8 +- .../runtime/serve-supervisor-relocation.ts | 80 +++++++++++++++--- .../orcad/orcad-agent-browser-binary.test.ts | 52 ------------ src/main/orcad/orcad-agent-browser-binary.ts | 28 +++---- .../orcad/orcad-artifact-materializer.test.ts | 16 ++++ src/main/orcad/orcad-artifact-materializer.ts | 5 +- src/main/orcad/orcad-browser-provider.test.ts | 2 +- src/main/orcad/orcad-browser-provider.ts | 4 +- src/main/orcad/orcad-template-manifest.ts | 7 +- src/shared/orcad-agent-browser-name.ts | 3 - 21 files changed, 274 insertions(+), 297 deletions(-) create mode 100644 config/scripts/packaged-orcad-browser.cjs create mode 100644 config/scripts/packaged-orcad-browser.test.mjs delete mode 100644 src/cli/runtime/claimed-snapshot-store.ts delete mode 100644 src/cli/runtime/serve-agent-browser-driver.test.ts delete mode 100644 src/cli/runtime/serve-agent-browser-driver.ts delete mode 100644 src/main/orcad/orcad-agent-browser-binary.test.ts diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 74f56cb4b01..23507b1a5c7 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -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, diff --git a/config/scripts/build-orcad-template.mjs b/config/scripts/build-orcad-template.mjs index aad87def12f..7dd1d610c84 100644 --- a/config/scripts/build-orcad-template.mjs +++ b/config/scripts/build-orcad-template.mjs @@ -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 }) diff --git a/config/scripts/packaged-orcad-browser.cjs b/config/scripts/packaged-orcad-browser.cjs new file mode 100644 index 00000000000..21612d7a6a1 --- /dev/null +++ b/config/scripts/packaged-orcad-browser.cjs @@ -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 } diff --git a/config/scripts/packaged-orcad-browser.test.mjs b/config/scripts/packaged-orcad-browser.test.mjs new file mode 100644 index 00000000000..dd44c30c99a --- /dev/null +++ b/config/scripts/packaged-orcad-browser.test.mjs @@ -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' + ) + }) +}) diff --git a/config/scripts/packaged-orcad-template.cjs b/config/scripts/packaged-orcad-template.cjs index d49e5658a92..ed447827606 100644 --- a/config/scripts/packaged-orcad-template.cjs +++ b/config/scripts/packaged-orcad-template.cjs @@ -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)`) } diff --git a/config/scripts/verify-packaged-orcad-template.cjs b/config/scripts/verify-packaged-orcad-template.cjs index 7930dabd7c4..6dfd47b05ea 100644 --- a/config/scripts/verify-packaged-orcad-template.cjs +++ b/config/scripts/verify-packaged-orcad-template.cjs @@ -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`) } diff --git a/src/cli/runtime/claimed-snapshot-store.ts b/src/cli/runtime/claimed-snapshot-store.ts deleted file mode 100644 index ceadf7b031a..00000000000 --- a/src/cli/runtime/claimed-snapshot-store.ts +++ /dev/null @@ -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 { - 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 -): Promise { - // 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 { - 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 - ) - } - } -} diff --git a/src/cli/runtime/launch-serve-runtime.test.ts b/src/cli/runtime/launch-serve-runtime.test.ts index 3b7433013e9..1d9e882f2f1 100644 --- a/src/cli/runtime/launch-serve-runtime.test.ts +++ b/src/cli/runtime/launch-serve-runtime.test.ts @@ -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') diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts index d102b255ffc..526c689e764 100644 --- a/src/cli/runtime/launch.ts +++ b/src/cli/runtime/launch.ts @@ -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) { diff --git a/src/cli/runtime/serve-agent-browser-driver.test.ts b/src/cli/runtime/serve-agent-browser-driver.test.ts deleted file mode 100644 index dc9b5970f60..00000000000 --- a/src/cli/runtime/serve-agent-browser-driver.test.ts +++ /dev/null @@ -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') - }) -}) diff --git a/src/cli/runtime/serve-agent-browser-driver.ts b/src/cli/runtime/serve-agent-browser-driver.ts deleted file mode 100644 index b69e7b5c1a5..00000000000 --- a/src/cli/runtime/serve-agent-browser-driver.ts +++ /dev/null @@ -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 { - 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 - } -} diff --git a/src/cli/runtime/serve-orcad-launch.ts b/src/cli/runtime/serve-orcad-launch.ts index 2b149a5dc7c..3d63f6fb3e6 100644 --- a/src/cli/runtime/serve-orcad-launch.ts +++ b/src/cli/runtime/serve-orcad-launch.ts @@ -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 { 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) diff --git a/src/cli/runtime/serve-supervisor-relocation.ts b/src/cli/runtime/serve-supervisor-relocation.ts index 2acdefbbe62..995171c06a8 100644 --- a/src/cli/runtime/serve-supervisor-relocation.ts +++ b/src/cli/runtime/serve-supervisor-relocation.ts @@ -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 { ) } +/** 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 { // 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 { }) } -function buildSnapshot(resources: string, root: string, id: string, pid: number): Promise { - return publishClaimedSnapshot(root, id, pid, async (directory) => { +async function buildSnapshot( + resources: string, + root: string, + id: string, + pid: number +): Promise { + // 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 { + 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 { diff --git a/src/main/orcad/orcad-agent-browser-binary.test.ts b/src/main/orcad/orcad-agent-browser-binary.test.ts deleted file mode 100644 index f18daff58a7..00000000000 --- a/src/main/orcad/orcad-agent-browser-binary.test.ts +++ /dev/null @@ -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 - ) - }) -}) diff --git a/src/main/orcad/orcad-agent-browser-binary.ts b/src/main/orcad/orcad-agent-browser-binary.ts index 0762a19ed1a..932578c4596 100644 --- a/src/main/orcad/orcad-agent-browser-binary.ts +++ b/src/main/orcad/orcad-agent-browser-binary.ts @@ -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 diff --git a/src/main/orcad/orcad-artifact-materializer.test.ts b/src/main/orcad/orcad-artifact-materializer.test.ts index f2a2aaa732a..53d3b18273c 100644 --- a/src/main/orcad/orcad-artifact-materializer.test.ts +++ b/src/main/orcad/orcad-artifact-materializer.test.ts @@ -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) => { diff --git a/src/main/orcad/orcad-artifact-materializer.ts b/src/main/orcad/orcad-artifact-materializer.ts index 82e2a723599..284e3951f34 100644 --- a/src/main/orcad/orcad-artifact-materializer.ts +++ b/src/main/orcad/orcad-artifact-materializer.ts @@ -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 } ] diff --git a/src/main/orcad/orcad-browser-provider.test.ts b/src/main/orcad/orcad-browser-provider.test.ts index 5d7563680f6..9dc6f4b08ea 100644 --- a/src/main/orcad/orcad-browser-provider.test.ts +++ b/src/main/orcad/orcad-browser-provider.test.ts @@ -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 diff --git a/src/main/orcad/orcad-browser-provider.ts b/src/main/orcad/orcad-browser-provider.ts index fc4fc6ded93..3e9217ff05d 100644 --- a/src/main/orcad/orcad-browser-provider.ts +++ b/src/main/orcad/orcad-browser-provider.ts @@ -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' }) } diff --git a/src/main/orcad/orcad-template-manifest.ts b/src/main/orcad/orcad-template-manifest.ts index 3eb0662db55..c465dc67404 100644 --- a/src/main/orcad/orcad-template-manifest.ts +++ b/src/main/orcad/orcad-template-manifest.ts @@ -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/`, 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), diff --git a/src/shared/orcad-agent-browser-name.ts b/src/shared/orcad-agent-browser-name.ts index 98d67df2457..5cd22beaa50 100644 --- a/src/shared/orcad-agent-browser-name.ts +++ b/src/shared/orcad-agent-browser-name.ts @@ -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'