Revert Claude scoped settings launch path (#2809)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-05-25 19:03:14 -07:00
committed by GitHub
co-authored by Orca
parent e0af80ddec
commit add481978e
20 changed files with 171 additions and 287 deletions
+20 -23
View File
@@ -17,8 +17,6 @@ vi.mock('electron', () => ({
import type { SFTPWrapper } from 'ssh2'
import { ClaudeHookService } from './hook-service'
const CLAUDE_SETTINGS_FILE = 'claude-agent-status-settings.json'
type FakeFs = {
files: Map<string, string>
dirs: Set<string>
@@ -104,7 +102,7 @@ function createFakeSftp(): { sftp: SFTPWrapper; fs: FakeFs } {
}
describe('ClaudeHookService.install', () => {
it('keeps the scoped settings hook-only and preserves user Bedrock settings', () => {
it('installs managed hooks into Claude settings and preserves user Bedrock settings', () => {
const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-hooks-'))
vi.stubEnv('HOME', tmpHome)
try {
@@ -141,11 +139,6 @@ describe('ClaudeHookService.install', () => {
const status = new ClaudeHookService().install()
expect(status.state).toBe('installed')
const scoped = JSON.parse(
readFileSync(join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SETTINGS_FILE), 'utf-8')
)
expect(Object.keys(scoped)).toEqual(['hooks'])
const legacy = JSON.parse(readFileSync(legacyPath, 'utf-8'))
expect(legacy).toMatchObject({
apiKeyHelper: '/opt/company/claude-key-helper',
@@ -160,7 +153,15 @@ describe('ClaudeHookService.install', () => {
(definition: { hooks: { command: string }[] }) =>
definition.hooks.map((hook) => hook.command)
)
expect(legacyCommands).toEqual(['/usr/local/bin/user-hook'])
expect(legacyCommands).toContain('/usr/local/bin/user-hook')
expect(legacyCommands.some((command: string) => command.includes('claude-hook.sh'))).toBe(
true
)
expect(
legacyCommands.some((command: string) =>
command.includes('/Users/old/.orca/agent-hooks/claude-hook.sh')
)
).toBe(false)
} finally {
vi.unstubAllEnvs()
rmSync(tmpHome, { recursive: true, force: true })
@@ -169,13 +170,13 @@ describe('ClaudeHookService.install', () => {
})
describe('ClaudeHookService.installRemote', () => {
it('writes scoped settings + managed script under the remote $HOME', async () => {
it('writes Claude settings + managed script under the remote $HOME', async () => {
const svc = new ClaudeHookService()
const { sftp, fs } = createFakeSftp()
const status = await svc.installRemote(sftp, '/home/dev')
expect(status.state).toBe('installed')
expect(status.configPath).toBe('/home/dev/.orca/agent-hooks/claude-agent-status-settings.json')
const settings = fs.files.get('/home/dev/.orca/agent-hooks/claude-agent-status-settings.json')
expect(status.configPath).toBe('/home/dev/.claude/settings.json')
const settings = fs.files.get('/home/dev/.claude/settings.json')
expect(settings).toBeTruthy()
const parsed = JSON.parse(settings!)
// Why: every load-bearing event must be present and point at the
@@ -199,20 +200,19 @@ describe('ClaudeHookService.installRemote', () => {
// Managed script body
expect(fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toContain('#!/bin/sh')
expect(fs.modes.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toBe(0o755)
expect(fs.files.has('/home/dev/.claude/settings.json')).toBe(false)
})
it('reports parse error when legacy remote settings.json cannot be cleaned', async () => {
it('reports parse error when remote settings.json cannot be parsed', async () => {
const svc = new ClaudeHookService()
const { sftp, fs } = createFakeSftp()
fs.files.set('/home/dev/.claude/settings.json', 'not json')
const status = await svc.installRemote(sftp, '/home/dev')
expect(status.state).toBe('error')
expect(status.managedHooksPresent).toBe(true)
expect(status.detail).toContain('Scoped Claude hooks installed')
expect(status.managedHooksPresent).toBe(false)
expect(status.detail).toContain('Could not parse remote Claude settings.json')
})
it('preserves user-authored legacy hook entries while sweeping old managed entries', async () => {
it('preserves user-authored hook entries while sweeping old managed entries', async () => {
const svc = new ClaudeHookService()
const { sftp, fs } = createFakeSftp()
fs.files.set(
@@ -238,14 +238,11 @@ describe('ClaudeHookService.installRemote', () => {
)
await svc.installRemote(sftp, '/home/dev')
const parsed = JSON.parse(fs.files.get('/home/dev/.claude/settings.json')!)
// Original user-authored entry survives, while legacy global Orca entries
// are removed because scoped --settings carries the managed hook now.
// Original user-authored entry survives, while stale Orca entries are
// replaced with the current managed hook command.
const stopDefs = parsed.hooks.Stop as { hooks: { command: string }[] }[]
const userCmds = stopDefs.flatMap((d) => d.hooks.map((h) => h.command))
expect(userCmds).toContain('/usr/local/bin/my-user-hook')
expect(userCmds.some((c) => c.includes('claude-hook.sh'))).toBe(false)
expect(fs.files.get('/home/dev/.orca/agent-hooks/claude-agent-status-settings.json')).toContain(
'claude-hook.sh'
)
expect(userCmds.filter((c) => c.includes('claude-hook.sh'))).toHaveLength(1)
})
})
+42 -94
View File
@@ -1,7 +1,5 @@
import { existsSync, unlinkSync } from 'fs'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import { ORCA_CLAUDE_AGENT_STATUS_SETTINGS_ENV } from '../../shared/claude-settings'
import {
buildWindowsAgentHookPostCommand,
readHooksJson,
@@ -16,13 +14,11 @@ import {
import {
applyManagedHooks,
CLAUDE_EVENTS,
getLegacyConfigPath,
getConfigPath,
getManagedCommand,
getManagedScriptPath,
getRemoteLegacyConfigPath,
getRemoteConfigPath,
getRemoteManagedCommand,
getRemoteScopedSettingsPath,
getScopedSettingsPath,
removeManagedHooks
} from './hook-settings'
@@ -92,7 +88,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
export class ClaudeHookService {
getStatus(): AgentHookInstallStatus {
const configPath = getScopedSettingsPath()
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
const config = readHooksJson(configPath)
if (!config) {
@@ -101,7 +97,7 @@ export class ClaudeHookService {
state: 'error',
configPath,
managedHooksPresent: false,
detail: 'Could not parse Orca Claude settings file'
detail: 'Could not parse Claude settings.json'
}
}
@@ -142,41 +138,18 @@ export class ClaudeHookService {
}
install(): AgentHookInstallStatus {
const scopedStatus = this.installScopedSettings()
if (scopedStatus.state === 'error') {
return scopedStatus
}
const legacyStatus = this.removeLegacyGlobalHooks()
if (legacyStatus.state === 'error') {
return {
...legacyStatus,
managedHooksPresent: scopedStatus.managedHooksPresent,
detail: scopedStatus.managedHooksPresent
? `Scoped Claude hooks installed, but ${legacyStatus.detail}`
: legacyStatus.detail
}
}
return scopedStatus
}
buildPtyEnv(): Record<string, string> {
try {
const status = this.installScopedSettings()
if (status.state === 'error') {
console.warn(`[agent-hooks] Failed to prepare Claude scoped settings: ${status.detail}`)
}
} catch (error) {
console.warn('[agent-hooks] Failed to prepare Claude scoped settings:', error)
}
return {
[ORCA_CLAUDE_AGENT_STATUS_SETTINGS_ENV]: getScopedSettingsPath()
}
}
private installScopedSettings(): AgentHookInstallStatus {
const configPath = getScopedSettingsPath()
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
const config = readHooksJson(configPath) ?? {}
const config = readHooksJson(configPath)
if (!config) {
return {
agent: 'claude',
state: 'error',
configPath,
managedHooksPresent: false,
detail: 'Could not parse Claude settings.json'
}
}
const command = getManagedCommand(scriptPath)
const nextConfig = applyManagedHooks(config, command)
@@ -185,37 +158,15 @@ export class ClaudeHookService {
return this.getStatus()
}
private removeLegacyGlobalHooks(): AgentHookInstallStatus {
const configPath = getLegacyConfigPath()
const config = readHooksJson(configPath)
if (!config) {
return {
agent: 'claude',
state: 'error',
configPath,
managedHooksPresent: false,
detail: 'Could not parse Claude settings.json to remove legacy global hooks'
}
}
const { config: nextConfig, changed } = removeManagedHooks(config)
if (changed) {
writeHooksJson(configPath, nextConfig)
}
return this.getStatus()
}
// Why: install Orca's scoped Claude hook settings on the remote box rather
// than the local Mac/Linux machine. Caller passes the user's SFTP handle
// from the SshConnection plus the resolved remote `$HOME` used to compute
// the Orca-owned settings path. POSIX-only by design — see
// docs/design/agent-status-over-ssh.md §3 / §6 (Windows-remote deferred).
// Why: install Orca's Claude hook settings on the remote box rather than the
// local machine. Caller passes the user's SFTP handle plus the resolved
// remote `$HOME`; POSIX-only by design (Windows-remote deferred).
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
// Why: remote-Windows is out of scope for v1 — we ship POSIX-shaped paths
// and a `.sh` managed script body. The remote platform is gated by the
// relay's capability RPC at a higher layer; we cannot detect it from
// `process.platform` here (that's the local box).
const remoteConfigPath = getRemoteScopedSettingsPath(remoteHome)
const remoteLegacyConfigPath = getRemoteLegacyConfigPath(remoteHome)
const remoteConfigPath = getRemoteConfigPath(remoteHome)
const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/claude-hook.sh`
// Why: SFTP reads/writes fail far more often than local fs (network drops,
// EACCES on remote dirs, disk full, channel closed). Wrap the entire
@@ -225,7 +176,16 @@ export class ClaudeHookService {
// specifically means "file present but unparseable" — keep that branch
// distinct so the user sees an actionable message.
try {
const config = (await readHooksJsonRemote(sftp, remoteConfigPath)) ?? {}
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
if (!config) {
return {
agent: 'claude',
state: 'error',
configPath: remoteConfigPath,
managedHooksPresent: false,
detail: 'Could not parse remote Claude settings.json'
}
}
// Why: the POSIX wrapper is identical regardless of where the script
// lands; only the path differs. Reuse the same wrapper helper.
@@ -243,25 +203,6 @@ export class ClaudeHookService {
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig)
const legacyConfig = await readHooksJsonRemote(sftp, remoteLegacyConfigPath)
if (!legacyConfig) {
return {
agent: 'claude',
state: 'error',
configPath: remoteLegacyConfigPath,
managedHooksPresent: true,
detail:
'Scoped Claude hooks installed, but could not parse remote Claude settings.json to remove legacy global hooks'
}
}
const { config: nextLegacyConfig, changed } = removeManagedHooks(
legacyConfig,
'claude-hook.sh'
)
if (changed) {
await writeHooksJsonRemote(sftp, remoteLegacyConfigPath, nextLegacyConfig)
}
return {
agent: 'claude',
state: 'installed',
@@ -281,13 +222,20 @@ export class ClaudeHookService {
}
remove(): AgentHookInstallStatus {
const scopedSettingsPath = getScopedSettingsPath()
if (existsSync(scopedSettingsPath)) {
unlinkSync(scopedSettingsPath)
const configPath = getConfigPath()
const config = readHooksJson(configPath)
if (!config) {
return {
agent: 'claude',
state: 'error',
configPath,
managedHooksPresent: false,
detail: 'Could not parse Claude settings.json'
}
}
const legacyStatus = this.removeLegacyGlobalHooks()
if (legacyStatus.state === 'error') {
return legacyStatus
const { config: nextConfig, changed } = removeManagedHooks(config)
if (changed) {
writeHooksJson(configPath, nextConfig)
}
return this.getStatus()
}
+2 -11
View File
@@ -1,6 +1,5 @@
import { homedir } from 'os'
import { join } from 'path'
import { ORCA_CLAUDE_AGENT_STATUS_SETTINGS_FILE } from '../../shared/claude-settings'
import {
createManagedCommandMatcher,
getSharedManagedScriptPath,
@@ -33,14 +32,10 @@ export const CLAUDE_EVENTS = [
}
] as const
export function getLegacyConfigPath(): string {
export function getConfigPath(): string {
return join(homedir(), '.claude', 'settings.json')
}
export function getScopedSettingsPath(): string {
return join(homedir(), '.orca', 'agent-hooks', ORCA_CLAUDE_AGENT_STATUS_SETTINGS_FILE)
}
export function getManagedScriptFileName(): string {
return process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh'
}
@@ -49,11 +44,7 @@ export function getManagedScriptPath(): string {
return getSharedManagedScriptPath(getManagedScriptFileName())
}
export function getRemoteScopedSettingsPath(remoteHome: string): string {
return `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/${ORCA_CLAUDE_AGENT_STATUS_SETTINGS_FILE}`
}
export function getRemoteLegacyConfigPath(remoteHome: string): string {
export function getRemoteConfigPath(remoteHome: string): string {
return `${remoteHome.replace(/\/$/, '')}/.claude/settings.json`
}
+55 -24
View File
@@ -20,7 +20,6 @@ const {
spawnMock,
openCodeBuildPtyEnvMock,
openCodeClearPtyMock,
claudeBuildPtyEnvMock,
buildAgentHookEnvMock,
clearAgentHookPaneStateMock,
registerPaneKeyAliasMock,
@@ -50,7 +49,6 @@ const {
getPathMock: vi.fn(),
spawnMock: vi.fn(),
openCodeBuildPtyEnvMock: vi.fn(),
claudeBuildPtyEnvMock: vi.fn(),
isPwshAvailableMock: vi.fn(),
openCodeClearPtyMock: vi.fn(),
buildAgentHookEnvMock: vi.fn(),
@@ -105,12 +103,6 @@ vi.mock('../opencode/hook-service', () => ({
}
}))
vi.mock('../claude/hook-service', () => ({
claudeHookService: {
buildPtyEnv: claudeBuildPtyEnvMock
}
}))
vi.mock('../agent-hooks/server', () => ({
agentHookServer: {
buildPtyEnv: buildAgentHookEnvMock,
@@ -198,7 +190,7 @@ describe('registerPtyHandlers', () => {
const savedOrcaCodexHome = process.env.ORCA_CODEX_HOME
const savedOrcaOmpAgentDir = process.env.ORCA_OMP_CODING_AGENT_DIR
const savedOrcaOmpSourceAgentDir = process.env.ORCA_OMP_SOURCE_AGENT_DIR
const savedOrcaClaudeSettings = process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS
const savedOrcaClaudeAgentStatusSettings = process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS
beforeEach(() => {
delete process.env.OPENCODE_CONFIG_DIR
@@ -228,7 +220,6 @@ describe('registerPtyHandlers', () => {
spawnMock.mockReset()
openCodeBuildPtyEnvMock.mockReset()
openCodeClearPtyMock.mockReset()
claudeBuildPtyEnvMock.mockReset()
buildAgentHookEnvMock.mockReset()
clearAgentHookPaneStateMock.mockReset()
registerPaneKeyAliasMock.mockReset()
@@ -265,9 +256,6 @@ describe('registerPtyHandlers', () => {
ORCA_AGENT_HOOK_PORT: '5678',
ORCA_AGENT_HOOK_TOKEN: 'agent-token'
})
claudeBuildPtyEnvMock.mockReturnValue({
ORCA_CLAUDE_AGENT_STATUS_SETTINGS: '/tmp/orca-claude-settings.json'
})
piBuildPtyEnvMock.mockImplementation(
(_ptyId: string, existingAgentDir?: string, _kind?: string) => ({
PI_CODING_AGENT_DIR: existingAgentDir
@@ -335,10 +323,10 @@ describe('registerPtyHandlers', () => {
} else {
delete process.env.ORCA_OMP_SOURCE_AGENT_DIR
}
if (savedOrcaClaudeSettings === undefined) {
if (savedOrcaClaudeAgentStatusSettings === undefined) {
delete process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS
} else {
process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS = savedOrcaClaudeSettings
process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS = savedOrcaClaudeAgentStatusSettings
}
})
@@ -731,7 +719,7 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/home/tester/.config/pi-agent')
})
it('injects the Claude/Codex hook receiver env into Orca terminal PTYs', async () => {
it('injects the agent hook receiver env into Orca terminal PTYs', async () => {
const env = await spawnAndGetEnv()
// Why: after the daemon-parity refactor, buildAgentHookEnv runs exactly
// once for a local spawn — inside the shared buildPtyHostEnv helper,
@@ -739,10 +727,8 @@ describe('registerPtyHandlers', () => {
// both route through. The handler's separate ad-hoc injection (which
// used to cause a double-call for local spawns) is gone.
expect(buildAgentHookEnvMock).toHaveBeenCalledTimes(1)
expect(claudeBuildPtyEnvMock).toHaveBeenCalledTimes(1)
expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678')
expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
expect(env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBe('/tmp/orca-claude-settings.json')
})
it('strips stale inherited hook receiver env before injecting this runtime', async () => {
@@ -752,7 +738,7 @@ describe('registerPtyHandlers', () => {
ORCA_AGENT_HOOK_ENV: 'production',
ORCA_AGENT_HOOK_VERSION: 'stale-version',
ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env',
ORCA_CLAUDE_AGENT_STATUS_SETTINGS: '/tmp/stale-claude-settings.json'
ORCA_CLAUDE_AGENT_STATUS_SETTINGS: '/tmp/orca/agent-hooks/claude-agent-status-settings.json'
})
expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678')
@@ -760,7 +746,7 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_AGENT_HOOK_ENV).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_VERSION).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
expect(env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBe('/tmp/orca-claude-settings.json')
expect(env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBeUndefined()
})
it('does not leak inherited hook receiver env if the hook server is unavailable', async () => {
@@ -772,7 +758,7 @@ describe('registerPtyHandlers', () => {
ORCA_AGENT_HOOK_ENV: 'production',
ORCA_AGENT_HOOK_VERSION: 'stale-version',
ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env',
ORCA_CLAUDE_AGENT_STATUS_SETTINGS: '/tmp/stale-claude-settings.json'
ORCA_CLAUDE_AGENT_STATUS_SETTINGS: '/tmp/orca/agent-hooks/claude-agent-status-settings.json'
})
expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined()
@@ -780,7 +766,7 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_AGENT_HOOK_ENV).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_VERSION).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
expect(env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBe('/tmp/orca-claude-settings.json')
expect(env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBeUndefined()
})
it('prepends local git/gh attribution shims when attribution is enabled', async () => {
@@ -1086,7 +1072,53 @@ describe('registerPtyHandlers', () => {
const env = await daemonSpawnAndGetEnv({})
expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678')
expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
expect(env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBe('/tmp/orca-claude-settings.json')
})
it('deletes stale Claude scoped settings env from daemon-hosted PTYs', async () => {
const spawnOptions = await daemonSpawnAndGetOptions({}, undefined, undefined, {
ORCA_CLAUDE_AGENT_STATUS_SETTINGS:
'/tmp/orca/agent-hooks/claude-agent-status-settings.json'
})
expect(spawnOptions.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBeUndefined()
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining(['ORCA_CLAUDE_AGENT_STATUS_SETTINGS'])
)
expect(spawnOptions.env.ORCA_AGENT_HOOK_PORT).toBe('5678')
expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
})
it('deletes stale Claude scoped settings env from runtime-created daemon PTYs', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
env?: Record<string, string>
}): Promise<{ id: string }>
}
const daemonSpawn = setupDaemonAdapter()
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS =
'/tmp/orca/agent-hooks/claude-agent-status-settings.json'
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
await controller.spawn({ cols: 80, rows: 24, worktreeId: 'wt-runtime', env: {} })
const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall
expect(spawnOptions.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBeUndefined()
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining(['ORCA_CLAUDE_AGENT_STATUS_SETTINGS'])
)
expect(spawnOptions.env.ORCA_AGENT_HOOK_PORT).toBe('5678')
expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
})
it('strips inherited agent-hook endpoint env from development daemon PTYs', async () => {
@@ -1381,7 +1413,6 @@ describe('registerPtyHandlers', () => {
// worst a credential leak.
expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined()
expect(env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBeUndefined()
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
expect(env.OPENCODE_CONFIG_DIR).toBeUndefined()
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined()
+23 -8
View File
@@ -10,8 +10,6 @@ export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-rea
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import type { Store } from '../persistence'
import type { GlobalSettings } from '../../shared/types'
import { ORCA_CLAUDE_AGENT_STATUS_SETTINGS_ENV } from '../../shared/claude-settings'
import { claudeHookService } from '../claude/hook-service'
import { openCodeHookService } from '../opencode/hook-service'
import { agentHookServer } from '../agent-hooks/server'
import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
@@ -93,7 +91,9 @@ const AGENT_HOOK_RUNTIME_ENV_KEYS = [
'ORCA_AGENT_HOOK_ENV',
'ORCA_AGENT_HOOK_VERSION',
'ORCA_AGENT_HOOK_ENDPOINT',
ORCA_CLAUDE_AGENT_STATUS_SETTINGS_ENV
// Why: PR 2778 briefly exported this scoped Claude settings path. Keep
// deleting stale inherited values so older PTYs cannot leak the reverted path.
'ORCA_CLAUDE_AGENT_STATUS_SETTINGS'
] as const
export function getPtyIdForPaneKey(paneKey: string): string | undefined {
@@ -332,6 +332,16 @@ function mergePtyEnvDeletions(
return Array.from(new Set([...(existingKeys ?? []), ...additionalKeys]))
}
function getInheritedAgentHookEnvKeysToDelete(
spawnEnv: Record<string, string> | undefined
): string[] {
const env = spawnEnv ?? {}
// Why: daemon/local providers merge process.env after main-process cleanup.
// Delete reverted or unavailable hook env keys there without dropping fresh
// receiver coordinates that buildPtyHostEnv intentionally set.
return AGENT_HOOK_RUNTIME_ENV_KEYS.filter((key) => env[key] === undefined)
}
// Why: when agent status is disabled, a nested Orca terminal can still pass
// through a prior PTY's OpenCode/Pi overlay env. Restore the user's original
// source dir when Orca recorded one, otherwise strip only values known to be ours.
@@ -431,7 +441,6 @@ export function buildPtyHostEnv(
}
if (opts.agentStatusHooksEnabled) {
Object.assign(baseEnv, agentHookServer.buildPtyEnv())
Object.assign(baseEnv, claudeHookService.buildPtyEnv())
}
// Why: PI_CODING_AGENT_DIR owns Pi's / OMP's full config/session root (OMP
@@ -1103,9 +1112,12 @@ export function registerPtyHandlers(
cwd: args.cwd,
env
}
if (claudeAuth?.stripAuthEnv) {
spawnOptions.envToDelete = [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS']
}
spawnOptions.envToDelete = mergePtyEnvDeletions(
claudeAuth?.stripAuthEnv
? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS']
: undefined,
args.connectionId ? [] : getInheritedAgentHookEnvKeysToDelete(env)
)
if (skipCodexHomeEnv) {
spawnOptions.envToDelete = mergePtyEnvDeletions(
spawnOptions.envToDelete,
@@ -1507,7 +1519,10 @@ export function registerPtyHandlers(
? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS']
: undefined
const combinedEnvToDelete = mergePtyEnvDeletions(
envToDelete,
mergePtyEnvDeletions(
envToDelete,
args.connectionId ? [] : getInheritedAgentHookEnvKeysToDelete(spawnEnv)
),
skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : []
)
const spawnOptions: PtySpawnOptions = {
+1 -1
View File
@@ -7189,7 +7189,7 @@ describe('OrcaRuntimeService', () => {
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
cwd: '/remote/mobile-startup-draft',
command: `claude --settings "$HOME/.orca/agent-hooks/claude-agent-status-settings.json" --prefill '${draftUrl}'`,
command: `claude --prefill '${draftUrl}'`,
connectionId: 'ssh-1',
worktreeId: result.worktree.id
})
+2 -4
View File
@@ -6730,8 +6730,7 @@ export class OrcaRuntimeService {
agent,
draft: content,
cmdOverrides: settings.agentCmdOverrides ?? {},
platform: agentLaunchPlatform,
useOrcaClaudeAgentStatusSettings: settings.agentStatusHooksEnabled !== false
platform: agentLaunchPlatform
})
if (draftLaunchPlan) {
return {
@@ -6748,8 +6747,7 @@ export class OrcaRuntimeService {
prompt: '',
cmdOverrides: settings.agentCmdOverrides ?? {},
platform: agentLaunchPlatform,
allowEmptyPromptLaunch: true,
useOrcaClaudeAgentStatusSettings: settings.agentStatusHooksEnabled !== false
allowEmptyPromptLaunch: true
})
if (!startupPlan) {
return null
@@ -48,8 +48,7 @@ export function FloatingTerminalWindowControls({
prompt: '',
cmdOverrides: state.settings?.agentCmdOverrides ?? {},
platform: CLIENT_PLATFORM,
allowEmptyPromptLaunch: true,
useOrcaClaudeAgentStatusSettings: state.settings?.agentStatusHooksEnabled !== false
allowEmptyPromptLaunch: true
})
if (!startupPlan) {
toast.error(`Could not build launch command for ${defaultAgentLabel ?? defaultAgent}.`)
@@ -147,7 +147,6 @@ describe('AgentsPane', () => {
it('includes hook search metadata for the status setting', () => {
expect(matchesSettingsSearch('hooks', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true)
expect(matchesSettingsSearch('waiting', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true)
expect(matchesSettingsSearch('scoped', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true)
expect(matchesSettingsSearch('codex', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true)
})
})
@@ -1,7 +1,7 @@
export const AGENT_STATUS_HOOKS_TITLE = 'Agent status hooks'
export const AGENT_STATUS_HOOKS_DESCRIPTION =
'Shows working, waiting, and done states in Orca. For Claude and Codex, Orca uses scoped settings and profiles so terminal sessions outside Orca keep your existing config. Turn off to remove Orca-managed hooks and stop reinstalling them.'
'Shows working, waiting, and done states in Orca. Turn off to remove Orca-managed hooks and stop reinstalling them.'
export const AGENT_STATUS_HOOKS_SEARCH_KEYWORDS = [
'hooks',
@@ -11,8 +11,6 @@ export const AGENT_STATUS_HOOKS_SEARCH_KEYWORDS = [
'done',
'remove',
'restore',
'scoped',
'profile',
'settings',
'config',
'claude',
+3 -8
View File
@@ -1761,8 +1761,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
agent: tuiAgent,
prompt: startupPrompt,
cmdOverrides: settings?.agentCmdOverrides ?? {},
platform: CLIENT_PLATFORM,
useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false
platform: CLIENT_PLATFORM
})
// Why: thread agent_started telemetry through the queued startup so
@@ -1836,7 +1835,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
selectedRepoIsGit,
selectedRepoRequiresConnection,
settings?.agentCmdOverrides,
settings?.agentStatusHooksEnabled,
setSidebarOpen,
setupDecision,
sparseEnabled,
@@ -1980,8 +1978,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
agent,
draft: quickDraftPrompt,
cmdOverrides: settings?.agentCmdOverrides ?? {},
platform: CLIENT_PLATFORM,
useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false
platform: CLIENT_PLATFORM
})
let startupPlan: ReturnType<typeof buildAgentStartupPlan> = null
@@ -1999,8 +1996,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
prompt: quickPrompt,
cmdOverrides: settings?.agentCmdOverrides ?? {},
platform: CLIENT_PLATFORM,
allowEmptyPromptLaunch: true,
useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false
allowEmptyPromptLaunch: true
})
if (startupPlan && quickDraftPrompt) {
startupPlan.draftPrompt = quickDraftPrompt
@@ -2077,7 +2073,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
selectedRepoIsGit,
selectedRepoRequiresConnection,
settings?.agentCmdOverrides,
settings?.agentStatusHooksEnabled,
setSidebarOpen,
setupDecision,
sparseEnabled,
@@ -18,8 +18,6 @@ const mockSubscribeToPtyExit = vi.fn()
const mockPasteDraftWhenAgentReady = vi.fn()
const mockMarkTrusted = vi.fn()
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
const CLAUDE_SCOPED_SETTINGS =
'claude --settings "$HOME/.orca/agent-hooks/claude-agent-status-settings.json"'
function expectStablePaneSpawn(): string {
const spawnArgs = mockSpawn.mock.calls[0]?.[0]
@@ -124,7 +122,7 @@ describe('launchAgentBackgroundSession', () => {
expect(mockSpawn).toHaveBeenCalledWith(
expect.objectContaining({
cwd: '/repo/worktree',
command: `${CLAUDE_SCOPED_SETTINGS} 'run the automation'`,
command: "claude 'run the automation'",
env: expect.objectContaining({
ORCA_TAB_ID: 'tab-1',
ORCA_WORKTREE_ID: 'wt-1'
@@ -269,10 +267,7 @@ describe('launchAgentBackgroundSession', () => {
dataSidecar('user@remote repo % ')
vi.advanceTimersByTime(50)
expect(mockWrite).toHaveBeenCalledWith(
'pty-1',
`${CLAUDE_SCOPED_SETTINGS} 'run the automation'\r`
)
expect(mockWrite).toHaveBeenCalledWith('pty-1', "claude 'run the automation'\r")
} finally {
vi.useRealTimers()
}
@@ -306,7 +301,7 @@ describe('launchAgentBackgroundSession', () => {
method: 'terminal.create',
params: expect.objectContaining({
worktree: 'wt-1',
command: `${CLAUDE_SCOPED_SETTINGS} 'run the automation'`,
command: "claude 'run the automation'",
env: expect.objectContaining({
ORCA_PANE_KEY: `tab-1:${leafId}`,
ORCA_TAB_ID: 'tab-1',
@@ -63,7 +63,6 @@ export async function launchAgentBackgroundSession(
}
}
const cmdOverrides = store.settings?.agentCmdOverrides ?? {}
const useOrcaAgentStatusHooks = store.settings?.agentStatusHooksEnabled !== false
const trimmedPrompt = prompt?.trim() ?? ''
const hasPrompt = trimmedPrompt.length > 0
const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start'
@@ -76,8 +75,7 @@ export async function launchAgentBackgroundSession(
prompt: '',
cmdOverrides,
platform: CLIENT_PLATFORM,
allowEmptyPromptLaunch: true,
useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks
allowEmptyPromptLaunch: true
})
pasteDraftAfterLaunch = trimmedPrompt
} else {
@@ -86,8 +84,7 @@ export async function launchAgentBackgroundSession(
prompt: hasPrompt ? trimmedPrompt : '',
cmdOverrides,
platform: CLIENT_PLATFORM,
allowEmptyPromptLaunch: !hasPrompt,
useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks
allowEmptyPromptLaunch: !hasPrompt
})
}
if (!startupPlan) {
@@ -72,7 +72,6 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
} = args
const store = useAppStore.getState()
const cmdOverrides = store.settings?.agentCmdOverrides ?? {}
const useOrcaAgentStatusHooks = store.settings?.agentStatusHooksEnabled !== false
const trimmedPrompt = prompt?.trim() ?? ''
const hasPrompt = trimmedPrompt.length > 0
const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start'
@@ -96,8 +95,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
prompt: '',
cmdOverrides,
platform: CLIENT_PLATFORM,
allowEmptyPromptLaunch: true,
useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks
allowEmptyPromptLaunch: true
})
pasteDraftAfterLaunch = trimmedPrompt
submitPastedPrompt = true
@@ -107,8 +105,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
agent,
draft: trimmedPrompt,
cmdOverrides,
platform: CLIENT_PLATFORM,
useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks
platform: CLIENT_PLATFORM
})
if (draftLaunchPlan) {
startupPlan = {
@@ -124,8 +121,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
prompt: '',
cmdOverrides,
platform: CLIENT_PLATFORM,
allowEmptyPromptLaunch: true,
useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks
allowEmptyPromptLaunch: true
})
pasteDraftAfterLaunch = trimmedPrompt
}
@@ -135,8 +131,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
prompt: '',
cmdOverrides,
platform: CLIENT_PLATFORM,
allowEmptyPromptLaunch: true,
useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks
allowEmptyPromptLaunch: true
})
pasteDraftAfterLaunch = trimmedPrompt
} else {
@@ -145,8 +140,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
prompt: hasPrompt ? trimmedPrompt : '',
cmdOverrides,
platform: CLIENT_PLATFORM,
allowEmptyPromptLaunch: !hasPrompt,
useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks
allowEmptyPromptLaunch: !hasPrompt
})
}
@@ -298,8 +298,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
agent: effectiveAgent,
draft: draftContent,
cmdOverrides: settings?.agentCmdOverrides ?? {},
platform: CLIENT_PLATFORM,
useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false
platform: CLIENT_PLATFORM
})
if (draftLaunchPlan) {
startupPlan = {
@@ -316,8 +315,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
prompt: '',
cmdOverrides: settings?.agentCmdOverrides ?? {},
platform: CLIENT_PLATFORM,
allowEmptyPromptLaunch: true,
useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false
allowEmptyPromptLaunch: true
})
}
@@ -29,8 +29,7 @@ export function buildOnboardingFolderAgentStartup(
prompt: '',
cmdOverrides: settings.agentCmdOverrides ?? {},
platform: getClientPlatform(),
allowEmptyPromptLaunch: true,
useOrcaClaudeAgentStatusSettings: settings.agentStatusHooksEnabled !== false
allowEmptyPromptLaunch: true
})
if (!startupPlan) {
return undefined
+1 -3
View File
@@ -97,9 +97,7 @@ function buildCreatedAgentReopenStartup(worktree: Worktree):
prompt: '',
cmdOverrides: useAppStore.getState().settings?.agentCmdOverrides ?? {},
platform: CLIENT_PLATFORM,
allowEmptyPromptLaunch: true,
useOrcaClaudeAgentStatusSettings:
useAppStore.getState().settings?.agentStatusHooksEnabled !== false
allowEmptyPromptLaunch: true
})
if (!startupPlan) {
return undefined
-19
View File
@@ -1,19 +0,0 @@
export const ORCA_CLAUDE_AGENT_STATUS_SETTINGS_ENV = 'ORCA_CLAUDE_AGENT_STATUS_SETTINGS'
export const ORCA_CLAUDE_AGENT_STATUS_SETTINGS_FILE = 'claude-agent-status-settings.json'
type ClaudeSettingsShell = 'posix' | 'powershell' | 'cmd'
export function appendOrcaClaudeAgentStatusSettings(
command: string,
shell: ClaudeSettingsShell
): string {
// Why: Claude's --settings is a per-process overlay. CLAUDE_CONFIG_DIR
// would fork auth/session state and normal external Claude launches.
if (shell === 'powershell') {
return `${command} --settings $Env:${ORCA_CLAUDE_AGENT_STATUS_SETTINGS_ENV}`
}
if (shell === 'cmd') {
return `${command} --settings "%${ORCA_CLAUDE_AGENT_STATUS_SETTINGS_ENV}%"`
}
return `${command} --settings "$HOME/.orca/agent-hooks/${ORCA_CLAUDE_AGENT_STATUS_SETTINGS_FILE}"`
}
+5 -45
View File
@@ -47,55 +47,16 @@ describe('tui agent startup plans', () => {
expect(plan?.launchCommand).toBe("codex 'fix it'")
})
it('does not inject Codex profile flags through the shared agent-status option', () => {
const plan = buildAgentStartupPlan({
agent: 'codex',
prompt: 'fix it',
cmdOverrides: {},
platform: 'linux',
useOrcaClaudeAgentStatusSettings: true
})
expect(plan?.launchCommand).toBe("codex 'fix it'")
expect(plan?.launchCommand).not.toContain('--profile')
expect(plan?.launchCommand).not.toContain('orca-agent-status')
})
it('launches Claude with the Orca settings file when agent status hooks are enabled', () => {
it('launches Claude without Orca settings injection', () => {
const plan = buildAgentStartupPlan({
agent: 'claude',
prompt: 'fix it',
cmdOverrides: {},
platform: 'linux',
useOrcaClaudeAgentStatusSettings: true
platform: 'linux'
})
expect(plan?.launchCommand).toBe(
'claude --settings "$HOME/.orca/agent-hooks/claude-agent-status-settings.json" \'fix it\''
)
})
it('uses the target shell syntax for Claude settings injection', () => {
expect(
buildAgentStartupPlan({
agent: 'claude',
prompt: 'fix it',
cmdOverrides: {},
platform: 'win32',
useOrcaClaudeAgentStatusSettings: true
})?.launchCommand
).toBe("claude --settings $Env:ORCA_CLAUDE_AGENT_STATUS_SETTINGS 'fix it'")
expect(
buildAgentStartupPlan({
agent: 'claude',
prompt: 'fix it',
cmdOverrides: {},
platform: 'win32',
shell: 'cmd',
useOrcaClaudeAgentStatusSettings: true
})?.launchCommand
).toBe('claude --settings "%ORCA_CLAUDE_AGENT_STATUS_SETTINGS%" "fix it"')
expect(plan?.launchCommand).toBe("claude 'fix it'")
expect(plan?.launchCommand).not.toContain('--settings')
})
it('leaves Claude command overrides untouched', () => {
@@ -103,8 +64,7 @@ describe('tui agent startup plans', () => {
agent: 'claude',
prompt: 'fix it',
cmdOverrides: { claude: 'claude --dangerously-skip-permissions' },
platform: 'linux',
useOrcaClaudeAgentStatusSettings: true
platform: 'linux'
})
expect(plan?.launchCommand).toBe("claude --dangerously-skip-permissions 'fix it'")
+2 -11
View File
@@ -1,5 +1,4 @@
import { isShellProcess } from './agent-detection'
import { appendOrcaClaudeAgentStatusSettings } from './claude-settings'
import { TUI_AGENT_CONFIG } from './tui-agent-config'
import type { TuiAgent } from './types'
@@ -49,16 +48,12 @@ function resolveBaseCommand(args: {
agent: TuiAgent
cmdOverrides: Partial<Record<TuiAgent, string>>
shell: AgentStartupShell
useOrcaClaudeAgentStatusSettings?: boolean
}): string {
const override = args.cmdOverrides[args.agent]
if (override) {
return override
}
const command = TUI_AGENT_CONFIG[args.agent].launchCmd
if (args.agent === 'claude' && args.useOrcaClaudeAgentStatusSettings) {
return appendOrcaClaudeAgentStatusSettings(command, args.shell)
}
// Why: Codex status hooks live in Orca's runtime CODEX_HOME; adding
// --profile-v2 makes Codex load a second hook representation and warn.
return command
@@ -71,7 +66,6 @@ export function buildAgentStartupPlan(args: {
platform: NodeJS.Platform
shell?: AgentStartupShell
allowEmptyPromptLaunch?: boolean
useOrcaClaudeAgentStatusSettings?: boolean
}): AgentStartupPlan | null {
const { agent, prompt, cmdOverrides, platform, allowEmptyPromptLaunch = false } = args
const shell = resolveStartupShell(platform, args.shell)
@@ -80,8 +74,7 @@ export function buildAgentStartupPlan(args: {
const baseCommand = resolveBaseCommand({
agent,
cmdOverrides,
shell,
useOrcaClaudeAgentStatusSettings: args.useOrcaClaudeAgentStatusSettings
shell
})
if (!trimmedPrompt) {
@@ -155,7 +148,6 @@ export function buildAgentDraftLaunchPlan(args: {
cmdOverrides: Partial<Record<TuiAgent, string>>
platform: NodeJS.Platform
shell?: AgentStartupShell
useOrcaClaudeAgentStatusSettings?: boolean
}): AgentDraftLaunchPlan | null {
const { agent, draft, cmdOverrides, platform } = args
const shell = resolveStartupShell(platform, args.shell)
@@ -167,8 +159,7 @@ export function buildAgentDraftLaunchPlan(args: {
const baseCommand = resolveBaseCommand({
agent,
cmdOverrides,
shell,
useOrcaClaudeAgentStatusSettings: args.useOrcaClaudeAgentStatusSettings
shell
})
if (config.draftPromptFlag) {
const quoted = quoteStartupArg(trimmed, shell)