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