Fix relay history isolation and safe fish cleanup

This commit is contained in:
Neil
2026-08-12 23:37:14 -07:00
parent 0b2f698991
commit ce7c6e0102
6 changed files with 142 additions and 8 deletions
+6 -3
View File
@@ -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')
})
+41 -3
View File
@@ -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)}`
+3 -2
View File
@@ -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)
})
})
+8
View File
@@ -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) {
+34
View File
@@ -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<string, string> = {}
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<string, string> = {}
expect(injectRelayHistoryEnv(env, worktreeId, '/bin/fish')).toBeNull()
deleteRelayHistory(worktreeId)
deleteRelayHistory(worktreeId)
expect(existsSync(historyDir)).toBe(false)
})
})
+50
View File
@@ -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<string, string>,
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)}`
)
}
}