diff --git a/src/main/pty/legacy-terminal-posix-tombstone.ts b/src/main/pty/legacy-terminal-posix-tombstone.ts new file mode 100644 index 00000000000..4520b9e4708 --- /dev/null +++ b/src/main/pty/legacy-terminal-posix-tombstone.ts @@ -0,0 +1,69 @@ +const SHELL_DOLLAR = '$' + +const POSIX_TOMBSTONE = String.raw`#!/usr/bin/env bash +set -u + +command_name="__ORCA_COMMAND__" +wrapper_dir="$(cd -- "$(dirname -- "${SHELL_DOLLAR}{BASH_SOURCE[0]}")" && pwd)" +legacy_wrapper_dir="${SHELL_DOLLAR}{ORCA_ATTRIBUTION_SHIM_DIR:-}" +cleaned_path="${SHELL_DOLLAR}{PATH:-}" + +filter_path() { + local legacy_target="$legacy_wrapper_dir" + while [[ "$legacy_target" != "/" && "$legacy_target" == */ ]]; do + legacy_target="${SHELL_DOLLAR}{legacy_target%/}" + done + local remaining="$cleaned_path" + local filtered_path="" + local separator="" + path_entry_kept=0 + local entry normalized candidate has_more + while true; do + if [[ "$remaining" == *:* ]]; then + entry="${SHELL_DOLLAR}{remaining%%:*}" + remaining="${SHELL_DOLLAR}{remaining#*:}" + has_more=1 + else + entry="$remaining" + has_more=0 + fi + normalized="$entry" + while [[ "$normalized" != "/" && "$normalized" == */ ]]; do + normalized="${SHELL_DOLLAR}{normalized%/}" + done + candidate="${SHELL_DOLLAR}{entry:-.}" + if [[ -n "$legacy_target" && "$normalized" == "$legacy_target" ]]; then + : + elif [[ "$candidate" -ef "$wrapper_dir" ]]; then + : + else + filtered_path+="$separator$entry" + separator=":" + path_entry_kept=1 + fi + [[ "$has_more" == 1 ]] || break + done + cleaned_path="$filtered_path" +} + +filter_path +unset ORCA_ENABLE_GIT_ATTRIBUTION ORCA_GIT_COMMIT_TRAILER ORCA_GH_PR_FOOTER +unset ORCA_GH_ISSUE_FOOTER ORCA_ATTRIBUTION_SHIM_DIR ORCA_REAL_GIT ORCA_REAL_GH ORCA_ATTRIBUTION_BYPASS + +real_command="" +if [[ "$path_entry_kept" == 1 ]]; then + real_command="$(PATH="$cleaned_path" type -P "$command_name" || true)" +fi +if [[ -n "$real_command" && "$real_command" -ef "${SHELL_DOLLAR}{BASH_SOURCE[0]}" ]]; then + real_command="" +fi +if [[ -z "$real_command" ]]; then + printf 'Orca compatibility wrapper could not locate %s on PATH.\n' "$command_name" >&2 + exit 127 +fi +PATH="$cleaned_path" exec "$real_command" "$@" +` + +export function renderLegacyTerminalPosixTombstone(command: 'git' | 'gh'): string { + return POSIX_TOMBSTONE.replaceAll('__ORCA_COMMAND__', command) +} diff --git a/src/main/pty/legacy-terminal-shim-dir.test.ts b/src/main/pty/legacy-terminal-shim-dir.test.ts index 2bf90e9d9fe..32e59800266 100644 --- a/src/main/pty/legacy-terminal-shim-dir.test.ts +++ b/src/main/pty/legacy-terminal-shim-dir.test.ts @@ -5,6 +5,7 @@ import { mkdirSync, mkdtempSync, readFileSync, + rmSync, statSync, writeFileSync } from 'node:fs' @@ -22,16 +23,27 @@ const itOnPosix = process.platform === 'win32' ? it.skip : it const itOnPosixNonRoot = process.platform === 'win32' || process.getuid?.() === 0 ? it.skip : it describe('legacy terminal shim neutralization', () => { + const tempRoots: string[] = [] + + const makeUserDataDir = (): string => { + const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-')) + tempRoots.push(userData) + return userData + } + beforeEach(() => { __resetLegacyTerminalShimNeutralizationForTests() }) afterEach(() => { vi.useRealTimers() + for (const tempRoot of tempRoots.splice(0)) { + rmSync(tempRoot, { recursive: true, force: true }) + } }) it('atomically replaces the legacy command paths with executable tombstones', () => { - const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-')) + const userData = makeUserDataDir() const legacyRoot = join(userData, 'orca-terminal-attribution') const posixDir = join(legacyRoot, 'posix') const win32Dir = join(legacyRoot, 'win32') @@ -58,7 +70,7 @@ describe('legacy terminal shim neutralization', () => { }) it('rejects stale Windows real-command paths inside the wrapper directory', () => { - const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-')) + const userData = makeUserDataDir() const win32Dir = join(userData, 'orca-terminal-attribution', 'win32') neutralizeLegacyTerminalShimDir(userData) @@ -76,7 +88,7 @@ describe('legacy terminal shim neutralization', () => { }) it('removes every Windows PATH occurrence of both captured wrapper directories', () => { - const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-')) + const userData = makeUserDataDir() const win32Dir = join(userData, 'orca-terminal-attribution', 'win32') neutralizeLegacyTerminalShimDir(userData) @@ -100,7 +112,7 @@ describe('legacy terminal shim neutralization', () => { }) it('does not throw when the legacy directory is absent', () => { - const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-')) + const userData = makeUserDataDir() expect(() => neutralizeLegacyTerminalShimDir(userData)).not.toThrow() expect(existsSync(join(userData, 'orca-terminal-attribution', 'posix', 'git'))).toBe(true) @@ -108,7 +120,7 @@ describe('legacy terminal shim neutralization', () => { itOnPosixNonRoot('retries a startup failure in-process and latches after success', async () => { vi.useFakeTimers() - const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-')) + const userData = makeUserDataDir() const posixDir = join(userData, 'orca-terminal-attribution', 'posix') const gitWrapper = join(posixDir, 'git') mkdirSync(posixDir, { recursive: true }) @@ -133,8 +145,8 @@ describe('legacy terminal shim neutralization', () => { } }) - itOnPosix('keeps a real Bash command hash working as an inert pass-through', async () => { - const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-')) + itOnPosix('keeps a real Bash command hash working with trailing PATH separators', async () => { + const userData = makeUserDataDir() const shimDir = join(userData, 'orca-terminal-attribution', 'posix') const shimGit = join(shimDir, 'git') const realBin = join(userData, 'real-bin') @@ -148,12 +160,13 @@ describe('legacy terminal shim neutralization', () => { { mode: 0o755 } ) const child = spawn('bash', ['--noprofile', '--norc'], { + cwd: shimDir, env: { ...process.env, - PATH: `${shimDir}:${realBin}:${process.env.PATH ?? ''}`, + PATH: `${shimDir}//::${realBin}:${process.env.PATH ?? ''}`, ORCA_ENABLE_GIT_ATTRIBUTION: '1', ORCA_GIT_COMMIT_TRAILER: 'Co-authored-by: Orca ', - ORCA_ATTRIBUTION_SHIM_DIR: shimDir + ORCA_ATTRIBUTION_SHIM_DIR: '' }, stdio: ['pipe', 'pipe', 'pipe'] }) @@ -168,14 +181,23 @@ describe('legacy terminal shim neutralization', () => { stderr += chunk }) const ready = waitForOutput(child.stdout, '__ORCA_HASH_READY__\n') - const closed = new Promise((resolve) => child.once('close', resolve)) child.stdin.write(`hash -p ${quoteBash(shimGit)} git\nprintf '__ORCA_HASH_READY__\\n'\n`) - await ready + try { + await ready + } catch (error) { + child.kill('SIGKILL') + throw error + } neutralizeLegacyTerminalShimDir(userData) + const closed = waitForChildClose(child, 2_000) child.stdin.end("printf 'stdin payload\\n' | git commit -m 'subject with spaces'; exit $?\n") - expect(await closed).toBe(23) + try { + expect(await closed).toBe(23) + } finally { + child.kill('SIGKILL') + } expect(stdout).toContain('arg=\narg=<-m>\narg=\nstdin payload\n') expect(stdout).not.toContain('Co-authored-by: Orca') expect(stderr).toBe('fixture stderr\n') @@ -303,3 +325,19 @@ function waitForOutput(stream: NodeJS.ReadableStream, marker: string): Promise, + timeoutMs: number +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + child.kill('SIGKILL') + reject(new Error(`Bash did not exit within ${timeoutMs}ms`)) + }, timeoutMs) + child.once('close', (exitCode) => { + clearTimeout(timeout) + resolve(exitCode) + }) + }) +} diff --git a/src/main/pty/legacy-terminal-shim-dir.ts b/src/main/pty/legacy-terminal-shim-dir.ts index 5d89839a0e5..0d64ebaed05 100644 --- a/src/main/pty/legacy-terminal-shim-dir.ts +++ b/src/main/pty/legacy-terminal-shim-dir.ts @@ -4,11 +4,11 @@ import { LEGACY_TERMINAL_ATTRIBUTION_BYPASS_ENV_KEY, LEGACY_TERMINAL_ATTRIBUTION_ENABLE_ENV_KEY } from '../../shared/legacy-terminal-attribution-env' +import { renderLegacyTerminalPosixTombstone } from './legacy-terminal-posix-tombstone' const LEGACY_SHIM_ROOT_DIR = 'orca-terminal-attribution' const LEGACY_SHIM_VERSION = '7' const NEUTRALIZATION_RETRY_DELAYS_MS = [1_000, 5_000, 15_000, 30_000] -const SHELL_DOLLAR = '$' export const LEGACY_TERMINAL_SHIM_ENV_KEYS = [ 'ORCA_ENABLE_GIT_ATTRIBUTION', 'ORCA_GIT_COMMIT_TRAILER', @@ -23,43 +23,6 @@ export const LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS = [ LEGACY_TERMINAL_ATTRIBUTION_ENABLE_ENV_KEY ] as const -const POSIX_PASSTHROUGH_WRAPPER = String.raw`#!/usr/bin/env bash -set -u - -command_name="__ORCA_COMMAND__" -wrapper_dir="$(cd -- "$(dirname -- "${SHELL_DOLLAR}{BASH_SOURCE[0]}")" && pwd)" -legacy_wrapper_dir="${SHELL_DOLLAR}{ORCA_ATTRIBUTION_SHIM_DIR:-}" -cleaned_path="${SHELL_DOLLAR}{PATH:-}" - -remove_path_entry() { - local entry="$1" - [[ -n "$entry" ]] || return - while [[ "$cleaned_path" == "$entry" || "$cleaned_path" == "$entry":* || "$cleaned_path" == *:"$entry" || "$cleaned_path" == *:"$entry":* ]]; do - if [[ "$cleaned_path" == "$entry" ]]; then - cleaned_path="" - elif [[ "$cleaned_path" == "$entry":* ]]; then - cleaned_path="${SHELL_DOLLAR}{cleaned_path#"$entry":}" - elif [[ "$cleaned_path" == *:"$entry" ]]; then - cleaned_path="${SHELL_DOLLAR}{cleaned_path%:"$entry"}" - else - cleaned_path="${SHELL_DOLLAR}{cleaned_path//:"$entry":/:}" - fi - done -} - -remove_path_entry "$legacy_wrapper_dir" -remove_path_entry "$wrapper_dir" -unset ORCA_ENABLE_GIT_ATTRIBUTION ORCA_GIT_COMMIT_TRAILER ORCA_GH_PR_FOOTER -unset ORCA_GH_ISSUE_FOOTER ORCA_ATTRIBUTION_SHIM_DIR ORCA_REAL_GIT ORCA_REAL_GH ORCA_ATTRIBUTION_BYPASS - -real_command="$(PATH="$cleaned_path" type -P "$command_name" || true)" -if [[ -z "$real_command" ]]; then - printf 'Orca compatibility wrapper could not locate %s on PATH.\n' "$command_name" >&2 - exit 127 -fi -PATH="$cleaned_path" exec "$real_command" "$@" -` - const WIN32_PASSTHROUGH_WRAPPER = String.raw`@echo off setlocal set "orca_real=%ORCA_REAL___ORCA_UPPER_COMMAND__%" @@ -179,13 +142,9 @@ function writeNeutralWrappers(rootDir: string): void { const win32Dir = join(rootDir, 'win32') mkdirSync(posixDir, { recursive: true }) mkdirSync(win32Dir, { recursive: true }) - for (const command of ['git', 'gh']) { + for (const command of ['git', 'gh'] as const) { const upperCommand = command.toUpperCase() - writeFileAtomically( - join(posixDir, command), - POSIX_PASSTHROUGH_WRAPPER.replaceAll('__ORCA_COMMAND__', command), - 0o755 - ) + writeFileAtomically(join(posixDir, command), renderLegacyTerminalPosixTombstone(command), 0o755) writeFileAtomically( join(win32Dir, `${command}.cmd`), renderWindowsWrapper(WIN32_PASSTHROUGH_WRAPPER, command, upperCommand),