mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
Fix relay history isolation and safe fish cleanup
This commit is contained in:
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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)}`
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user