diff --git a/skills/orca-cli/SKILL.md b/skills/orca-cli/SKILL.md index 4cc5acaa9ed..0231463e4a4 100644 --- a/skills/orca-cli/SKILL.md +++ b/skills/orca-cli/SKILL.md @@ -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 diff --git a/src/main/cli/linux-bare-orca-dispatcher.ts b/src/main/cli/linux-bare-orca-dispatcher.ts index 33c794b8150..3dc8df2906c 100644 --- a/src/main/cli/linux-bare-orca-dispatcher.ts +++ b/src/main/cli/linux-bare-orca-dispatcher.ts @@ -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) { diff --git a/src/main/cli/linux-terminal-orca-cli-shim.test.ts b/src/main/cli/linux-terminal-orca-cli-shim.test.ts new file mode 100644 index 00000000000..81af17ba779 --- /dev/null +++ b/src/main/cli/linux-terminal-orca-cli-shim.test.ts @@ -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')) + }) +}) diff --git a/src/main/cli/linux-terminal-orca-cli-shim.ts b/src/main/cli/linux-terminal-orca-cli-shim.ts new file mode 100644 index 00000000000..56f38df15ce --- /dev/null +++ b/src/main/cli/linux-terminal-orca-cli-shim.ts @@ -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() + +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 + } +} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 94b047d8968..35d386eb473 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -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') diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 493cc494058..305ebabfc14 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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