mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user