diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index 1eb644c17ff..4405575192c 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -14,6 +14,7 @@ import { } from './appimage-extracted-root' import { isAppImageStableLauncherReady } from './appimage-stable-launcher' import { CliPathRegistration } from './cli-path-registration' +import { removeLinuxBareOrcaDispatcher } from './linux-bare-orca-dispatcher' export class CliInstaller extends CliPathRegistration { isAppImageRegistrationOwnedBySibling(status: CliInstallStatus): boolean { @@ -164,6 +165,7 @@ export class CliInstaller extends CliPathRegistration { } private async removeUnlocked(): Promise { + await this.removeLinuxBareOrcaDispatcher() const status = await this.getStatus() if (!status.supported || !status.commandPath || !status.launcherPath || !status.installMethod) { await this.removeLinuxAppImagePayloads() @@ -204,6 +206,21 @@ export class CliInstaller extends CliPathRegistration { return this.getStatus() } + // Removing the `orca` command also retires the bare-name dispatcher headless serve installed. + private async removeLinuxBareOrcaDispatcher(): Promise { + if (this.platform !== 'linux' || this.commandPathOverride) { + return + } + try { + await removeLinuxBareOrcaDispatcher(this.homePath) + } catch (error) { + console.warn( + '[cli] Could not remove the bare orca dispatcher:', + error instanceof Error ? error.message : String(error) + ) + } + } + private async removeLinuxAppImagePayloads(): Promise { const extractionOptions = this.appImageExtractionOptions() if (this.isLinuxAppImage() && extractionOptions) { diff --git a/src/main/cli/linux-bare-orca-dispatcher-script.test.ts b/src/main/cli/linux-bare-orca-dispatcher-script.test.ts new file mode 100644 index 00000000000..c601f0bdf97 --- /dev/null +++ b/src/main/cli/linux-bare-orca-dispatcher-script.test.ts @@ -0,0 +1,277 @@ +import { spawnSync } from 'node:child_process' +import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { isPackaged: true, getPath: () => tmpdir(), getAppPath: () => tmpdir() } +})) + +import { CliInstaller } from './cli-installer' +import { + installLinuxBareOrcaDispatcher, + removeLinuxBareOrcaDispatcher +} from './linux-bare-orca-dispatcher' + +const created: string[] = [] + +afterEach(async () => { + await Promise.all(created.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +async function writeProgram(path: string, output: string): Promise { + await mkdir(join(path, '..'), { recursive: true }) + await writeFile(path, `#!/bin/sh\necho "${output} $*"\n`, 'utf8') + await chmod(path, 0o755) +} + +/** A home with Orca's dispatcher installed, and a second `orca` (as GNOME's screen reader). */ +async function makeFixture(): Promise<{ + root: string + homePath: string + resourcesPath: string + launcherPath: string + dispatcherPath: string + screenReaderDir: string + toolsDir: string +}> { + const root = await mkdtemp(join(tmpdir(), 'orca bare dispatcher ')) + created.push(root) + const homePath = join(root, 'home') + const resourcesPath = join(root, 'resources') + const launcherPath = join(resourcesPath, 'bin', 'orca-ide') + const screenReaderDir = join(root, 'usr-bin') + await writeProgram(launcherPath, 'ORCA_CLI') + await writeProgram(join(screenReaderDir, 'orca'), 'SCREEN_READER') + // Only bash on the base PATH, so a runner's own /usr/bin/orca cannot answer. + const toolsDir = join(root, 'tools') + await mkdir(toolsDir) + const bash = spawnSync('/bin/sh', ['-c', 'command -v bash'], { encoding: 'utf8' }).stdout.trim() + await symlink(bash, join(toolsDir, 'bash')) + const result = await installLinuxBareOrcaDispatcher({ + resourcesPath, + homePath, + appImagePath: null + }) + expect(result.state).toBe('installed') + return { + root, + homePath, + resourcesPath, + launcherPath, + dispatcherPath: result.dispatcherPath, + screenReaderDir, + toolsDir + } +} + +function runOrca( + fixture: { homePath: string; toolsDir: string }, + pathDirs: string[], + env: Record = {} +): { status: number | null; stdout: string; stderr: string } { + const result = spawnSync('orca', ['status'], { + encoding: 'utf8', + env: { HOME: fixture.homePath, PATH: [...pathDirs, fixture.toolsDir].join(':'), ...env }, + timeout: 10_000 + }) + return { status: result.status, stdout: result.stdout, stderr: result.stderr } +} + +describe.runIf(process.platform !== 'win32')('the bare orca dispatcher as a shell runs it', () => { + it("leaves another `orca` on PATH (GNOME's screen reader) to everyone but Orca's children", async () => { + const fixture = await makeFixture() + const binDir = join(fixture.homePath, '.local', 'bin') + + expect(runOrca(fixture, [binDir, fixture.screenReaderDir]).stdout).toBe( + 'SCREEN_READER status\n' + ) + expect( + runOrca(fixture, [binDir, fixture.screenReaderDir], { ORCA_PANE_KEY: 'tab:1' }).stdout + ).toBe('ORCA_CLI status\n') + expect( + runOrca(fixture, [binDir, fixture.screenReaderDir], { ORCA_CLI_BIN_DIR: '/x' }).stdout + ).toBe('ORCA_CLI status\n') + }) + + it('reaches this app when no other `orca` is on PATH', async () => { + const fixture = await makeFixture() + const binDir = join(fixture.homePath, '.local', 'bin') + + expect(runOrca(fixture, ['', binDir]).stdout).toBe('ORCA_CLI status\n') + }) + + it('says Orca is gone instead of failing to exec once the launcher is removed', async () => { + const fixture = await makeFixture() + const binDir = join(fixture.homePath, '.local', 'bin') + await rm(fixture.resourcesPath, { recursive: true, force: true }) + + const alone = runOrca(fixture, [binDir], { ORCA_PANE_KEY: 'tab:1' }) + expect(alone.status).toBe(127) + expect(alone.stdout).toBe('') + expect(alone.stderr).toContain(`Orca is no longer installed at ${fixture.launcherPath}`) + expect(alone.stderr).toContain(`Remove ${fixture.dispatcherPath}`) + + expect(runOrca(fixture, [binDir, fixture.screenReaderDir]).stdout).toBe( + 'SCREEN_READER status\n' + ) + }) + + async function addSecondDispatcher(fixture: { root: string; dispatcherPath: string }) { + const otherBin = join(fixture.root, 'other-home', '.local', 'bin') + await mkdir(otherBin, { recursive: true }) + await writeFile(join(otherBin, 'orca'), await readFile(fixture.dispatcherPath, 'utf8'), { + mode: 0o755 + }) + return otherBin + } + + it('skips a second Orca dispatcher ahead of it and reaches the real `orca`', async () => { + const fixture = await makeFixture() + const otherBin = await addSecondDispatcher(fixture) + const binDir = join(fixture.homePath, '.local', 'bin') + + expect(runOrca(fixture, [otherBin, binDir, fixture.screenReaderDir]).stdout).toBe( + 'SCREEN_READER status\n' + ) + expect(runOrca(fixture, [binDir, otherBin, fixture.screenReaderDir]).stdout).toBe( + 'SCREEN_READER status\n' + ) + }) + + it.each(['ORCA_PANE_KEY', 'ORCA_CLI_BIN_DIR', 'ORCA_USER_DATA_PATH'])( + 'never starts the screen reader for Orca children (%s) once Orca is gone', + async (variable) => { + const fixture = await makeFixture() + const otherBin = await addSecondDispatcher(fixture) + await rm(fixture.resourcesPath, { recursive: true, force: true }) + + const result = runOrca( + fixture, + [otherBin, join(fixture.homePath, '.local', 'bin'), fixture.screenReaderDir], + { [variable]: 'set' } + ) + expect(result.status).toBe(127) + expect(result.stdout).toBe('') + expect(result.stderr).toContain('Orca is no longer installed') + } + ) + + it('finds the real `orca` in a PATH folder with spaces', async () => { + const fixture = await makeFixture() + const spaced = join(fixture.root, 'screen reader bin') + await writeProgram(join(spaced, 'orca'), 'SPACED') + + expect(runOrca(fixture, [join(fixture.homePath, '.local', 'bin'), spaced]).stdout).toBe( + 'SPACED status\n' + ) + }) + + it('ignores empty and relative PATH entries, which depend on the caller folder', async () => { + const fixture = await makeFixture() + await writeProgram(join(fixture.root, 'relative', 'orca'), 'RELATIVE') + + const result = spawnSync(fixture.dispatcherPath, ['status'], { + cwd: fixture.root, + encoding: 'utf8', + env: { + HOME: fixture.homePath, + PATH: ['', '.', 'relative', fixture.toolsDir].join(':') + }, + timeout: 10_000 + }) + expect(result.stdout).toBe('ORCA_CLI status\n') + }) + + it('skips a symlink to itself on PATH', async () => { + const fixture = await makeFixture() + const linkDir = join(fixture.root, 'linked') + await mkdir(linkDir) + await symlink(fixture.dispatcherPath, join(linkDir, 'orca')) + + expect( + runOrca(fixture, [linkDir, join(fixture.homePath, '.local', 'bin'), fixture.screenReaderDir]) + .stdout + ).toBe('SCREEN_READER status\n') + }) + + it.skipIf(process.getuid?.() === 0)( + 'runs an unreadable candidate as the real `orca` rather than skipping it', + async () => { + const fixture = await makeFixture() + const lockedDir = join(fixture.root, 'locked') + await writeProgram(join(lockedDir, 'orca'), 'LOCKED') + await chmod(join(lockedDir, 'orca'), 0o111) + + const result = runOrca(fixture, [ + join(fixture.homePath, '.local', 'bin'), + lockedDir, + fixture.screenReaderDir + ]) + // Exec'd (and refused by the kernel/shell), never passed over for the next `orca`. + expect(result.stdout).not.toContain('SCREEN_READER') + expect(result.status).not.toBe(0) + } + ) + + it('never loops when the only other `orca` is another dispatcher', async () => { + const fixture = await makeFixture() + const otherBin = await addSecondDispatcher(fixture) + await rm(fixture.resourcesPath, { recursive: true, force: true }) + + const result = runOrca(fixture, [join(fixture.homePath, '.local', 'bin'), otherBin]) + expect(result.status).toBe(127) + expect(result.stderr).toContain('Orca is no longer installed') + }) + + it('adds nothing to the environment of the `orca` it hands over to', async () => { + const fixture = await makeFixture() + const otherBin = await addSecondDispatcher(fixture) + await writeFile( + join(fixture.screenReaderDir, 'orca'), + "#!/bin/sh\nenv | grep '^ORCA_' || echo NO_ORCA_ENV\n", + { mode: 0o755 } + ) + + const result = runOrca(fixture, [ + otherBin, + join(fixture.homePath, '.local', 'bin'), + fixture.screenReaderDir + ]) + expect(result.stdout).toBe('NO_ORCA_ENV\n') + }) +}) + +describe.runIf(process.platform !== 'win32')('removing the bare orca dispatcher', () => { + it("removes Orca's dispatcher and keeps a user's own `orca`", async () => { + const fixture = await makeFixture() + + await expect(removeLinuxBareOrcaDispatcher(fixture.homePath)).resolves.toBe(true) + expect(existsSync(fixture.dispatcherPath)).toBe(false) + await expect(removeLinuxBareOrcaDispatcher(fixture.homePath)).resolves.toBe(false) + + await writeProgram(fixture.dispatcherPath, 'MINE') + await expect(removeLinuxBareOrcaDispatcher(fixture.homePath)).resolves.toBe(false) + expect(await readFile(fixture.dispatcherPath, 'utf8')).toContain('MINE') + }) + + it('goes with the `orca` command when the user removes it', async () => { + const fixture = await makeFixture() + const installer = new CliInstaller({ + platform: 'linux', + isPackaged: true, + userDataPath: join(fixture.root, 'userData'), + appPath: join(fixture.root, 'app'), + resourcesPath: fixture.resourcesPath, + homePath: fixture.homePath, + processPathEnv: join(fixture.homePath, '.local', 'bin') + }) + await installer.install() + + await installer.remove() + + expect(existsSync(fixture.dispatcherPath)).toBe(false) + }) +}) diff --git a/src/main/cli/linux-bare-orca-dispatcher.ts b/src/main/cli/linux-bare-orca-dispatcher.ts index ddefcb2c541..812d3a4eca6 100644 --- a/src/main/cli/linux-bare-orca-dispatcher.ts +++ b/src/main/cli/linux-bare-orca-dispatcher.ts @@ -46,7 +46,7 @@ export type LinuxBareOrcaDispatcherResult = { target: string | null } -// Team launchers invoke bare `orca`; headless managed terminals need this ahead of GNOME Orca. +// Team launchers invoke bare `orca`, and a login shell can drop Orca's PATH entry ahead of it. // A regular file avoids the installer's legacy-symlink cleanup. export async function installLinuxBareOrcaDispatcher( options: LinuxBareOrcaDispatcherOptions @@ -63,7 +63,7 @@ export async function installLinuxBareOrcaDispatcher( const installed = await publishDispatcher( dispatcherPath, - insertDispatcherMarker(buildBareOrcaCliScript(launcher)) + insertDispatcherMarker(buildBareOrcaDispatcherScript(launcher)) ) return installed ? { state: 'installed', dispatcherPath, target: launcher } @@ -74,6 +74,71 @@ export function buildBareOrcaCliScript(launcherPath: string): string { return `#!/usr/bin/env bash\nexec ${quotePosixShellDoubleQuotedApostrophe(launcherPath)} "$@"\n` } +/** + * Orca's own children reach this app's CLI, or a clear error once it is gone, never another `orca` + * (GNOME's screen reader shares the name). Anyone else gets the next real `orca` on PATH first. + */ +export function buildBareOrcaDispatcherScript(launcherPath: string): string { + return [ + '#!/usr/bin/env bash', + `launcher=${quotePosixShellDoubleQuotedApostrophe(launcherPath)}`, + 'gone() {', + ` printf 'orca: Orca is no longer installed at %s. Remove %s to stop this message.\\n' "$launcher" "\${BASH_SOURCE[0]}" >&2`, + ' exit 127', + '}', + // An agent's `orca …` must never start a talking screen reader, even once Orca is gone. + 'if [ -n "${ORCA_CLI_BIN_DIR-}${ORCA_PANE_KEY-}${ORCA_USER_DATA_PATH-}" ]; then', + ' [ -x "$launcher" ] && exec "$launcher" "$@"', + ' gone', + 'fi', + // Skip every Orca dispatcher, not just this one, so none can hand the call back to Orca; + // the real target runs with nothing added to its environment. Relative entries depend on the + // caller's folder, so only absolute ones count. + 'IFS=: read -r -a path_dirs <<< "${PATH-}"', + 'for dir in "${path_dirs[@]}"; do', + ' case "$dir" in /*) ;; *) continue ;; esac', + ' candidate="$dir/orca"', + ' if [ -f "$candidate" ] && [ -x "$candidate" ] && ! [ "$candidate" -ef "${BASH_SOURCE[0]}" ]; then', + ' marker=""', + ' { IFS= read -r _ && IFS= read -r marker; } < "$candidate" 2>/dev/null', + ` if [ "$marker" != ${quotePosixShellDoubleQuotedApostrophe(DISPATCHER_MARKER)} ]; then`, + ' exec "$candidate" "$@"', + ' fi', + ' fi', + 'done', + '[ -x "$launcher" ] && exec "$launcher" "$@"', + 'gone', + '' + ].join('\n') +} + +/** Removes only Orca's own dispatcher; a user's `orca` at that path is left alone. */ +export async function removeLinuxBareOrcaDispatcher(homePath = homedir()): Promise { + const dispatcherPath = join(homePath, '.local', 'bin', 'orca') + if (!(await isOwnedDispatcher(dispatcherPath))) { + return false + } + const displacedPath = join( + dirname(dispatcherPath), + `.orca-removed-dispatcher-${process.pid}-${randomUUID()}` + ) + try { + await rename(dispatcherPath, displacedPath) + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return false + } + throw error + } + if (await isOwnedDispatcher(displacedPath)) { + await unlink(displacedPath) + return true + } + // A foreign command replaced it between the check and the rename; put that one back. + await restoreDisplacedDispatcher(displacedPath, dispatcherPath) + return false +} + // AppImage resources live on an ephemeral FUSE mount; dispatch through a persistent copy. async function resolveStableLauncherPath( options: LinuxBareOrcaDispatcherOptions