From 1603fd246a44527073eae5e0d7368e22cf30d644 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:44:52 -0700 Subject: [PATCH] fix(linux): stop the serve orca dispatcher shadowing the screen reader, and remove it with the CLI (#35578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(linux): stop the serve `orca` dispatcher shadowing the screen reader, and remove it with the CLI Headless `orca serve` writes ~/.local/bin/orca so Claude Team's bare `orca` reaches Orca even when a login shell drops Orca's PATH entry. It exec'd the launcher unconditionally, so it sat in front of GNOME's `orca` screen reader for everyone, failed with "No such file or directory" once Orca was uninstalled, and nothing ever removed it. The dispatcher now answers for Orca's own children only (ORCA_CLI_BIN_DIR, ORCA_PANE_KEY or ORCA_USER_DATA_PATH set). Anyone else gets the next `orca` on PATH, skipping itself, with one hop so two dispatchers cannot loop; then this app; then a clear message and exit 127 once the launcher is gone. Existing dispatchers are rewritten on the next serve start. Removing the `orca` command now also removes the dispatcher, and only if it carries Orca's marker. * fix(linux): skip every Orca dispatcher on PATH instead of an env hop guard The hop variable let a second dispatcher earlier on PATH send a screen-reader user's `orca` to Orca, and leaked into the screen reader's environment so its descendants' `orca` could land on Orca too. The PATH scan now skips any candidate carrying the dispatcher marker (as well as itself), so dispatchers cannot hand calls to each other, and the real target runs with nothing added to its environment. * test(linux): Orca's own children reach the screen reader once Orca is gone * fix(linux): Orca's own children get "no longer installed", never the screen reader With ORCA_CLI_BIN_DIR, ORCA_PANE_KEY or ORCA_USER_DATA_PATH set and the launcher gone, the dispatcher now prints the message and exits 127 without scanning PATH, so an agent's `orca …` can never start GNOME's talking screen reader. The PATH scan also ignores relative entries, which depend on the caller's folder. Tests cover each Orca variable, a PATH folder with spaces, empty and relative entries, a symlink to the dispatcher, and an unreadable candidate being run rather than skipped. --- src/main/cli/cli-installer.ts | 17 ++ .../linux-bare-orca-dispatcher-script.test.ts | 277 ++++++++++++++++++ src/main/cli/linux-bare-orca-dispatcher.ts | 69 ++++- 3 files changed, 361 insertions(+), 2 deletions(-) create mode 100644 src/main/cli/linux-bare-orca-dispatcher-script.test.ts 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