fix(linux): stop Orca terminals from launching the GNOME Orca screen reader via bare orca (#8347)

On Linux the CLI installs as orca-ide so it never shadows /usr/bin/orca
(GNOME's screen reader), but agent-facing surfaces (orca-cli skill,
dispatch preambles, CLI hints) all invoke bare `orca` — so on stock
Ubuntu an agent inside an Orca terminal launched the screen reader,
which started speaking (#7904).

Fix: prepend a userData-scoped shim dir (bare `orca` -> bundled
orca-ide launcher, or the stable AppImage) to the PATH of every
packaged-Linux managed PTY, mirroring the existing dev-mode cli/bin
prepend. The user's own shells — and their real screen reader command —
stay untouched. Also flip the orca-cli skill probe to prefer orca-ide
so agents outside Orca terminals never execute the screen reader.

Fixes #7904

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-12 00:38:35 -07:00
committed by GitHub
co-authored by Orca
parent 6ee81dcfb1
commit 42cf64cdc9
6 changed files with 250 additions and 7 deletions
+4 -2
View File
@@ -15,7 +15,7 @@ description: >-
# Orca CLI
Use `orca` when Orca's running editor/runtime is the source of truth. On Linux, use `orca-ide` wherever this file says `orca`.
Use `orca` when Orca's running editor/runtime is the source of truth. Inside Orca-managed terminals, `orca` always resolves to the Orca CLI on every platform. In any other shell on Linux, use `orca-ide` wherever this file says `orca` — outside Orca's terminals, bare `orca` on Linux is usually the GNOME Orca screen reader (`/usr/bin/orca`), and running it starts speech on the user's machine.
**Dev builds (`pnpm dev`):** after `pnpm build:cli`, the dev CLI is exposed as `orca-dev` (the global shim points at this checkout's wrapper + out/cli). Inside a dev Orca's terminals use `orca-dev emulator ...` (or `./config/scripts/orca-dev.mjs emulator ...` for worktree-local invocation that does not depend on the /usr/local/bin symlink). Plain `orca` targets any installed production Orca. The app's own agent preambles use `orca-dev` automatically in dev mode.
@@ -24,7 +24,9 @@ Use plain shell tools when Orca state does not matter.
## Start Here
```bash
command -v orca || command -v orca-ide
# Prefer orca-ide first: on Linux, a bare `orca` hit outside an Orca-managed
# terminal is likely the GNOME screen reader, not the Orca CLI.
command -v orca-ide || command -v orca
orca status --json
orca worktree ps --json
orca terminal list --json
+16 -5
View File
@@ -60,30 +60,41 @@ export async function installLinuxBareOrcaDispatcher(
return { state: 'installed', dispatcherPath, target: resolved.target }
}
function resolveDispatcherScript(
/** Bare-`orca` script that execs the Orca CLI: the stable AppImage when running
* from one, otherwise the bundled `orca-ide` launcher. Shared by the serve
* dispatcher and the managed-terminal PATH shim. */
export function buildBareOrcaCliScript(
resourcesPath: string,
appImagePath: string | null
): { script: string; target: string } | null {
if (appImagePath) {
// Why: an AppImage mounts resources under an ephemeral FUSE path per launch,
// so the dispatcher must exec the stable outer AppImage — reuse the same
// so the script must exec the stable outer AppImage — reuse the same
// wrapper CliInstaller installs for the AppImage command.
return { script: withMarker(buildAppImageCliWrapper(appImagePath)), target: appImagePath }
return { script: buildAppImageCliWrapper(appImagePath), target: appImagePath }
}
const launcher = getBundledLauncherPath('linux', resourcesPath)
// Why: getBundledLauncherPath only joins the path; guard existence so we never
// write a dispatcher pointing at a missing launcher (which would fail at exec
// write a script pointing at a missing launcher (which would fail at exec
// time with a confusing error instead of the command-not-found we fix).
if (!launcher || !existsSync(launcher)) {
return null
}
return {
script: `#!/usr/bin/env bash\n${DISPATCHER_MARKER}\nexec ${quoteShell(launcher)} "$@"\n`,
script: `#!/usr/bin/env bash\nexec ${quoteShell(launcher)} "$@"\n`,
target: launcher
}
}
function resolveDispatcherScript(
resourcesPath: string,
appImagePath: string | null
): { script: string; target: string } | null {
const resolved = buildBareOrcaCliScript(resourcesPath, appImagePath)
return resolved && { script: withMarker(resolved.script), target: resolved.target }
}
function withMarker(script: string): string {
const firstNewline = script.indexOf('\n')
if (firstNewline === -1) {
@@ -0,0 +1,118 @@
import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
app: { isPackaged: true }
}))
import { ensureLinuxTerminalOrcaCliShimDir } from './linux-terminal-orca-cli-shim'
const created: string[] = []
async function makeFixture(): Promise<{ userDataPath: string; resourcesPath: string }> {
const root = await mkdtemp(join(tmpdir(), 'orca-terminal-cli-shim-'))
created.push(root)
const resourcesPath = join(root, 'resources')
// The bundled orca-ide launcher must exist for the shim to be written.
mkdirSync(join(resourcesPath, 'bin'), { recursive: true })
writeFileSync(join(resourcesPath, 'bin', 'orca-ide'), '#!/usr/bin/env bash\n', 'utf8')
return { userDataPath: join(root, 'user-data'), resourcesPath }
}
afterEach(async () => {
await Promise.all(created.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
})
describe('ensureLinuxTerminalOrcaCliShimDir', () => {
it('writes an executable bare-orca shim that execs the bundled orca-ide launcher', async () => {
const { userDataPath, resourcesPath } = await makeFixture()
const shimDir = ensureLinuxTerminalOrcaCliShimDir({
userDataPath,
resourcesPath,
appImagePath: null
})
expect(shimDir).toBe(join(userDataPath, 'linux-orca-cli-shim'))
const content = readFileSync(join(shimDir!, 'orca'), 'utf8')
// Single-quoted so a resources path with shell metacharacters can't break out.
expect(content).toContain(`exec '${join(resourcesPath, 'bin', 'orca-ide')}' "$@"`)
const mode = statSync(join(shimDir!, 'orca')).mode & 0o777
expect(mode & 0o111).not.toBe(0)
})
it('memoizes per userDataPath and re-asserts the exec bit for a stale shim', async () => {
const { userDataPath, resourcesPath } = await makeFixture()
const options = { userDataPath, resourcesPath, appImagePath: null }
const first = ensureLinuxTerminalOrcaCliShimDir(options)
expect(first).not.toBeNull()
const shimPath = join(first!, 'orca')
chmodSync(shimPath, 0o644)
// A distinct userData path is not memoized, so ensure runs again and heals
// the exec bit lost above only when it actually processes that path.
const second = ensureLinuxTerminalOrcaCliShimDir(options)
expect(second).toBe(first)
const root = await mkdtemp(join(tmpdir(), 'orca-terminal-cli-shim-2-'))
created.push(root)
const otherUserData = join(root, 'user-data')
mkdirSync(join(otherUserData, 'linux-orca-cli-shim'), { recursive: true })
writeFileSync(join(otherUserData, 'linux-orca-cli-shim', 'orca'), 'stale contents', 'utf8')
chmodSync(join(otherUserData, 'linux-orca-cli-shim', 'orca'), 0o644)
const healed = ensureLinuxTerminalOrcaCliShimDir({
userDataPath: otherUserData,
resourcesPath,
appImagePath: null
})
expect(healed).not.toBeNull()
const healedPath = join(healed!, 'orca')
expect(readFileSync(healedPath, 'utf8')).toContain('orca-ide')
expect(statSync(healedPath).mode & 0o111).not.toBe(0)
})
it('execs the stable AppImage (not the ephemeral mount) when running from an AppImage', async () => {
const { userDataPath, resourcesPath } = await makeFixture()
const appImagePath = join(userDataPath, 'Applications', 'Orca.AppImage')
const shimDir = ensureLinuxTerminalOrcaCliShimDir({
userDataPath,
resourcesPath,
appImagePath
})
const content = readFileSync(join(shimDir!, 'orca'), 'utf8')
expect(content).toContain(appImagePath)
expect(content).not.toContain(resourcesPath)
})
it('returns null (and does not memoize) when the bundled launcher is missing', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-terminal-cli-shim-missing-'))
created.push(root)
const userDataPath = join(root, 'user-data')
const missing = ensureLinuxTerminalOrcaCliShimDir({
userDataPath,
resourcesPath: join(root, 'resources'),
appImagePath: null
})
expect(missing).toBeNull()
// Once the launcher exists (e.g. later probe with real resources), the same
// userData path succeeds — proving failures are not cached.
const resourcesPath = join(root, 'resources')
mkdirSync(join(resourcesPath, 'bin'), { recursive: true })
writeFileSync(join(resourcesPath, 'bin', 'orca-ide'), '#!/usr/bin/env bash\n', 'utf8')
const recovered = ensureLinuxTerminalOrcaCliShimDir({
userDataPath,
resourcesPath,
appImagePath: null
})
expect(recovered).toBe(join(userDataPath, 'linux-orca-cli-shim'))
})
})
@@ -0,0 +1,70 @@
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { buildBareOrcaCliScript } from './linux-bare-orca-dispatcher'
const SHIM_DIR_NAME = 'linux-orca-cli-shim'
// Why: rewriting the shim on every PTY spawn is wasted fs work; the target only
// changes with the install itself, so one successful write per process is enough.
// Failures are NOT cached so a transient fs error retries on the next spawn.
const ensuredShimDirs = new Map<string, string>()
export type LinuxTerminalOrcaCliShimOptions = {
userDataPath: string
/** Test seam — defaults to the packaged resources root. */
resourcesPath?: string | null
/** Test seam — defaults to $APPIMAGE (set only when running from an AppImage). */
appImagePath?: string | null
}
// Why: on Linux the CLI installs as `orca-ide` so it never shadows the GNOME
// Orca screen reader at /usr/bin/orca — but agent-facing surfaces (skills,
// dispatch preambles, CLI hints) all invoke bare `orca`, so on stock Ubuntu an
// agent inside an Orca terminal would launch the screen reader instead
// (stablyai/orca#7904). Prepending this userData-scoped shim dir to managed-PTY
// PATH makes bare `orca` resolve to the Orca CLI inside Orca terminals only,
// leaving the user's own shells (and their screen reader) untouched.
export function ensureLinuxTerminalOrcaCliShimDir(
options: LinuxTerminalOrcaCliShimOptions
): string | null {
const cached = ensuredShimDirs.get(options.userDataPath)
if (cached !== undefined) {
return cached
}
const resourcesPath = options.resourcesPath ?? process.resourcesPath
if (!resourcesPath) {
return null
}
const resolved = buildBareOrcaCliScript(
resourcesPath,
options.appImagePath ?? process.env.APPIMAGE ?? null
)
if (!resolved) {
return null
}
const shimDir = join(options.userDataPath, SHIM_DIR_NAME)
const shimPath = join(shimDir, 'orca')
try {
if (readShim(shimPath) !== resolved.script) {
mkdirSync(shimDir, { recursive: true })
writeFileSync(shimPath, resolved.script, 'utf8')
}
// Why: always re-assert the exec bit — a shim written by an older run (or
// restored from backup) with mode stripped would fail every agent CLI call.
chmodSync(shimPath, 0o755)
} catch {
return null
}
ensuredShimDirs.set(options.userDataPath, shimDir)
return shimDir
}
function readShim(shimPath: string): string | null {
try {
return readFileSync(shimPath, 'utf8')
} catch {
return null
}
}
+29
View File
@@ -169,6 +169,13 @@ vi.mock('../telemetry/classify-error', () => ({
classifyError: classifyErrorMock
}))
// Why: the real ensure writes to disk from process.resourcesPath, which does
// not exist under vitest; env assembly only needs the returned dir path.
vi.mock('../cli/linux-terminal-orca-cli-shim', () => ({
ensureLinuxTerminalOrcaCliShimDir: (options: { userDataPath: string }) =>
join(options.userDataPath, 'linux-orca-cli-shim')
}))
vi.mock('../memory/pty-registry', () => ({
registerPty: registerPtyMock,
unregisterPty: unregisterPtyMock
@@ -1681,6 +1688,28 @@ describe('registerPtyHandlers', () => {
}
})
it('prepends the bare-orca CLI shim dir to PATH for packaged Linux spawns', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux'
})
try {
const env = await daemonSpawnAndGetEnv({ PATH: '/usr/local/bin:/usr/bin' })
const entries = env.PATH.split(delimiter)
const shimDir = join('/tmp/orca-user-data', 'linux-orca-cli-shim')
// Why: bare `orca` must resolve to the Orca CLI before /usr/bin/orca
// (the GNOME screen reader) inside Orca-managed terminals (#7904).
expect(entries.indexOf(shimDir)).toBeGreaterThanOrEqual(0)
expect(entries.indexOf(shimDir)).toBeLessThan(entries.indexOf('/usr/bin'))
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('injects the agent-hook receiver env on the daemon path', async () => {
const env = await daemonSpawnAndGetEnv({})
expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678')
+13
View File
@@ -78,6 +78,7 @@ import {
applyTerminalAttributionEnv,
resolveAttributionShellFamily
} from '../attribution/terminal-attribution'
import { ensureLinuxTerminalOrcaCliShimDir } from '../cli/linux-terminal-orca-cli-shim'
import { registerPty, unregisterPty } from '../memory/pty-registry'
import { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
import { track } from '../telemetry/client'
@@ -991,6 +992,18 @@ export function buildPtyHostEnv(
// the current working directory (a foot-gun we don't want to create
// for dev terminals).
baseEnv.PATH = inheritedPath ? `${devCliBin}${delimiter}${inheritedPath}` : devCliBin
} else if (process.platform === 'linux') {
// Why: the Linux CLI installs as `orca-ide` (never shadowing GNOME's
// /usr/bin/orca screen reader), but agent-facing guidance invokes bare
// `orca`. Scope a bare-`orca` shim to Orca-managed PTYs so agents reach
// the Orca CLI instead of the screen reader (stablyai/orca#7904).
const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath })
if (shimDir) {
const inheritedEntries = readInheritedPath(baseEnv)
.split(delimiter)
.filter((entry) => entry.length > 0 && entry !== shimDir)
baseEnv.PATH = [shimDir, ...inheritedEntries].join(delimiter)
}
}
// Why: GitHub attribution should only affect commands launched from