diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 96df2d6a1..9c01fc355 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -125,6 +125,7 @@ module.exports = { } prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName) verifyPackagedMainRuntimeDeps(resourcesDir) + chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName) for (const filename of readdirSync(resourcesDir)) { if (!filename.startsWith('agent-browser-')) { continue @@ -286,6 +287,21 @@ module.exports = { } } +function chmodUnixCliLaunchers(resourcesDir, electronPlatformName) { + if (electronPlatformName === 'win32') { + return + } + for (const launcherName of ['orca', 'orca-ide']) { + const launcherPath = join(resourcesDir, 'bin', launcherName) + if (!existsSync(launcherPath)) { + continue + } + // Why: packaged Unix installs expose these extraResources as public shell + // commands, and source/packager mode drift must not ship a non-executable CLI. + chmodSync(launcherPath, 0o755) + } +} + async function signMacComputerUseHelper(helperAppPath, packager) { if (!existsSync(helperAppPath)) { if (isMacRelease) { diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 9c6092ebb..a1d75c30f 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readdir, rm, stat, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -89,10 +89,7 @@ describe('electron-builder config', () => { const sources = new Map([ ['out\\main\\index.js', 'const z = require("zod")'], - [ - 'out\\main\\agent-hooks\\managed-agent-hook-controls.js', - 'const YAML = require("yaml")' - ] + ['out\\main\\agent-hooks\\managed-agent-hook-controls.js', 'const YAML = require("yaml")'] ]) const asar = { listPackage: () => [...sources.keys()].map((entry) => `\\${entry}`), @@ -136,9 +133,9 @@ describe('electron-builder config', () => { await expect( readdir(join(resourcesDir, 'node_modules', 'node-pty', 'third_party')) ).resolves.toEqual([]) - await expect(readdir(join(resourcesDir, 'node_modules', 'node-pty', 'deps'))).resolves.toEqual( - [] - ) + await expect( + readdir(join(resourcesDir, 'node_modules', 'node-pty', 'deps')) + ).resolves.toEqual([]) } finally { await rm(resourcesDir, { recursive: true, force: true }) } @@ -197,4 +194,27 @@ describe('electron-builder config', () => { await rm(resourcesDir, { recursive: true, force: true }) } }) + + it.skipIf(process.platform === 'win32')( + 'marks packaged Unix CLI launchers executable', + async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-')) + try { + const resourcesDir = join(root, 'linux-unpacked', 'resources') + const launcherPath = join(resourcesDir, 'bin', 'orca-ide') + await mkdir(join(resourcesDir, 'bin'), { recursive: true }) + await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 }) + + await electronBuilderConfig.afterPack({ + appOutDir: join(root, 'linux-unpacked'), + electronPlatformName: 'linux' + }) + + expect((await stat(launcherPath)).mode & 0o111).not.toBe(0) + } finally { + await rm(root, { recursive: true, force: true }) + } + } + ) }) diff --git a/src/main/cli/packaged-cli-assets.test.ts b/src/main/cli/packaged-cli-assets.test.ts index 63f5f1b78..4aa8b034d 100644 --- a/src/main/cli/packaged-cli-assets.test.ts +++ b/src/main/cli/packaged-cli-assets.test.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process' -import { copyFile, chmod, mkdir, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises' +import { copyFile, mkdir, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -62,7 +62,7 @@ describe('packaged CLI assets', () => { await mkdir(launcherDir, { recursive: true }) await mkdir(cliDir, { recursive: true }) await copyFile(linuxLauncherAsset, launcherPath) - await chmod(launcherPath, 0o755) + expect((await stat(launcherPath)).mode & 0o111).not.toBe(0) await writeFile(cliPath, '', 'utf8') await writeFile( electronPath, diff --git a/src/main/index.ts b/src/main/index.ts index 3c887db6c..e1dc5dfd7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -52,6 +52,7 @@ import { patchPackagedProcessPath, shouldInstallManagedHooks } from './startup/configure-process' +import { maybeRedirectAppImageCliLaunch } from './startup/appimage-cli-redirect' import { startFirstWindowStartupServices } from './startup/first-window-startup-services' import { getDevInstanceIdentity } from './startup/dev-instance-identity' import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path' @@ -138,6 +139,14 @@ let automations: AutomationService | null = null let keybindings: KeybindingService | null = null let expectedRendererReload: { webContentsId: number; until: number } | null = null const isServeMode = process.argv.includes('--serve') +const appImageCliRedirect = maybeRedirectAppImageCliLaunch({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + execPath: process.execPath +}) +if (appImageCliRedirect.redirected) { + app.exit(appImageCliRedirect.status) +} // Why: the store/runtime singletons live here in index.ts; injecting them keeps // the rename orchestrator free of module-level state and unit-testable. diff --git a/src/main/startup/appimage-cli-redirect.test.ts b/src/main/startup/appimage-cli-redirect.test.ts new file mode 100644 index 000000000..822719dd8 --- /dev/null +++ b/src/main/startup/appimage-cli-redirect.test.ts @@ -0,0 +1,92 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { getAppImageCliArgs, maybeRedirectAppImageCliLaunch } from './appimage-cli-redirect' + +const commandNames = ['status', 'terminal'] + +describe('AppImage CLI redirect', () => { + it('detects direct AppImage CLI commands', () => { + expect( + getAppImageCliArgs( + ['orca-linux.AppImage', 'status', '--json'], + { APPIMAGE: '/opt/orca' }, + { + platform: 'linux', + isPackaged: true, + commandNames + } + ) + ).toEqual(['status', '--json']) + }) + + it('allows CLI global flags before the command', () => { + expect( + getAppImageCliArgs( + ['orca-linux.AppImage', '--pairing-code', 'abc123', '--json', 'terminal', 'list'], + { + APPIMAGE: '/opt/orca' + }, + { + platform: 'linux', + isPackaged: true, + commandNames + } + ) + ).toEqual(['--pairing-code', 'abc123', '--json', 'terminal', 'list']) + }) + + it('does not redirect normal desktop AppImage launches', () => { + expect( + getAppImageCliArgs( + ['AppRun', '--no-sandbox', 'file:///tmp/example.txt'], + { + APPIMAGE: '/opt/orca' + }, + { + platform: 'linux', + isPackaged: true, + commandNames + } + ) + ).toBeNull() + }) + + it('spawns the unpacked CLI entrypoint with Electron node mode', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-appimage-cli-redirect-')) + const cliEntryPath = join(root, 'app.asar.unpacked', 'out', 'cli', 'index.js') + await mkdir(join(root, 'app.asar.unpacked', 'out', 'cli'), { recursive: true }) + await writeFile(cliEntryPath, '', 'utf8') + const spawn = vi.fn((..._args: unknown[]) => ({ status: 0 })) + + const result = maybeRedirectAppImageCliLaunch({ + argv: ['orca-linux.AppImage', 'status', '--json'], + env: { + APPIMAGE: '/opt/orca/orca-linux.AppImage', + NODE_OPTIONS: '--inspect', + NODE_REPL_EXTERNAL_MODULE: '/tmp/repl.js' + }, + platform: 'linux', + isPackaged: true, + resourcesPath: root, + execPath: '/opt/orca/orca-ide', + commandNames, + spawn: spawn as never + }) + + expect(result).toEqual({ redirected: true, status: 0 }) + expect(spawn).toHaveBeenCalledWith('/opt/orca/orca-ide', [cliEntryPath, 'status', '--json'], { + env: expect.objectContaining({ + APPIMAGE: '/opt/orca/orca-linux.AppImage', + ELECTRON_RUN_AS_NODE: '1', + ORCA_NODE_OPTIONS: '--inspect', + ORCA_NODE_REPL_EXTERNAL_MODULE: '/tmp/repl.js' + }), + stdio: 'inherit' + }) + const spawnOptions = spawn.mock.calls[0]?.[2] as { env: NodeJS.ProcessEnv } | undefined + expect(spawnOptions?.env).not.toHaveProperty('NODE_OPTIONS') + expect(spawnOptions?.env).not.toHaveProperty('NODE_REPL_EXTERNAL_MODULE') + }) +}) diff --git a/src/main/startup/appimage-cli-redirect.ts b/src/main/startup/appimage-cli-redirect.ts new file mode 100644 index 000000000..83aa80709 --- /dev/null +++ b/src/main/startup/appimage-cli-redirect.ts @@ -0,0 +1,180 @@ +import { spawnSync, type SpawnSyncReturns } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +type RedirectResult = + | { + redirected: false + } + | { + redirected: true + status: number + } + +type RedirectOptions = { + argv?: string[] + env?: NodeJS.ProcessEnv + platform?: NodeJS.Platform + isPackaged?: boolean + resourcesPath?: string + execPath?: string + commandNames?: readonly string[] + spawn?: typeof spawnSync +} + +const HELP_FLAGS = new Set(['--help', '-h', 'help']) +const APPIMAGE_DESKTOP_FLAGS = new Set(['--no-sandbox']) +const CLI_FLAGS_WITH_VALUES = new Set(['--environment', '--pairing-code']) +// Why: the main tsconfig cannot import the CLI project, but AppImage direct +// launches need a conservative allow-list before bypassing the GUI startup. +const APPIMAGE_CLI_COMMAND_NAMES = [ + 'agent', + 'automations', + 'back', + 'capture', + 'check', + 'clear', + 'click', + 'clipboard', + 'computer', + 'console', + 'cookie', + 'dblclick', + 'dialog', + 'download', + 'drag', + 'environment', + 'eval', + 'exec', + 'file', + 'fill', + 'find', + 'focus', + 'forward', + 'full-screenshot', + 'geolocation', + 'get', + 'goto', + 'highlight', + 'hover', + 'inserttext', + 'intercept', + 'is', + 'keypress', + 'mouse', + 'network', + 'open', + 'orchestration', + 'pdf', + 'reload', + 'repo', + 'screenshot', + 'scroll', + 'scrollintoview', + 'select', + 'select-all', + 'serve', + 'set', + 'snapshot', + 'status', + 'storage', + 'tab', + 'terminal', + 'type', + 'uncheck', + 'upload', + 'viewport', + 'wait', + 'worktree' +] + +export function maybeRedirectAppImageCliLaunch(options: RedirectOptions = {}): RedirectResult { + const argv = options.argv ?? process.argv + const env = options.env ?? process.env + const platform = options.platform ?? process.platform + const isPackaged = options.isPackaged ?? false + const resourcesPath = options.resourcesPath ?? process.resourcesPath + const execPath = options.execPath ?? process.execPath + const spawn = options.spawn ?? spawnSync + const cliArgs = getAppImageCliArgs(argv, env, { + platform, + isPackaged, + commandNames: options.commandNames ?? APPIMAGE_CLI_COMMAND_NAMES + }) + + if (!cliArgs) { + return { redirected: false } + } + + const cliEntryPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') + if (!existsSync(cliEntryPath)) { + process.stderr.write(`Unable to locate the Orca CLI entrypoint at ${cliEntryPath}\n`) + return { redirected: true, status: 1 } + } + + const childEnv = buildElectronRunAsNodeEnv(env) + const result = spawn(execPath, [cliEntryPath, ...cliArgs], { + env: childEnv, + stdio: 'inherit' + }) as SpawnSyncReturns + + if (result.error) { + process.stderr.write(`${result.error.message}\n`) + return { redirected: true, status: 1 } + } + + return { redirected: true, status: result.status ?? 1 } +} + +export function getAppImageCliArgs( + argv: string[], + env: NodeJS.ProcessEnv, + options: { + platform: NodeJS.Platform + isPackaged: boolean + commandNames: readonly string[] + } +): string[] | null { + if (options.platform !== 'linux' || !options.isPackaged) { + return null + } + if (!env.APPIMAGE && !env.APPDIR) { + return null + } + + const args = argv.slice(1) + if (args.length === 0 || args.some((arg) => APPIMAGE_DESKTOP_FLAGS.has(arg))) { + return null + } + if (args.some((arg) => HELP_FLAGS.has(arg))) { + return args + } + + const commandNames = new Set(options.commandNames) + const firstPositional = findFirstCommandCandidate(args) + return firstPositional && commandNames.has(firstPositional) ? args : null +} + +function findFirstCommandCandidate(args: string[]): string | null { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (!arg.startsWith('-')) { + return arg + } + const flagName = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg + if (CLI_FLAGS_WITH_VALUES.has(flagName) && !arg.includes('=')) { + index += 1 + } + } + return null +} + +function buildElectronRunAsNodeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const childEnv = { ...env } + childEnv.ORCA_NODE_OPTIONS = env.NODE_OPTIONS ?? '' + childEnv.ORCA_NODE_REPL_EXTERNAL_MODULE = env.NODE_REPL_EXTERNAL_MODULE ?? '' + childEnv.ELECTRON_RUN_AS_NODE = '1' + delete childEnv.NODE_OPTIONS + delete childEnv.NODE_REPL_EXTERNAL_MODULE + return childEnv +}