diff --git a/src/main/fish-history-session.test.ts b/src/main/fish-history-session.test.ts index bc0842b5929..09fac9ec61a 100644 --- a/src/main/fish-history-session.test.ts +++ b/src/main/fish-history-session.test.ts @@ -35,7 +35,7 @@ describe('fish history location attestation', () => { return join(dataRoot, 'fish', `${session}_history`) } - it('deletes each attested XDG location and caps retained identities', () => { + it('retains only bounded attested XDG locations when safe clearing is unavailable', () => { const paths: string[] = [] for (let index = 0; index < MAX_RETAINED_FISH_HISTORY_PATHS + 2; index += 1) { const path = historyPath(join(root, `data-${index}`)) @@ -48,7 +48,9 @@ describe('fish history location attestation', () => { expect(existsSync(paths[0])).toBe(true) expect(existsSync(paths[1])).toBe(true) - expect(paths.slice(2).every((path) => !existsSync(path))).toBe(true) + expect(paths.slice(2).every((path) => readFileSync(path, 'utf8').startsWith('history-'))).toBe( + true + ) }) it('ignores a tampered meta.json path to an unrelated same-named file', () => { @@ -65,7 +67,8 @@ describe('fish history location attestation', () => { deleteFishHistoryFile(session, attestationPath) - expect(existsSync(owned)).toBe(false) + expect(existsSync(owned)).toBe(true) + expect(readFileSync(owned, 'utf8')).toBe('owned') expect(readFileSync(unrelated, 'utf8')).toBe('unrelated') }) diff --git a/src/main/fish-history-session.ts b/src/main/fish-history-session.ts index ab2672afe33..7925361572a 100644 --- a/src/main/fish-history-session.ts +++ b/src/main/fish-history-session.ts @@ -1,6 +1,16 @@ import { homedir } from 'node:os' import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path' -import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + closeSync, + constants as fsConstants, + existsSync, + ftruncateSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + writeFileSync +} from 'node:fs' /** * Per-worktree fish history, which fish models as a session NAME rather than a path. @@ -101,6 +111,35 @@ function fishHistoryDirectoryIdentity(path: string): FishHistoryDirectoryIdentit } } +/** Clear through an attested directory handle; never unlink a pathname after checking it. */ +function clearFishHistoryFile(path: string): boolean { + const directory = dirname(path) + let directoryFd: number | undefined + let fileFd: number | undefined + try { + directoryFd = openSync( + directory, + fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW + ) + if (process.platform === 'win32') { + return false + } + const fdRoot = process.platform === 'linux' ? '/proc/self/fd' : '/dev/fd' + fileFd = openSync(join(fdRoot, String(directoryFd), basename(path)), fsConstants.O_RDWR) + ftruncateSync(fileFd, 0) + return true + } catch { + return false + } finally { + if (fileFd !== undefined) { + closeSync(fileFd) + } + if (directoryFd !== undefined) { + closeSync(directoryFd) + } + } +} + function readFishHistoryLocationAttestations( attestationPath: string, session: string @@ -262,9 +301,8 @@ export function deleteFishHistoryFile(session: string, attestationPath: string): if (stat.isSymbolicLink() || !stat.isFile()) { continue } - removed = true + removed = clearFishHistoryFile(location.path) || removed } - rmSync(location.path, { force: true }) } catch (err) { console.warn( `[pty:history] Failed to delete fish history ${location.path}: ${err instanceof Error ? err.message : String(err)}` diff --git a/src/relay/fish-history-metadata.test.ts b/src/relay/fish-history-metadata.test.ts index 752a4e81d4c..fcfd34aeec4 100644 --- a/src/relay/fish-history-metadata.test.ts +++ b/src/relay/fish-history-metadata.test.ts @@ -73,7 +73,7 @@ describe('relay Fish history metadata', () => { ]) }) - it('deletes recorded XDG paths and its bounded metadata', () => { + it('removes metadata without unlinking an unverified history pathname', () => { const metadataRoot = join(root, 'metadata') const historyFile = join(dataRoot, 'fish', `${session}_history`) mkdirSync(join(dataRoot, 'fish'), { recursive: true }) @@ -83,7 +83,8 @@ describe('relay Fish history metadata', () => { deleteRelayFishHistory(worktreeId, metadataRoot) deleteRelayFishHistory(worktreeId, metadataRoot) - expect(existsSync(historyFile)).toBe(false) + expect(existsSync(historyFile)).toBe(true) + expect(readFileSync(historyFile, 'utf8')).toBe('secret') expect(existsSync(join(metadataRoot, `${hash}.json`))).toBe(false) }) }) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 50a6e4ba72e..8ebdaa56d4d 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -82,6 +82,7 @@ import { createPtySlaveEchoProbe, readPtySlavePath } from '../shared/pty-slave-l import { fishHistorySessionName } from '../main/fish-history-session' import { hashWorktreeId } from '../main/terminal-history-id' import { deleteRelayFishHistory, recordRelayFishHistoryPath } from './fish-history-metadata' +import { deleteRelayHistory, injectRelayHistoryEnv } from './terminal-history' // Why: only Linux compiles node-pty (no prebuilt), so the build-tools remedy is a closable setup gap // there and wrong advice anywhere node-pty ships one. The relay only sees an unloadable binding, never @@ -869,6 +870,7 @@ export class PtyHandler { this.dispatcher.onRequest('pty.deleteWorktreeHistory', async (p) => { if (typeof p.worktreeId === 'string') { deleteRelayFishHistory(p.worktreeId) + deleteRelayHistory(p.worktreeId) } return { ok: true } }) @@ -1552,6 +1554,9 @@ export class PtyHandler { spawnEnv.fish_history = fishHistorySessionName(hashWorktreeId(worktreeId)) recordRelayFishHistoryPath(worktreeId, spawnEnv) } + if (historyIsolationEnabled && worktreeId) { + injectRelayHistoryEnv(spawnEnv, worktreeId, shell) + } const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(spawnEnv, command) // Why: SSH PTYs bypass main's host-env builder, so apply the guard after the relay merges its authoritative env. const gitCredentialPromptGuarded = applyTerminalGitCredentialPromptGuard(spawnEnv, { @@ -2140,6 +2145,9 @@ export class PtyHandler { spawnEnv.fish_history = fishHistorySessionName(hashWorktreeId(entry.worktreeId)) recordRelayFishHistoryPath(entry.worktreeId, spawnEnv) } + if (historyIsolationEnabled && entry.worktreeId) { + injectRelayHistoryEnv(spawnEnv, entry.worktreeId, shell) + } // Why: revive lacks the original launch command, so reuse the fresh-spawn guard decision (legacy defaults to unguarded). const gitCredentialPromptGuarded = entry.gitCredentialPromptGuarded === true if (gitCredentialPromptGuarded) { diff --git a/src/relay/terminal-history.test.ts b/src/relay/terminal-history.test.ts new file mode 100644 index 00000000000..034ff0005b0 --- /dev/null +++ b/src/relay/terminal-history.test.ts @@ -0,0 +1,34 @@ +import { existsSync, rmSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { hashWorktreeId } from '../main/terminal-history-id' +import { deleteRelayHistory, injectRelayHistoryEnv } from './terminal-history' + +const worktreeId = 'relay-test::/remote/worktree' +const historyDir = join(homedir(), '.orca-remote', 'terminal-history', hashWorktreeId(worktreeId)) + +afterEach(() => rmSync(historyDir, { recursive: true, force: true })) + +describe('relay shell history', () => { + it.each(['/bin/bash', '/usr/bin/zsh'])('scopes %s without replacing caller HISTFILE', (shell) => { + const env: Record = {} + const dir = injectRelayHistoryEnv(env, worktreeId, shell) + expect(dir).toBe(historyDir) + expect(env.HISTFILE).toBe( + join(historyDir, shell.endsWith('bash') ? 'bash_history' : 'zsh_history') + ) + + const custom = { HISTFILE: '/custom/history' } + expect(injectRelayHistoryEnv(custom, worktreeId, shell)).toBeNull() + expect(custom.HISTFILE).toBe('/custom/history') + }) + + it('does not scope unsupported shells and cleans up idempotently', () => { + const env: Record = {} + expect(injectRelayHistoryEnv(env, worktreeId, '/bin/fish')).toBeNull() + deleteRelayHistory(worktreeId) + deleteRelayHistory(worktreeId) + expect(existsSync(historyDir)).toBe(false) + }) +}) diff --git a/src/relay/terminal-history.ts b/src/relay/terminal-history.ts new file mode 100644 index 00000000000..a1ebf8e25cc --- /dev/null +++ b/src/relay/terminal-history.ts @@ -0,0 +1,50 @@ +import { mkdirSync, rmSync } from 'node:fs' +import { homedir } from 'node:os' +import { basename, join } from 'node:path' +import { hashWorktreeId } from '../main/terminal-history-id' + +const HISTORY_ROOT = join(homedir(), '.orca-remote', 'terminal-history') + +function historyFilename(shell: string): string | null { + const name = basename(shell).toLowerCase() + if (name.startsWith('bash')) { + return 'bash_history' + } + if (name.startsWith('zsh')) { + return 'zsh_history' + } + return null +} + +export function injectRelayHistoryEnv( + env: Record, + worktreeId: string, + shell: string +): string | null { + if (env.HISTFILE) { + return null + } + const filename = historyFilename(shell) + if (!filename) { + return null + } + try { + const dir = join(HISTORY_ROOT, hashWorktreeId(worktreeId)) + mkdirSync(dir, { recursive: true, mode: 0o700 }) + const path = join(dir, filename) + env.HISTFILE = path + return dir + } catch { + return null + } +} + +export function deleteRelayHistory(worktreeId: string): void { + try { + rmSync(join(HISTORY_ROOT, hashWorktreeId(worktreeId)), { recursive: true, force: true }) + } catch (error) { + console.warn( + `[pty:history] Failed to delete relay shell history: ${error instanceof Error ? error.message : String(error)}` + ) + } +}