Support agent status hooks over SSH (#1865)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-14 16:06:23 -07:00 committed by GitHub
parent dcf5bf0dce
commit 2cadcbc4e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 2829 additions and 158 deletions

View File

@ -1,6 +1,10 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["../src/relay/**/*"],
"include": [
"../src/relay/**/*",
"../src/main/pty/overlay-mirror.ts",
"../src/main/pty/shell-startup-env.ts"
],
"exclude": ["../src/relay/integration.test.ts"],
"compilerOptions": {
"composite": true,

View File

@ -0,0 +1,269 @@
import { describe, expect, it } from 'vitest'
import type { SFTPWrapper } from 'ssh2'
import {
readHooksJsonRemote,
writeHooksJsonRemote,
writeManagedScriptRemote,
writeTextFileRemoteAtomic
} from './installer-utils-remote'
type FakeFs = {
files: Map<string, string>
dirs: Set<string>
modes: Map<string, number>
openSshRenameCount: number
}
function createFakeSftp(
opts: {
plainRenameOverwrites?: boolean
openSshRename?: boolean
failDotFileWrites?: boolean
} = {}
): {
sftp: SFTPWrapper
fs: FakeFs
} {
const plainRenameOverwrites = opts.plainRenameOverwrites ?? true
const fs: FakeFs = {
files: new Map(),
dirs: new Set(['/']),
modes: new Map(),
openSshRenameCount: 0
}
const noEntryError = (path: string): { code: number; message: string } => ({
code: 2,
message: `ENOENT ${path}`
})
const fakeStats = (mode: number): { mode: number } => ({ mode })
const sftp = {
readFile: (path: string, _enc: string, cb: (err: unknown, data?: string) => void): void => {
const v = fs.files.get(path)
if (v === undefined) {
cb(noEntryError(path))
return
}
cb(null, v)
},
writeFile: (
path: string,
content: string,
options: string | { mode?: number },
cb: (err: unknown) => void
): void => {
if (opts.failDotFileWrites && path.includes('/.')) {
cb({ code: 4, message: `write failed ${path}` })
return
}
fs.files.set(path, content)
if (typeof options !== 'string' && options.mode !== undefined) {
fs.modes.set(path, options.mode)
}
cb(null)
},
rename: (src: string, dst: string, cb: (err: unknown) => void): void => {
const v = fs.files.get(src)
if (v === undefined) {
cb(noEntryError(src))
return
}
if (!plainRenameOverwrites && fs.files.has(dst)) {
cb({ code: 4, message: `SSH_FX_FAILURE destination exists ${dst}` })
return
}
fs.files.set(dst, v)
fs.files.delete(src)
const mode = fs.modes.get(src)
if (mode !== undefined) {
fs.modes.set(dst, mode)
fs.modes.delete(src)
}
cb(null)
},
unlink: (path: string, cb: (err: unknown) => void): void => {
if (!fs.files.has(path)) {
cb(noEntryError(path))
return
}
fs.files.delete(path)
fs.modes.delete(path)
cb(null)
},
chmod: (path: string, mode: number, cb: (err: unknown) => void): void => {
fs.modes.set(path, mode)
cb(null)
},
stat: (path: string, cb: (err: unknown, stats?: { mode: number }) => void): void => {
if (!fs.files.has(path)) {
cb(noEntryError(path))
return
}
cb(null, fakeStats(fs.modes.get(path) ?? 0o100644))
},
readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => {
if (fs.dirs.has(path)) {
cb(null, [])
return
}
cb(noEntryError(path))
},
mkdir: (path: string, cb: (err: unknown) => void): void => {
fs.dirs.add(path)
cb(null)
},
...(opts.openSshRename
? {
ext_openssh_rename: (src: string, dst: string, cb: (err: unknown) => void): void => {
fs.openSshRenameCount += 1
const v = fs.files.get(src)
if (v === undefined) {
cb(noEntryError(src))
return
}
fs.files.set(dst, v)
fs.files.delete(src)
const mode = fs.modes.get(src)
if (mode !== undefined) {
fs.modes.set(dst, mode)
fs.modes.delete(src)
}
cb(null)
}
}
: {})
} as unknown as SFTPWrapper
return { sftp, fs }
}
describe('installer-utils-remote', () => {
it('returns {} when settings.json does not exist on the remote', async () => {
const { sftp } = createFakeSftp()
const result = await readHooksJsonRemote(sftp, '/home/u/.claude/settings.json')
expect(result).toEqual({})
})
it('returns null when settings.json is malformed JSON', async () => {
const { sftp, fs } = createFakeSftp()
fs.files.set('/home/u/.claude/settings.json', 'not json {{')
const result = await readHooksJsonRemote(sftp, '/home/u/.claude/settings.json')
expect(result).toBeNull()
})
it('rethrows non-ENOENT read errors so callers can distinguish I/O failures from parse failures', async () => {
const sftp = {
readFile: (_path: string, _enc: string, cb: (err: unknown) => void): void => {
// Why: SSH_FX_PERMISSION_DENIED (3) is a real I/O failure that should
// not collapse into the same null result the parse-error path uses.
cb({ code: 3, message: 'permission denied' })
}
} as unknown as SFTPWrapper
await expect(readHooksJsonRemote(sftp, '/home/u/.claude/settings.json')).rejects.toMatchObject({
code: 3
})
})
it('atomically writes settings.json via tmp + rename', async () => {
const { sftp, fs } = createFakeSftp()
await writeHooksJsonRemote(sftp, '/home/u/.claude/settings.json', {
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'foo' }] }] }
})
expect(fs.files.has('/home/u/.claude/settings.json')).toBe(true)
expect(fs.dirs.has('/home/u/.claude')).toBe(true)
const contents = fs.files.get('/home/u/.claude/settings.json')!
const parsed = JSON.parse(contents)
expect(parsed.hooks.Stop[0].hooks[0].command).toBe('foo')
// Tmp must be cleaned up.
const tmp = Array.from(fs.files.keys()).find((k) => k.includes('.tmp'))
expect(tmp).toBeUndefined()
expect(fs.modes.get('/home/u/.claude/settings.json')).toBe(0o600)
})
it('preserves existing config file mode across atomic replacement', async () => {
const { sftp, fs } = createFakeSftp()
const path = '/home/u/.codex/config.toml'
fs.files.set(path, 'old')
fs.modes.set(path, 0o640)
await writeTextFileRemoteAtomic(sftp, path, 'new')
expect(fs.modes.get(path)).toBe(0o640)
})
it('uses OpenSSH overwrite rename when an atomic write updates an existing file', async () => {
const { sftp, fs } = createFakeSftp({
plainRenameOverwrites: false,
openSshRename: true
})
const path = '/home/u/.claude/settings.json'
fs.files.set(path, JSON.stringify({ hooks: {} }))
await writeHooksJsonRemote(sftp, path, {
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'new' }] }] }
})
expect(fs.openSshRenameCount).toBe(1)
expect(JSON.parse(fs.files.get(path)!).hooks.Stop[0].hooks[0].command).toBe('new')
})
it('leaves existing files intact when overwrite rename is unavailable', async () => {
const { sftp, fs } = createFakeSftp({ plainRenameOverwrites: false })
const path = '/home/u/.claude/settings.json'
fs.files.set(path, JSON.stringify({ hooks: { Stop: [] } }))
await expect(
writeHooksJsonRemote(sftp, path, {
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'fallback' }] }] }
})
).rejects.toMatchObject({ code: 4 })
expect(JSON.parse(fs.files.get(path)!).hooks.Stop).toEqual([])
expect(Array.from(fs.files.keys()).some((key) => key.includes('.tmp'))).toBe(false)
})
it('writes the managed script and chmods 0o755', async () => {
const { sftp, fs } = createFakeSftp()
await writeManagedScriptRemote(sftp, '/home/u/.orca/agent-hooks/claude-hook.sh', '#!/bin/sh\n')
expect(fs.files.get('/home/u/.orca/agent-hooks/claude-hook.sh')).toBe('#!/bin/sh\n')
expect(fs.modes.get('/home/u/.orca/agent-hooks/claude-hook.sh')).toBe(0o755)
})
it('replaces an existing managed script atomically via temp file rename', async () => {
const { sftp, fs } = createFakeSftp({
plainRenameOverwrites: false,
openSshRename: true
})
const path = '/home/u/.orca/agent-hooks/claude-hook.sh'
fs.files.set(path, 'old script')
await writeManagedScriptRemote(sftp, path, 'new script')
expect(fs.files.get(path)).toBe('new script')
expect(fs.modes.get(path)).toBe(0o755)
expect(Array.from(fs.files.keys()).some((key) => key.includes('.orca-backup-'))).toBe(false)
})
it('leaves the existing managed script intact when temp write fails', async () => {
const { sftp, fs } = createFakeSftp({ failDotFileWrites: true })
const path = '/home/u/.orca/agent-hooks/claude-hook.sh'
fs.files.set(path, 'old script')
await expect(writeManagedScriptRemote(sftp, path, 'new script')).rejects.toMatchObject({
code: 4
})
expect(fs.files.get(path)).toBe('old script')
})
it('skips a no-op write when contents already match', async () => {
const { sftp, fs } = createFakeSftp()
const path = '/home/u/.claude/settings.json'
await writeHooksJsonRemote(sftp, path, { hooks: {} })
const beforeKey = fs.files.get(path)
// Re-writing the same payload should produce the same content; there is
// no rename/tmp cycle visible to a downstream observer beyond the
// identical file body.
await writeHooksJsonRemote(sftp, path, { hooks: {} })
expect(fs.files.get(path)).toBe(beforeKey)
})
})

View File

@ -0,0 +1,385 @@
// Why: SFTP-backed equivalents of `installer-utils.ts` for the remote-install
// flow. Each function takes an `sftp` handle plus paths the agent CLI expects
// on the remote (e.g. `~/.claude/settings.json`). Lives in `agent-hooks/`
// because it shares the contract with the local installer (script body,
// hook-event shape, atomic-rename semantics) and any drift between them is
// exactly the bug we want to avoid.
//
// We deliberately keep the JSON merge logic in the existing
// `installer-utils.ts` and only swap fs primitives — the JSON shape and
// managed-command matching must stay identical to the local install.
//
// See docs/design/agent-status-over-ssh.md §8 (commit #8).
import { randomUUID } from 'crypto'
import type { SFTPWrapper, FileEntryWithStats } from 'ssh2'
import { isPlainObject, type HooksConfig } from './installer-utils'
const DEFAULT_REMOTE_CONFIG_MODE = 0o600
/** Read+JSON-parse a remote file. Returns `null` on parse failure (caller
* surfaces "could not parse" status to the UI), `{}` on missing file
* (matches local behavior first-install case). Rethrows on other I/O
* failures (permission denied, EIO, channel closed) so the caller can
* distinguish transient SFTP errors from a malformed-JSON case rather
* than collapsing both into a misleading "could not parse" diagnostic. */
export async function readHooksJsonRemote(
sftp: SFTPWrapper,
remotePath: string
): Promise<HooksConfig | null> {
let body: string
try {
body = await readFile(sftp, remotePath)
} catch (err) {
if (isNoEntryError(err)) {
return {}
}
throw err
}
try {
const parsed = JSON.parse(body)
return isPlainObject(parsed) ? parsed : null
} catch {
return null
}
}
/** Atomically write a JSON config to the remote write to a tmp path then
* rename, mirroring the local writeHooksJson contract. The .bak rotation is
* intentionally NOT carried over: the remote file is the user's, and a
* per-target backup convention belongs alongside the remote installer UI
* (out of scope for this commit). */
export async function writeHooksJsonRemote(
sftp: SFTPWrapper,
remotePath: string,
config: HooksConfig
): Promise<void> {
const dir = dirnamePosix(remotePath)
await mkdirpRemote(sftp, dir)
const serialized = `${JSON.stringify(config, null, 2)}\n`
// Why: skip the write when on-disk content is identical so repeated
// install() calls do not bump the file's mtime / inode unnecessarily.
try {
const existing = await readFile(sftp, remotePath)
if (existing === serialized) {
return
}
} catch {
// ENOENT or read error — fall through to the write below.
}
// Why: tmp + rename so a partial network drop mid-write does not leave a
// truncated settings.json that the agent CLI would refuse to load.
const tmp = `${dir}/.${Date.now()}-${randomUUID()}.tmp`
try {
const mode = await getRemoteFileModeOrDefault(sftp, remotePath, DEFAULT_REMOTE_CONFIG_MODE)
await writeFile(sftp, tmp, serialized, mode)
await chmod(sftp, tmp, mode)
await rename(sftp, tmp, remotePath)
} finally {
// Best-effort cleanup if rename failed.
try {
await unlink(sftp, tmp)
} catch {
// already gone or never created
}
}
}
/** Write the managed hook script to the remote and chmod 0o755. POSIX-only
* the relay deliberately does not support Windows-remote in v1 (see design
* doc §3 + §6). */
export async function writeManagedScriptRemote(
sftp: SFTPWrapper,
remotePath: string,
content: string
): Promise<void> {
const dir = dirnamePosix(remotePath)
await mkdirpRemote(sftp, dir)
try {
const existing = await readFile(sftp, remotePath)
if (existing === content) {
await chmod(sftp, remotePath, 0o755)
return
}
} catch {
// ENOENT or read error — fall through to the atomic write below.
}
// Why: existing configs may already invoke this script. Write/chmod a temp
// file first, then rename it into place so interrupted reinstalls do not
// leave the configured hook path truncated or non-executable.
const tmp = `${dir}/.${Date.now()}-${randomUUID()}.tmp`
try {
await writeFile(sftp, tmp, content, 0o755)
await chmod(sftp, tmp, 0o755)
await rename(sftp, tmp, remotePath)
} finally {
try {
await unlink(sftp, tmp)
} catch {
// already gone or never created
}
}
}
export async function readTextFileRemote(
sftp: SFTPWrapper,
remotePath: string
): Promise<string | null> {
try {
return await readFile(sftp, remotePath)
} catch (err) {
if (isNoEntryError(err)) {
return null
}
throw err
}
}
export async function writeTextFileRemoteAtomic(
sftp: SFTPWrapper,
remotePath: string,
content: string
): Promise<void> {
const dir = dirnamePosix(remotePath)
await mkdirpRemote(sftp, dir)
try {
const existing = await readFile(sftp, remotePath)
if (existing === content) {
return
}
} catch {
// ENOENT or read error — fall through to the atomic write below.
}
const tmp = `${dir}/.${Date.now()}-${randomUUID()}.tmp`
try {
const mode = await getRemoteFileModeOrDefault(sftp, remotePath, DEFAULT_REMOTE_CONFIG_MODE)
await writeFile(sftp, tmp, content, mode)
await chmod(sftp, tmp, mode)
await rename(sftp, tmp, remotePath)
} finally {
try {
await unlink(sftp, tmp)
} catch {
// already gone or never created
}
}
}
// ─── Private SFTP primitives ────────────────────────────────────────
async function readFile(sftp: SFTPWrapper, remotePath: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
sftp.readFile(remotePath, 'utf8', (err, data) => {
if (err) {
reject(err)
return
}
resolve(typeof data === 'string' ? data : data.toString('utf8'))
})
})
}
async function writeFile(
sftp: SFTPWrapper,
remotePath: string,
content: string,
mode?: number
): Promise<void> {
return new Promise((resolve, reject) => {
const options =
mode === undefined ? { encoding: 'utf8' as const } : { encoding: 'utf8' as const, mode }
sftp.writeFile(remotePath, content, options, (err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
}
async function statMode(sftp: SFTPWrapper, remotePath: string): Promise<number> {
return new Promise((resolve, reject) => {
sftp.stat(remotePath, (err, stats) => {
if (err) {
reject(err)
return
}
resolve(stats.mode & 0o7777)
})
})
}
async function getRemoteFileModeOrDefault(
sftp: SFTPWrapper,
remotePath: string,
defaultMode: number
): Promise<number> {
try {
return await statMode(sftp, remotePath)
} catch (err) {
if (isNoEntryError(err)) {
return defaultMode
}
throw err
}
}
async function rename(sftp: SFTPWrapper, src: string, dst: string): Promise<void> {
if (typeof sftp.ext_openssh_rename === 'function') {
try {
await renameOpenSsh(sftp, src, dst)
return
} catch (err) {
if (!isUnsupportedExtensionError(err)) {
throw err
}
}
}
// Why: servers without OpenSSH overwrite-rename cannot safely replace an
// existing live config path. Renaming dst aside would leave settings.json
// missing if the SFTP channel dies before src is moved into place, so fail
// closed and keep the existing file intact.
await renamePlain(sftp, src, dst)
}
async function renamePlain(sftp: SFTPWrapper, src: string, dst: string): Promise<void> {
return new Promise((resolve, reject) => {
sftp.rename(src, dst, (err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
}
async function renameOpenSsh(sftp: SFTPWrapper, src: string, dst: string): Promise<void> {
return new Promise((resolve, reject) => {
sftp.ext_openssh_rename(src, dst, (err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
}
async function unlink(sftp: SFTPWrapper, remotePath: string): Promise<void> {
return new Promise((resolve, reject) => {
sftp.unlink(remotePath, (err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
}
async function chmod(sftp: SFTPWrapper, remotePath: string, mode: number): Promise<void> {
return new Promise((resolve, reject) => {
sftp.chmod(remotePath, mode, (err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
}
async function readdir(sftp: SFTPWrapper, remotePath: string): Promise<FileEntryWithStats[]> {
return new Promise((resolve, reject) => {
sftp.readdir(remotePath, (err, list) => {
if (err) {
reject(err)
return
}
resolve(list)
})
})
}
async function mkdir(sftp: SFTPWrapper, remotePath: string): Promise<void> {
return new Promise((resolve, reject) => {
sftp.mkdir(remotePath, (err) => {
if (err) {
// SSH_FX_FAILURE (4) often means "already exists" on OpenSSH; we
// probe with stat afterwards rather than parse the error code.
reject(err)
return
}
resolve()
})
})
}
async function mkdirpRemote(sftp: SFTPWrapper, remotePath: string): Promise<void> {
if (remotePath === '/' || remotePath === '' || remotePath === '.') {
return
}
// Why: walk the path top-down rather than bottom-up so an existing parent
// chain doesn't cost a full readdir per segment. POSIX-only — Windows-
// remote is out of scope for v1.
const segments = remotePath.split('/').filter((s) => s.length > 0)
let current = remotePath.startsWith('/') ? '' : '.'
for (const seg of segments) {
current = current === '' ? `/${seg}` : current === '.' ? seg : `${current}/${seg}`
try {
await readdir(sftp, current)
} catch {
try {
await mkdir(sftp, current)
} catch (err) {
// Why: re-raise only when the dir really isn't there. SSH_FX_FAILURE
// on a concurrent mkdir from another client is harmless — readdir on
// the next iteration will succeed.
if (!isAlreadyExistsError(err)) {
throw err
}
}
}
}
}
function dirnamePosix(p: string): string {
const idx = p.lastIndexOf('/')
if (idx <= 0) {
return idx === 0 ? '/' : '.'
}
return p.slice(0, idx)
}
function isNoEntryError(err: unknown): boolean {
if (!err || typeof err !== 'object') {
return false
}
// ssh2 surfaces SFTP errors with `code === 2` (SSH_FX_NO_SUCH_FILE).
return (err as { code?: unknown }).code === 2
}
function isAlreadyExistsError(err: unknown): boolean {
if (!err || typeof err !== 'object') {
return false
}
// SSH_FX_FAILURE (4) is OpenSSH's catch-all for "exists" alongside other
// mkdir failures; we accept the ambiguity and let the next readdir prove
// success.
return (err as { code?: unknown }).code === 4
}
function isUnsupportedExtensionError(err: unknown): boolean {
if (!err || typeof err !== 'object') {
return false
}
const code = (err as { code?: unknown }).code
const message = (err as { message?: unknown }).message
return code === 8 || (typeof message === 'string' && /unsupported/i.test(message))
}

View File

@ -0,0 +1,224 @@
import { describe, expect, it, vi } from 'vitest'
import type { SFTPWrapper } from 'ssh2'
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/orca-user-data'
}
}))
import { CodexHookService } from '../codex/hook-service'
import { CursorHookService } from '../cursor/hook-service'
import { GeminiHookService } from '../gemini/hook-service'
import { ClaudeHookService } from '../claude/hook-service'
type FakeFs = {
files: Map<string, string>
dirs: Set<string>
modes: Map<string, number>
failRenameTo: Set<string>
}
function createFakeSftp(): { sftp: SFTPWrapper; fs: FakeFs } {
const fs: FakeFs = {
files: new Map(),
dirs: new Set(['/']),
modes: new Map(),
failRenameTo: new Set()
}
const noEntryError = (path: string): { code: number; message: string } => ({
code: 2,
message: `ENOENT ${path}`
})
const fakeStats = (mode: number): { mode: number } => ({ mode })
const sftp = {
readFile: (path: string, _enc: string, cb: (err: unknown, data?: string) => void): void => {
const v = fs.files.get(path)
if (v === undefined) {
cb(noEntryError(path))
return
}
cb(null, v)
},
writeFile: (
path: string,
content: string,
options: string | { mode?: number },
cb: (err: unknown) => void
): void => {
fs.files.set(path, content)
if (typeof options !== 'string' && options.mode !== undefined) {
fs.modes.set(path, options.mode)
}
cb(null)
},
rename: (src: string, dst: string, cb: (err: unknown) => void): void => {
if (fs.failRenameTo.has(dst)) {
cb({ code: 4, message: `rename failed ${dst}` })
return
}
const v = fs.files.get(src)
if (v === undefined) {
cb(noEntryError(src))
return
}
fs.files.set(dst, v)
fs.files.delete(src)
const mode = fs.modes.get(src)
if (mode !== undefined) {
fs.modes.set(dst, mode)
fs.modes.delete(src)
}
cb(null)
},
unlink: (path: string, cb: (err: unknown) => void): void => {
fs.files.delete(path)
fs.modes.delete(path)
cb(null)
},
chmod: (path: string, mode: number, cb: (err: unknown) => void): void => {
fs.modes.set(path, mode)
cb(null)
},
stat: (path: string, cb: (err: unknown, stats?: { mode: number }) => void): void => {
if (!fs.files.has(path)) {
cb(noEntryError(path))
return
}
cb(null, fakeStats(fs.modes.get(path) ?? 0o100644))
},
readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => {
if (fs.dirs.has(path)) {
cb(null, [])
return
}
cb(noEntryError(path))
},
mkdir: (path: string, cb: (err: unknown) => void): void => {
fs.dirs.add(path)
cb(null)
}
} as unknown as SFTPWrapper
return { sftp, fs }
}
describe('remote hook service installers', () => {
it('always writes POSIX scripts for SSH remotes even from a Windows host', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
try {
const installers = [
{
path: '/home/dev/.orca/agent-hooks/claude-hook.sh',
install: (sftp: SFTPWrapper) => new ClaudeHookService().installRemote(sftp, '/home/dev')
},
{
path: '/home/dev/.orca/agent-hooks/codex-hook.sh',
install: (sftp: SFTPWrapper) => new CodexHookService().installRemote(sftp, '/home/dev')
},
{
path: '/home/dev/.orca/agent-hooks/gemini-hook.sh',
install: (sftp: SFTPWrapper) => new GeminiHookService().installRemote(sftp, '/home/dev')
},
{
path: '/home/dev/.orca/agent-hooks/cursor-hook.sh',
install: (sftp: SFTPWrapper) => new CursorHookService().installRemote(sftp, '/home/dev')
}
]
for (const { install, path } of installers) {
const { sftp, fs } = createFakeSftp()
const status = await install(sftp)
expect(status.state).toBe('installed')
const script = fs.files.get(path)
expect(script).toMatch(/^#!\/bin\/sh\n/)
expect(script).not.toContain('@echo off')
expect(script).not.toContain('powershell -NoProfile')
}
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
}
})
it('installs remote Codex hooks with matching trust entries', async () => {
const { sftp, fs } = createFakeSftp()
const status = await new CodexHookService().installRemote(sftp, '/home/dev/')
expect(status.state).toBe('installed')
expect(status.configPath).toBe('/home/dev/.codex/hooks.json')
const hooks = JSON.parse(fs.files.get('/home/dev/.codex/hooks.json')!) as {
hooks: Record<string, { hooks: { command: string }[] }[]>
}
for (const eventName of [
'SessionStart',
'UserPromptSubmit',
'PreToolUse',
'PermissionRequest',
'PostToolUse',
'Stop'
]) {
const command = hooks.hooks[eventName]?.[0]?.hooks?.[0]?.command
expect(command).toContain('/home/dev/.orca/agent-hooks/codex-hook.sh')
expect(command).toMatch(/^if \[ -x /)
}
expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh')
expect(fs.modes.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toBe(0o755)
const toml = fs.files.get('/home/dev/.codex/config.toml')
expect(toml).toContain('/home/dev/.codex/hooks.json:permission_request:0:0')
expect(toml).toContain('trusted_hash = "sha256:')
})
it('reports Codex trust-write failures without rolling back installed hooks', async () => {
const { sftp, fs } = createFakeSftp()
fs.failRenameTo.add('/home/dev/.codex/config.toml')
const status = await new CodexHookService().installRemote(sftp, '/home/dev')
expect(status.state).toBe('error')
expect(status.managedHooksPresent).toBe(true)
expect(status.detail).toContain('trust entries could not be written')
expect(fs.files.get('/home/dev/.codex/hooks.json')).toContain('codex-hook.sh')
expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh')
})
it('installs remote Gemini and Cursor configs using their CLI-specific schemas', async () => {
const gemini = createFakeSftp()
const cursor = createFakeSftp()
await new GeminiHookService().installRemote(gemini.sftp, '/home/dev')
await new CursorHookService().installRemote(cursor.sftp, '/home/dev')
const geminiConfig = JSON.parse(gemini.fs.files.get('/home/dev/.gemini/settings.json')!) as {
hooks: Record<string, { hooks: { command: string }[] }[]>
}
for (const eventName of ['BeforeAgent', 'AfterAgent', 'AfterTool', 'PreToolUse']) {
const command = geminiConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command
expect(command).toContain('/home/dev/.orca/agent-hooks/gemini-hook.sh')
expect(command).toMatch(/^if \[ -x /)
}
const cursorConfig = JSON.parse(cursor.fs.files.get('/home/dev/.cursor/hooks.json')!) as {
version: number
hooks: Record<string, { command?: string; hooks?: unknown[] }[]>
}
expect(cursorConfig.version).toBe(1)
for (const eventName of [
'beforeSubmitPrompt',
'stop',
'preToolUse',
'postToolUse',
'postToolUseFailure',
'beforeShellExecution',
'beforeMCPExecution',
'afterAgentResponse'
]) {
const definition = cursorConfig.hooks[eventName]?.[0]
expect(definition?.command).toContain('/home/dev/.orca/agent-hooks/cursor-hook.sh')
expect(definition?.hooks).toBeUndefined()
}
})
})

View File

@ -709,6 +709,14 @@ export class AgentHookServer {
}
}
}
/** Test-only accessor for the per-instance listener state. The `_internals`
* shim needs to reach this without exposing `state` on the public surface
* to renderer/main callers. AGENTS.md disallows `as unknown as X` escapes,
* so we expose a narrow getter rather than casting the private field. */
_getStateForTests(): HookListenerState {
return this.state
}
}
export const agentHookServer = new AgentHookServer()
@ -722,19 +730,9 @@ export const _internals = {
body: unknown,
expectedEnv: string
): AgentHookEventPayload | null =>
normalizeHookPayload(_singletonState(), source, body, expectedEnv),
normalizeHookPayload(agentHookServer._getStateForTests(), source, body, expectedEnv),
parseFormEncodedBody,
resetCachesForTests: (): void => {
clearAllListenerCaches(_singletonState())
clearAllListenerCaches(agentHookServer._getStateForTests())
}
}
// Why: ergonomic accessor so the `_internals` shim can reach the singleton's
// per-instance state without exposing `state` on the public class surface.
function _singletonState(): HookListenerState {
// The runtime field is private, but tests access this module exclusively
// through `_internals`, which only fires after the module-level
// `agentHookServer` is constructed. The cast keeps the compile-time
// private invariant intact.
return (agentHookServer as unknown as { state: HookListenerState }).state
}

View File

@ -0,0 +1,166 @@
// Why: locks in the remote-install contract so a refactor cannot silently
// drift the produced settings.json shape, the wrapper-quoted command path,
// or the script body that lands on the remote box. Local install behavior
// is exercised through `installer-utils.test.ts` and the per-CLI status
// audit; this file covers ONLY the SFTP-backed path added in commit #8.
import { vi, describe, expect, it } from 'vitest'
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/userData'
}
}))
import type { SFTPWrapper } from 'ssh2'
import { ClaudeHookService } from './hook-service'
type FakeFs = {
files: Map<string, string>
dirs: Set<string>
modes: Map<string, number>
}
function createFakeSftp(): { sftp: SFTPWrapper; fs: FakeFs } {
const fs: FakeFs = {
files: new Map(),
dirs: new Set(['/']),
modes: new Map()
}
const noEntryError = (path: string): { code: number; message: string } => ({
code: 2,
message: `ENOENT ${path}`
})
const fakeStats = (mode: number): { mode: number } => ({ mode })
const sftp = {
readFile: (path: string, _enc: string, cb: (err: unknown, data?: string) => void): void => {
const v = fs.files.get(path)
if (v === undefined) {
cb(noEntryError(path))
return
}
cb(null, v)
},
writeFile: (
path: string,
content: string,
options: string | { mode?: number },
cb: (err: unknown) => void
): void => {
fs.files.set(path, content)
if (typeof options !== 'string' && options.mode !== undefined) {
fs.modes.set(path, options.mode)
}
cb(null)
},
rename: (src: string, dst: string, cb: (err: unknown) => void): void => {
const v = fs.files.get(src)
if (v === undefined) {
cb(noEntryError(src))
return
}
fs.files.set(dst, v)
fs.files.delete(src)
const mode = fs.modes.get(src)
if (mode !== undefined) {
fs.modes.set(dst, mode)
fs.modes.delete(src)
}
cb(null)
},
unlink: (path: string, cb: (err: unknown) => void): void => {
fs.files.delete(path)
fs.modes.delete(path)
cb(null)
},
chmod: (path: string, mode: number, cb: (err: unknown) => void): void => {
fs.modes.set(path, mode)
cb(null)
},
stat: (path: string, cb: (err: unknown, stats?: { mode: number }) => void): void => {
if (!fs.files.has(path)) {
cb(noEntryError(path))
return
}
cb(null, fakeStats(fs.modes.get(path) ?? 0o100644))
},
readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => {
if (fs.dirs.has(path)) {
cb(null, [])
return
}
cb(noEntryError(path))
},
mkdir: (path: string, cb: (err: unknown) => void): void => {
fs.dirs.add(path)
cb(null)
}
} as unknown as SFTPWrapper
return { sftp, fs }
}
describe('ClaudeHookService.installRemote', () => {
it('writes settings.json + 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/.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
// remote-shaped script path with the `if [ -x ... ]; then ... fi`
// wrapper applied. Drift in any of these is a real bug — Claude
// Code rejects unknown shapes silently and the agent-hooks pipeline
// goes dark.
for (const event of [
'UserPromptSubmit',
'Stop',
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',
'PermissionRequest'
]) {
expect(parsed.hooks[event]).toBeTruthy()
const cmd = parsed.hooks[event][0].hooks[0].command as string
expect(cmd).toContain('/home/dev/.orca/agent-hooks/claude-hook.sh')
expect(cmd).toMatch(/^if \[ -x /)
}
// 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)
})
it('reports parse error when remote settings.json is malformed', 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.detail).toContain('Could not parse')
})
it('preserves user-authored hook entries on a fresh install', async () => {
const svc = new ClaudeHookService()
const { sftp, fs } = createFakeSftp()
fs.files.set(
'/home/dev/.claude/settings.json',
JSON.stringify({
hooks: {
Stop: [
{
hooks: [{ type: 'command', command: '/usr/local/bin/my-user-hook' }]
}
]
}
})
)
await svc.installRemote(sftp, '/home/dev')
const parsed = JSON.parse(fs.files.get('/home/dev/.claude/settings.json')!)
// Original user-authored entry survives alongside the new managed entry.
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(true)
})
})

View File

@ -1,5 +1,6 @@
import { homedir } from 'os'
import { join } from 'path'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
createManagedCommandMatcher,
@ -11,6 +12,11 @@ import {
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
import {
readHooksJsonRemote,
writeHooksJsonRemote,
writeManagedScriptRemote
} from '../agent-hooks/installer-utils-remote'
const CLAUDE_EVENTS = [
{ eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } },
@ -61,8 +67,8 @@ function getManagedCommand(scriptPath: string): string {
return wrapPosixHookCommand(scriptPath)
}
function getManagedScript(): string {
if (process.platform === 'win32') {
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
if (target === 'local' && process.platform === 'win32') {
return [
'@echo off',
'setlocal',
@ -215,6 +221,83 @@ export class ClaudeHookService {
return this.getStatus()
}
// Why: install Orca's managed Claude hooks 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
// ~/.claude/settings.json on the target). POSIX-only by design — see
// docs/design/agent-status-over-ssh.md §3 / §6 (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
// (`~/.claude/settings.json`) 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 = `${remoteHome.replace(/\/$/, '')}/.claude/settings.json`
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
// install flow in try/catch so a transient I/O failure surfaces as a
// structured `state: 'error'` result for the UI, not an unstructured
// rejection the caller has to remember to handle. A `null` config
// specifically means "file present but unparseable" — keep that branch
// distinct so the user sees an actionable message.
try {
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.
const command = wrapPosixHookCommand(remoteScriptPath)
const nextHooks = { ...config.hooks }
const isManagedCommand = createManagedCommandMatcher('claude-hook.sh')
for (const event of CLAUDE_EVENTS) {
const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : []
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
...event.definition,
hooks: [{ type: 'command', command }]
}
nextHooks[event.eventName] = [...cleaned, definition]
}
config.hooks = nextHooks
// Why: write the script first, then the settings — settings.json
// referencing a missing script body would fire `command not found` on
// every tool call until the user re-runs install. Doing it in this
// order means a partial-failure mid-install at worst leaves the user
// with a working script no settings.json points at (a no-op), instead
// of broken settings.json.
// Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is
// running on Windows; never derive remote script syntax from local OS.
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
await writeHooksJsonRemote(sftp, remoteConfigPath, config)
return {
agent: 'claude',
state: 'installed',
configPath: remoteConfigPath,
managedHooksPresent: true,
detail: null
}
} catch (err) {
return {
agent: 'claude',
state: 'error',
configPath: remoteConfigPath,
managedHooksPresent: false,
detail: err instanceof Error ? err.message : String(err)
}
}
}
remove(): AgentHookInstallStatus {
const configPath = getConfigPath()
const config = readHooksJson(configPath)

View File

@ -190,16 +190,26 @@ export function upsertHookTrustEntries(
entries: readonly CodexTrustEntry[]
): void {
const existing = existsSync(configPath) ? readTomlFile(configPath) : ''
let updated = existing
for (const entry of entries) {
updated = upsertTrustBlock(updated, computeTrustKey(entry), computeTrustedHash(entry))
}
const updated = upsertHookTrustEntriesInContent(existing, entries)
if (updated === existing) {
return
}
writeConfigAtomically(configPath, updated)
}
export function upsertHookTrustEntriesInContent(
existingContent: string,
entries: readonly CodexTrustEntry[]
): string {
const existing =
existingContent.charCodeAt(0) === 0xfeff ? existingContent.slice(1) : existingContent
let updated = existing
for (const entry of entries) {
updated = upsertTrustBlock(updated, computeTrustKey(entry), computeTrustedHash(entry))
}
return updated
}
// Why: build the canonical block we own. The two field names mirror what
// Codex itself writes when the user approves via /hooks (HookStateToml
// fields). `enabled` is plumbed through so an existing user-set

View File

@ -1,6 +1,7 @@
/* eslint-disable max-lines -- Why: getStatus + install + remove all share the managed-command and trust-key derivation. Splitting would hide that the three operations must agree on group index, event label, and command bytes. */
import { homedir } from 'os'
import { join } from 'path'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
createManagedCommandMatcher,
@ -12,12 +13,20 @@ import {
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
import {
readHooksJsonRemote,
readTextFileRemote,
writeHooksJsonRemote,
writeManagedScriptRemote,
writeTextFileRemoteAtomic
} from '../agent-hooks/installer-utils-remote'
import {
computeTrustKey,
computeTrustedHash,
parseTrustKey,
readHookTrustEntries,
removeHookTrustEntries,
upsertHookTrustEntriesInContent,
upsertHookTrustEntries,
type CodexEventLabel,
type CodexHookTrustState,
@ -71,8 +80,8 @@ function getManagedCommand(scriptPath: string): string {
return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath)
}
function getManagedScript(): string {
if (process.platform === 'win32') {
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
if (target === 'local' && process.platform === 'win32') {
return [
'@echo off',
'setlocal',
@ -329,6 +338,99 @@ export class CodexHookService {
return this.getStatus()
}
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.codex/hooks.json`
const remoteTomlPath = `${remoteHome.replace(/\/$/, '')}/.codex/config.toml`
const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh`
try {
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
if (!config) {
return {
agent: 'codex',
state: 'error',
configPath: remoteConfigPath,
managedHooksPresent: false,
detail: 'Could not parse remote Codex hooks.json'
}
}
const command = wrapPosixHookCommand(remoteScriptPath)
const nextHooks = { ...config.hooks }
const managedEvents = new Set<string>(CODEX_EVENTS)
const isManagedCommand = createManagedCommandMatcher('codex-hook.sh')
for (const [eventName, definitions] of Object.entries(nextHooks)) {
if (managedEvents.has(eventName) || !Array.isArray(definitions)) {
continue
}
const cleaned = removeManagedCommands(definitions, isManagedCommand)
if (cleaned.length === 0) {
delete nextHooks[eventName]
} else {
nextHooks[eventName] = cleaned
}
}
const trustEntries: CodexTrustEntry[] = []
for (const eventName of CODEX_EVENTS) {
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
hooks: [{ type: 'command', command }]
}
nextHooks[eventName] = [...cleaned, definition]
trustEntries.push({
sourcePath: remoteConfigPath,
eventLabel: CODEX_EVENT_LABEL[eventName],
groupIndex: cleaned.length,
handlerIndex: 0,
command
})
}
config.hooks = nextHooks
// Why: script/settings first, trust TOML last. A partial trust write
// leaves Codex asking for approval rather than executing a missing script.
// Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is
// running on Windows; never derive remote script syntax from local OS.
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
await writeHooksJsonRemote(sftp, remoteConfigPath, config)
try {
const existingToml = (await readTextFileRemote(sftp, remoteTomlPath)) ?? ''
const updatedToml = upsertHookTrustEntriesInContent(existingToml, trustEntries)
if (updatedToml !== existingToml) {
await writeTextFileRemoteAtomic(sftp, remoteTomlPath, updatedToml)
}
} catch (error) {
return {
agent: 'codex',
state: 'error',
configPath: remoteConfigPath,
managedHooksPresent: true,
detail: `Hooks installed but trust entries could not be written: ${
error instanceof Error ? error.message : String(error)
}. Run /hooks in Codex on the remote host to approve.`
}
}
return {
agent: 'codex',
state: 'installed',
configPath: remoteConfigPath,
managedHooksPresent: true,
detail: null
}
} catch (err) {
return {
agent: 'codex',
state: 'error',
configPath: remoteConfigPath,
managedHooksPresent: false,
detail: err instanceof Error ? err.message : String(err)
}
}
}
remove(): AgentHookInstallStatus {
const configPath = getConfigPath()
const config = readHooksJson(configPath)

View File

@ -1,5 +1,6 @@
import { homedir } from 'os'
import { join } from 'path'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
createManagedCommandMatcher,
@ -11,6 +12,11 @@ import {
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
import {
readHooksJsonRemote,
writeHooksJsonRemote,
writeManagedScriptRemote
} from '../agent-hooks/installer-utils-remote'
// Why: cursor-agent exposes a declarative hooks.json surface at
// ~/.cursor/hooks.json (https://cursor.com/docs/hooks) with camelCase event
@ -55,8 +61,8 @@ function getManagedCommand(scriptPath: string): string {
return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath)
}
function getManagedScript(): string {
if (process.platform === 'win32') {
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
if (target === 'local' && process.platform === 'win32') {
return [
'@echo off',
'setlocal',
@ -235,6 +241,73 @@ export class CursorHookService {
return this.getStatus()
}
// Why: install Orca's managed Cursor hooks on the remote box. Mirrors
// ClaudeHookService.installRemote — POSIX-only, uses the same SFTP-backed
// primitives, and emits Cursor's documented schema (top-level `command`
// on each definition + top-level `version: 1`) so cursor-agent on the
// remote actually invokes the script. See docs/design/agent-status-over-ssh.md
// §8.
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.cursor/hooks.json`
const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/cursor-hook.sh`
try {
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
if (!config) {
return {
agent: 'cursor',
state: 'error',
configPath: remoteConfigPath,
managedHooksPresent: false,
detail: 'Could not parse remote Cursor hooks.json'
}
}
const command = wrapPosixHookCommand(remoteScriptPath)
const nextHooks = { ...config.hooks }
const isManagedCommand = createManagedCommandMatcher('cursor-hook.sh')
for (const eventName of CURSOR_EVENTS) {
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
// Why: same dual-shape sweep as the local install — repeated
// installs converge on a single managed entry.
const cleaned = removeManagedCommands(current, isManagedCommand).filter(
(definition) => !isManagedCommand(definition.command as string | undefined)
)
const definition: HookDefinition = { command }
nextHooks[eventName] = [...cleaned, definition]
}
const nextConfig: Record<string, unknown> = { ...config, hooks: nextHooks }
if (nextConfig.version === undefined) {
nextConfig.version = 1
}
// Why: script-then-config order so a partial-failure mid-install at
// worst leaves a working script no settings.json points at — see
// ClaudeHookService.installRemote.
// Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is
// running on Windows; never derive remote script syntax from local OS.
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig)
return {
agent: 'cursor',
state: 'installed',
configPath: remoteConfigPath,
managedHooksPresent: true,
detail: null
}
} catch (err) {
return {
agent: 'cursor',
state: 'error',
configPath: remoteConfigPath,
managedHooksPresent: false,
detail: err instanceof Error ? err.message : String(err)
}
}
}
remove(): AgentHookInstallStatus {
const configPath = getConfigPath()
const config = readHooksJson(configPath)

View File

@ -1,5 +1,6 @@
import { homedir } from 'os'
import { join } from 'path'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
createManagedCommandMatcher,
@ -11,6 +12,11 @@ import {
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
import {
readHooksJsonRemote,
writeHooksJsonRemote,
writeManagedScriptRemote
} from '../agent-hooks/installer-utils-remote'
// Why: Gemini CLI fires `BeforeAgent` when a turn starts and `AfterAgent` when
// it completes. `AfterTool` marks the resumption of model work after a tool
@ -41,8 +47,8 @@ function getManagedCommand(scriptPath: string): string {
return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath)
}
function getManagedScript(): string {
if (process.platform === 'win32') {
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
if (target === 'local' && process.platform === 'win32') {
return [
'@echo off',
'setlocal',
@ -183,6 +189,65 @@ export class GeminiHookService {
return this.getStatus()
}
// Why: install Orca's managed Gemini hooks on the remote box. Mirrors
// ClaudeHookService.installRemote — POSIX-only, uses the same SFTP-backed
// primitives, and lays down the same script body the local install
// generates so a remote-side Gemini CLI behaves identically. See
// docs/design/agent-status-over-ssh.md §8.
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.gemini/settings.json`
const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/gemini-hook.sh`
try {
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
if (!config) {
return {
agent: 'gemini',
state: 'error',
configPath: remoteConfigPath,
managedHooksPresent: false,
detail: 'Could not parse remote Gemini settings.json'
}
}
const command = wrapPosixHookCommand(remoteScriptPath)
const nextHooks = { ...config.hooks }
const isManagedCommand = createManagedCommandMatcher('gemini-hook.sh')
for (const eventName of GEMINI_EVENTS) {
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
hooks: [{ type: 'command', command }]
}
nextHooks[eventName] = [...cleaned, definition]
}
config.hooks = nextHooks
// Why: write the script first so an interrupted install never leaves
// settings.json pointing at a missing script. See ClaudeHookService.
// Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is
// running on Windows; never derive remote script syntax from local OS.
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
await writeHooksJsonRemote(sftp, remoteConfigPath, config)
return {
agent: 'gemini',
state: 'installed',
configPath: remoteConfigPath,
managedHooksPresent: true,
detail: null
}
} catch (err) {
return {
agent: 'gemini',
state: 'error',
configPath: remoteConfigPath,
managedHooksPresent: false,
detail: err instanceof Error ? err.message : String(err)
}
}
}
remove(): AgentHookInstallStatus {
const configPath = getConfigPath()
const config = readHooksJson(configPath)

View File

@ -1277,6 +1277,9 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_TAB_ID).toBeUndefined()
expect(env.ORCA_WORKTREE_ID).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined()
// Why: the local hook server's userData-relative endpoint file path
// is meaningless on the remote box; assert it does not leak.
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
} finally {
if (prevFlag === undefined) {
delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
@ -1336,6 +1339,7 @@ describe('registerPtyHandlers', () => {
// relay is the source of truth for those.
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
} finally {
if (prevFlag === undefined) {
delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS

View File

@ -1,8 +1,8 @@
// Why: Pi (PI_CODING_AGENT_DIR) and OpenCode (OPENCODE_CONFIG_DIR) both inject
// Orca-owned files into per-PTY overlay directories that mirror a user-owned
// source dir via symlinks/junctions. The safety guarantees here never
// source dir via symlinks/junctions. The safety guarantees here -- never
// descend into a symlink/junction during teardown, refuse to operate outside
// the overlay root, lstat-not-stat to avoid following links are the result
// the overlay root, lstat-not-stat to avoid following links -- are the result
// of debugging issue #1083 (Windows directory junctions causing fs.rmSync to
// delete the user's real Pi state). Shared in one module so a new overlay
// consumer cannot accidentally diverge from the audited cleanup behavior.
@ -42,7 +42,7 @@ export function mirrorEntry(sourcePath: string, targetPath: string): void {
// Exported for tests. A "descend candidate" is an entry whose children we
// should recurse into when tearing down the overlay. Anything that is a
// symlink (including a Windows directory junction) must NOT be a candidate
// even if it also reports isDirectory() following it would walk into the
// even if it also reports isDirectory() -- following it would walk into the
// link target and delete user data, which is the bug in #1083.
export function isSafeDescendCandidate(stats: {
isSymbolicLink(): boolean
@ -56,7 +56,7 @@ export function isSafeDescendCandidate(stats: {
// Why: the overlay tree contains symlinks/junctions that point back into the
// user's real state dir. fs.rmSync with { recursive: true } has repeatedly
// regressed on Windows when walking NTFS junctions it can follow them and
// regressed on Windows when walking NTFS junctions -- it can follow them and
// delete the *target*, destroying the user's data. Never descend into a
// symlink/junction here: for any non-real-directory entry we unlink the link
// itself; only entries that are truly directories on disk are recursed into.
@ -70,7 +70,7 @@ export function safeRemoveTree(path: string): void {
// On Windows, lstat on a directory junction can report BOTH
// isSymbolicLink() === true AND isDirectory() === true, so we MUST check
// isSymbolicLink first otherwise a junction enters the recursive branch
// isSymbolicLink first -- otherwise a junction enters the recursive branch
// and readdirSync enumerates the link's target, the exact bug in #1083.
if (!isSafeDescendCandidate(stat)) {
try {
@ -98,7 +98,7 @@ export function safeRemoveTree(path: string): void {
try {
unlinkSync(child)
} catch {
// best-effort, see above
// Best-effort, see above.
}
}

View File

@ -34,13 +34,17 @@ describe('readShellStartupEnvVar', () => {
})
function mockStartupFiles(files: Record<string, string>) {
const hasAbsoluteKeys = Object.keys(files).some((path) => path.startsWith('/'))
existsSyncMock.mockImplementation((p: string) => {
const file = p.split('/').pop() ?? ''
return file in files
return p in files || (!hasAbsoluteKeys && file in files)
})
readFileSyncMock.mockImplementation((p: string) => {
const file = p.split('/').pop() ?? ''
if (file in files) {
if (p in files) {
return files[p]
}
if (!hasAbsoluteKeys && file in files) {
return files[file]
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
@ -96,6 +100,17 @@ describe('readShellStartupEnvVar', () => {
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/newest/zlogin')
})
it('uses ZDOTDIR exported from .zshenv for later zsh startup files', () => {
mockStartupFiles({
'/home/alice/.zshenv': 'export ZDOTDIR="$HOME/.config/zsh"\n',
'/home/alice/.config/zsh/.zshrc': 'export OPENCODE_CONFIG_DIR="$HOME/company/opencode"\n'
})
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice', '/bin/zsh')).toBe(
'/home/alice/company/opencode'
)
})
it('handles double-quoted values', () => {
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR="/quoted/path"\n' })
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/quoted/path')

View File

@ -4,25 +4,62 @@ import { basename, join } from 'path'
// Why: only files the user's actual shell would source. Mixing zsh and bash
// files breaks the "last assignment wins matches the live shell" guarantee —
// a stale .bash_profile on a zsh user would clobber the real .zshrc value.
const ZSH_FILES = ['.zshenv', '.zprofile', '.zshrc', '.zlogin']
const ZSH_ENV_FILE = '.zshenv'
const ZSH_AFTER_ENV_FILES = ['.zprofile', '.zshrc', '.zlogin']
// Why: Orca launches bash as a login shell (see local-pty-shell-ready.ts
// getBashShellReadyRcfileContent and daemon/shell-ready.ts) which sources
// .bash_profile / .bash_login / .profile but intentionally does NOT force
// .bashrc. Scanning .bashrc would mirror values the live Orca bash never sees.
const BASH_LOGIN_FILES = ['.bash_profile', '.bash_login', '.profile']
function shellStartupFiles(shell: string | undefined): readonly string[] {
function parseExportedValue(content: string, name: string, home: string): string | undefined {
const assignment = new RegExp(`^export\\s+${name}=(.+)$`)
let lastMatch: string | undefined
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim()
const match = assignment.exec(line)
if (!match?.[1]) {
continue
}
// Why: strip trailing unquoted `# comment` first so quoted values like
// `"$HOME/.opencode" # note` survive intact for unquoteShellValue.
const decommented = stripTrailingComment(match[1])
const { text, quoted } = unquoteShellValue(decommented)
// Why: $HOME / ${HOME} / ~ expansion mimics what the live shell would
// do for double-quoted and unquoted values; single-quoted is literal.
const expanded = quoted === "'" ? text : expandHome(text, home)
if (expanded.length > 0) {
lastMatch = expanded
}
}
return lastMatch
}
function readStartupFile(path: string): string | null {
if (!existsSync(path)) {
return null
}
try {
return readFileSync(path, 'utf8')
} catch {
return null
}
}
function shellStartupFilePaths(home: string, shell: string | undefined): readonly string[] {
if (!shell) {
// Why: Orca's POSIX default shell is /bin/zsh when $SHELL is unset.
return ZSH_FILES
return zshStartupFilePaths(home)
}
const name = basename(shell).toLowerCase()
if (name === 'zsh') {
return ZSH_FILES
return zshStartupFilePaths(home)
}
if (name === 'bash') {
return BASH_LOGIN_FILES
return BASH_LOGIN_FILES.map((file) => join(home, file))
}
// Why: unsupported explicit shells (fish, nushell, custom wrappers) do not
// use Orca's zsh/bash shell-ready startup files, so scanning those files
@ -30,6 +67,16 @@ function shellStartupFiles(shell: string | undefined): readonly string[] {
return []
}
function zshStartupFilePaths(home: string): readonly string[] {
const zshEnvPath = join(home, ZSH_ENV_FILE)
const zshEnv = readStartupFile(zshEnvPath)
// Why: zsh sources ~/.zshenv first, then uses any ZDOTDIR exported there
// for .zprofile/.zshrc/.zlogin. Mirror that enough for static env discovery
// so users who keep zsh config in ~/.config/zsh do not lose overlay sources.
const zshDir = zshEnv ? (parseExportedValue(zshEnv, 'ZDOTDIR', home) ?? home) : home
return [zshEnvPath, ...ZSH_AFTER_ENV_FILES.map((file) => join(zshDir, file))]
}
function unquoteShellValue(value: string): { text: string; quoted: '"' | "'" | null } {
const trimmed = value.trim()
if (trimmed.length >= 2) {
@ -118,38 +165,17 @@ export function readShellStartupEnvVar(
return cache.get(cacheKey)
}
const assignment = new RegExp(`^export\\s+${name}=(.+)$`)
let lastMatch: string | undefined
for (const file of shellStartupFiles(shell)) {
const path = join(home, file)
if (!existsSync(path)) {
for (const path of shellStartupFilePaths(home, shell)) {
const content = readStartupFile(path)
if (content === null) {
continue
}
let content
try {
content = readFileSync(path, 'utf8')
} catch {
continue
}
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim()
const match = assignment.exec(line)
if (!match?.[1]) {
continue
}
// Why: strip trailing unquoted `# comment` first so quoted values like
// `"$HOME/.opencode" # note` survive intact for unquoteShellValue.
const decommented = stripTrailingComment(match[1])
const { text, quoted } = unquoteShellValue(decommented)
// Why: $HOME / ${HOME} / ~ expansion mimics what the live shell would
// do for double-quoted and unquoted values; single-quoted is literal.
const expanded = quoted === "'" ? text : expandHome(text, home)
if (expanded.length > 0) {
lastMatch = expanded
}
const match = parseExportedValue(content, name, home)
if (match !== undefined) {
lastMatch = match
}
}

View File

@ -6,6 +6,11 @@ import { SshRelaySession } from './ssh-relay-session'
import type { SshConnection } from './ssh-connection'
import type { Store } from '../persistence'
import type { SshPortForwardManager } from './ssh-port-forward'
import { AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../../shared/agent-hook-relay'
const { muxRequestMock } = vi.hoisted(() => ({
muxRequestMock: vi.fn()
}))
vi.mock('./ssh-relay-deploy', () => ({
deployAndLaunchRelay: vi.fn()
@ -15,7 +20,7 @@ vi.mock('./ssh-channel-multiplexer', () => {
return {
SshChannelMultiplexer: class MockSshChannelMultiplexer {
notify = vi.fn()
request = vi.fn().mockResolvedValue([])
request = muxRequestMock
onNotification = vi.fn().mockReturnValue(() => {})
onDispose = vi.fn().mockReturnValue(() => {})
dispose = vi.fn()
@ -119,6 +124,9 @@ function mockDeploySuccess() {
describe('SshRelaySession', () => {
beforeEach(() => {
vi.clearAllMocks()
delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
muxRequestMock.mockReset()
muxRequestMock.mockResolvedValue([])
mockDeploySuccess()
vi.mocked(getPtyIdsForConnection).mockReturnValue([])
})
@ -143,6 +151,63 @@ describe('SshRelaySession', () => {
expect(registerSshGitProvider).toHaveBeenCalledWith('target-1', expect.anything())
})
it('syncs relay-owned plugin assets before registering the SSH PTY provider', async () => {
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1'
muxRequestMock.mockResolvedValue({ ok: true })
const sftp = { end: vi.fn() }
const { mockStore, mockPortForward, getMainWindow } = createMockDeps()
const mockConn = {
sftp: vi.fn().mockResolvedValue(sftp)
} as unknown as SshConnection
const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)
await session.establish(mockConn)
const installPluginsCallIndex = muxRequestMock.mock.calls.findIndex(
([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD
)
expect(installPluginsCallIndex).toBeGreaterThanOrEqual(0)
expect(muxRequestMock.mock.invocationCallOrder[installPluginsCallIndex]).toBeLessThan(
vi.mocked(registerSshPtyProvider).mock.invocationCallOrder[0]
)
// Why: connecting to SSH may upload relay-owned plugin source, but must
// not mutate user-owned agent config files. Remote managed-hook install
// belongs behind an explicit per-host user action.
expect(mockConn.sftp).not.toHaveBeenCalled()
expect(sftp.end).not.toHaveBeenCalled()
})
it('does not register providers if dispose wins during initial plugin sync', async () => {
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1'
let resolvePluginInstall!: () => void
muxRequestMock.mockImplementation(async (method: string) => {
if (method === AGENT_HOOK_INSTALL_PLUGINS_METHOD) {
return new Promise((resolve) => {
resolvePluginInstall = () => resolve({ ok: true })
})
}
return { ok: true }
})
const { mockStore, mockPortForward, getMainWindow } = createMockDeps()
const mockConn = {} as SshConnection
const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)
const establish = session.establish(mockConn)
await vi.waitFor(() =>
expect(muxRequestMock).toHaveBeenCalledWith(
AGENT_HOOK_INSTALL_PLUGINS_METHOD,
expect.anything()
)
)
session.dispose()
resolvePluginInstall()
await expect(establish).rejects.toThrow('Session disposed during establish')
expect(registerSshPtyProvider).not.toHaveBeenCalled()
expect(registerSshFilesystemProvider).not.toHaveBeenCalled()
expect(registerSshGitProvider).not.toHaveBeenCalled()
})
it('rejects establish when not idle', async () => {
const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps()
const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)

View File

@ -18,10 +18,13 @@ import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider'
import { SshGitProvider } from '../providers/ssh-git-provider'
import { agentHookServer } from '../agent-hooks/server'
import {
AGENT_HOOK_INSTALL_PLUGINS_METHOD,
AGENT_HOOK_NOTIFICATION_METHOD,
AGENT_HOOK_REQUEST_REPLAY_METHOD,
isRemoteAgentHooksEnabled
} from '../../shared/agent-hook-relay'
import { _internals as openCodeInternals } from '../opencode/hook-service'
import { getPiAgentStatusExtensionSource } from '../pi/agent-status-extension-source'
import {
registerSshPtyProvider,
unregisterSshPtyProvider,
@ -52,6 +55,11 @@ export class SshRelaySession {
private mux: SshChannelMultiplexer | null = null
private abortController: AbortController | null = null
private muxDisposeCleanup: (() => void) | null = null
// Why: store the notification-handler disposer so teardownProviders can
// release it on reconnect/shutdown. Symmetric with muxDisposeCleanup; while
// the old mux's handler array is GC'd along with the mux today, holding the
// disposer is cheap insurance against future code that retains the old mux.
private muxNotificationCleanup: (() => void) | null = null
// Why: when the relay exec channel closes but the SSH connection stays
// up, the onStateChange reconnect path never fires. This callback lets
// ssh.ts wire up relay-level reconnect from outside the session.
@ -152,7 +160,13 @@ export class SshRelaySession {
// here fails fast so doConnect() can report the real error.
await mux.request('session.resolveHome', { path: '~' })
await this.registerProviders(mux)
const registered = await this.registerProviders(mux, ownsAttempt)
if (!registered) {
if (!mux.isDisposed()) {
mux.dispose()
}
throw new Error('Session disposed during establish')
}
// Why: the mux's transport can close during registerProviders (e.g.
// the --connect bridge exits). registerRelayRoots swallows mux errors
@ -405,6 +419,11 @@ export class SshRelaySession {
return false
}
await this.installPluginsOnRelay(mux)
if (shouldContinue && !shouldContinue()) {
return false
}
const ptyProvider = new SshPtyProvider(this.targetId, mux)
registerSshPtyProvider(this.targetId, ptyProvider)
@ -419,6 +438,47 @@ export class SshRelaySession {
return true
}
// Why: ship the OpenCode plugin / Pi extension source bodies to the relay
// so it can materialize per-PTY overlay dirs and inject OPENCODE_CONFIG_DIR
// / PI_CODING_AGENT_DIR into spawn env. The strings change as we add agent
// events (recent additions: cursor, pi); pinning them to the relay binary
// would force a relay redeploy on every Orca update. See
// docs/design/agent-status-over-ssh.md §4 + §8 (commit #7).
//
// Best-effort: a -32601 from an older relay (no handler installed) is
// swallowed; the user just doesn't get OpenCode/Pi status reporting until
// they upgrade. Hook-script-based agents use a separate explicit remote
// installer flow because that mutates user-owned agent config files.
private async installPluginsOnRelay(mux: SshChannelMultiplexer): Promise<void> {
if (!isRemoteAgentHooksEnabled()) {
return
}
try {
await mux.request(AGENT_HOOK_INSTALL_PLUGINS_METHOD, {
opencodePluginSource: openCodeInternals.getOpenCodePluginSource(),
piExtensionSource: getPiAgentStatusExtensionSource()
})
} catch (err) {
// Why: -32601 = older relay without the handler (treat as soft skip).
// CONNECTION_LOST / DISPOSED come from the multiplexer when it tears
// down mid-flight (routine on session shutdown / reconnect race) — not
// a real failure to surface; suppress to avoid log spam on every clean
// disconnect.
const code = (err as { code?: unknown })?.code
if (code === -32601 || code === 'CONNECTION_LOST' || code === 'DISPOSED') {
return
}
if (mux.isDisposed()) {
return
}
console.warn(
`[ssh-relay-session] agent_hook.installPlugins failed for ${this.targetId}: ${
err instanceof Error ? err.message : String(err)
}`
)
}
}
// Why: route the relay's `agent.hook` JSON-RPC notification into Orca's
// shared `agentHookServer` via `ingestRemote`. The wire envelope carries
// `connectionId: null` (the relay does not know Orca's local handle); we
@ -433,7 +493,13 @@ export class SshRelaySession {
if (!isRemoteAgentHooksEnabled()) {
return
}
mux.onNotification((method, params) => {
// Why: capture the disposer so teardownProviders can release the
// notification handler symmetrically with muxDisposeCleanup. Even though
// the disposed mux's handler array is GC'd along with it today, retaining
// the disposer makes "registerProviders called twice on the same mux"
// safe by future-proofing against duplicate handler registration.
this.muxNotificationCleanup?.()
this.muxNotificationCleanup = mux.onNotification((method, params) => {
if (method !== AGENT_HOOK_NOTIFICATION_METHOD) {
return
}
@ -470,10 +536,14 @@ export class SshRelaySession {
// *after* the handler is wired so the request-driven replay shape
// strictly trails our subscription on the dispatcher's single write
// callback. Best-effort: a relay that does not know the method
// (e.g. older relay binary) returns -32601, which we swallow.
// (e.g. older relay binary) returns -32601; CONNECTION_LOST / DISPOSED
// arise from mux teardown mid-flight on routine reconnect/shutdown.
void mux.request(AGENT_HOOK_REQUEST_REPLAY_METHOD).catch((err) => {
const code = (err as { code?: unknown })?.code
if (code === -32601) {
if (code === -32601 || code === 'CONNECTION_LOST' || code === 'DISPOSED') {
return
}
if (mux.isDisposed()) {
return
}
// Why: a normal disconnect/teardown rejects the in-flight request with
@ -493,6 +563,8 @@ export class SshRelaySession {
private teardownProviders(reason: 'shutdown' | 'connection_lost'): void {
this.muxDisposeCleanup?.()
this.muxDisposeCleanup = null
this.muxNotificationCleanup?.()
this.muxNotificationCleanup = null
if (this.mux && !this.mux.isDisposed()) {
this.mux.dispose(reason)
}

View File

@ -106,6 +106,11 @@ export class RelayAgentHookServer {
await new Promise<void>((resolve, reject) => {
const onStartupError = (err: Error): void => {
this.server?.off('listening', onListening)
// Why: null the server reference on bind failure so a subsequent
// start() can retry. Without this, a failed bind (e.g. EMFILE) leaves
// this.server populated and the early-return at the top of start()
// wedges the relay into a permanently broken state until stop() runs.
this.server = null
reject(err)
}
const onListening = (): void => {
@ -234,9 +239,14 @@ export class RelayAgentHookServer {
}
res.writeHead(204)
res.end()
} catch {
} catch (err) {
// Why: agent hooks must fail open — return success on parse / size /
// timeout errors so a buggy agent script never blocks the agent run.
// Log the swallowed error to stderr so future programmer bugs are not
// invisible (the 204 response would otherwise mask them entirely).
process.stderr.write(
`[relay-hook-server] hook request failed: ${err instanceof Error ? err.message : String(err)}\n`
)
res.writeHead(204)
res.end()
}

View File

@ -0,0 +1,81 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { __resetShellStartupEnvCache } from '../main/pty/shell-startup-env'
import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env'
describe('plugin overlay env source resolution', () => {
let homeDir: string
beforeEach(() => {
homeDir = mkdtempSync(join(tmpdir(), 'relay-plugin-overlay-env-'))
__resetShellStartupEnvCache()
})
afterEach(() => {
rmSync(homeDir, { recursive: true, force: true })
__resetShellStartupEnvCache()
})
it.skipIf(process.platform === 'win32')(
'uses zsh startup exports before inherited public overlay env',
() => {
mkdirSync(join(homeDir, 'company-opencode'), { recursive: true })
mkdirSync(join(homeDir, 'company-pi'), { recursive: true })
writeFileSync(
join(homeDir, '.zshrc'),
[
'export OPENCODE_CONFIG_DIR="$HOME/company-opencode"',
'export PI_CODING_AGENT_DIR="$HOME/company-pi"'
].join('\n')
)
const env = {
HOME: homeDir,
OPENCODE_CONFIG_DIR: '/tmp/inherited-opencode-overlay',
PI_CODING_AGENT_DIR: '/tmp/inherited-pi-overlay'
}
expect(resolveOpenCodeSourceConfigDir(env, '/bin/zsh')).toBe(
join(homeDir, 'company-opencode')
)
expect(resolvePiSourceAgentDir(env, '/bin/zsh')).toBe(join(homeDir, 'company-pi'))
}
)
it.skipIf(process.platform === 'win32')(
'discovers overlay sources from a custom zsh ZDOTDIR',
() => {
const zshDir = join(homeDir, '.config', 'zsh')
mkdirSync(zshDir, { recursive: true })
writeFileSync(join(homeDir, '.zshenv'), 'export ZDOTDIR="$HOME/.config/zsh"\n')
writeFileSync(join(zshDir, '.zshrc'), 'export OPENCODE_CONFIG_DIR="$HOME/opencode-src"\n')
expect(
resolveOpenCodeSourceConfigDir(
{
HOME: homeDir,
OPENCODE_CONFIG_DIR: '/tmp/inherited-opencode-overlay'
},
'/bin/zsh'
)
).toBe(join(homeDir, 'opencode-src'))
}
)
it('keeps explicit original-source env ahead of startup hints', () => {
writeFileSync(join(homeDir, '.zshrc'), 'export OPENCODE_CONFIG_DIR="$HOME/company-opencode"\n')
expect(
resolveOpenCodeSourceConfigDir(
{
HOME: homeDir,
ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/remote/original-opencode',
OPENCODE_CONFIG_DIR: '/tmp/inherited-opencode-overlay'
},
'/bin/zsh'
)
).toBe('/remote/original-opencode')
})
})

View File

@ -0,0 +1,35 @@
import { readShellStartupEnvVar } from '../main/pty/shell-startup-env'
function firstNonEmpty(...values: (string | undefined)[]): string | undefined {
return values.find((value) => typeof value === 'string' && value.length > 0)
}
function readStartupEnv(
name: string,
env: Record<string, string>,
shell: string | undefined
): string | undefined {
return readShellStartupEnvVar(name, env.HOME ?? process.env.HOME, shell ?? env.SHELL)
}
export function resolveOpenCodeSourceConfigDir(
env: Record<string, string>,
shell: string | undefined
): string | undefined {
return firstNonEmpty(
env.ORCA_OPENCODE_SOURCE_CONFIG_DIR,
readStartupEnv('OPENCODE_CONFIG_DIR', env, shell),
env.OPENCODE_CONFIG_DIR
)
}
export function resolvePiSourceAgentDir(
env: Record<string, string>,
shell: string | undefined
): string | undefined {
return firstNonEmpty(
env.ORCA_PI_SOURCE_AGENT_DIR,
readStartupEnv('PI_CODING_AGENT_DIR', env, shell),
env.PI_CODING_AGENT_DIR
)
}

View File

@ -0,0 +1,186 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
symlinkSync,
writeFileSync
} from 'fs'
import { tmpdir } from 'os'
import { basename, join } from 'path'
import { PluginOverlayManager } from './plugin-overlay'
describe('PluginOverlayManager', () => {
let homeDir: string
let manager: PluginOverlayManager
beforeEach(() => {
homeDir = mkdtempSync(join(tmpdir(), 'plugin-overlay-'))
manager = new PluginOverlayManager({ homeDir })
})
afterEach(() => {
rmSync(homeDir, { recursive: true, force: true })
})
it('reports no source until install runs', () => {
expect(manager.hasOpenCodeSource()).toBe(false)
expect(manager.hasPiSource()).toBe(false)
expect(manager.materializeOpenCode('tab-1:0')).toBeNull()
expect(manager.materializePi('tab-1:0')).toBeNull()
})
it('materializes OpenCode plugin into <overlay>/plugins/<file>', () => {
manager.setSources({ opencodePluginSource: 'export const X = 1' })
const dir = manager.materializeOpenCode('tab-1:0')
expect(dir).not.toBeNull()
const expected = join(dir!, 'plugins', 'orca-opencode-status.js')
expect(existsSync(expected)).toBe(true)
expect(readFileSync(expected, 'utf8')).toBe('export const X = 1')
})
it('mirrors a preexisting remote OpenCode config dir before adding Orca plugin', () => {
const userConfigDir = join(homeDir, 'company-opencode')
mkdirSync(join(userConfigDir, 'plugins'), { recursive: true })
writeFileSync(join(userConfigDir, 'opencode.json'), '{"provider":"custom"}')
writeFileSync(join(userConfigDir, 'plugins', 'user-plugin.js'), 'user plugin')
writeFileSync(join(userConfigDir, 'plugins', 'orca-opencode-status.js'), 'user same-name')
manager.setSources({ opencodePluginSource: 'orca plugin' })
const dir = manager.materializeOpenCode('tab-opencode:0', userConfigDir)
expect(dir).not.toBeNull()
expect(readFileSync(join(dir!, 'opencode.json'), 'utf8')).toBe('{"provider":"custom"}')
expect(readFileSync(join(dir!, 'plugins', 'user-plugin.js'), 'utf8')).toBe('user plugin')
expect(readFileSync(join(dir!, 'plugins', 'orca-opencode-status.js'), 'utf8')).toBe(
'orca plugin'
)
expect(readFileSync(join(userConfigDir, 'plugins', 'orca-opencode-status.js'), 'utf8')).toBe(
'user same-name'
)
})
it('does not override a missing preexisting OpenCode config dir', () => {
manager.setSources({ opencodePluginSource: 'orca plugin' })
expect(manager.materializeOpenCode('tab-missing:0', join(homeDir, 'missing'))).toBeNull()
})
it('materializes Pi extension into <overlay>/extensions/<file>', () => {
manager.setSources({ piExtensionSource: '// pi extension' })
const dir = manager.materializePi('tab-2:0')
expect(dir).not.toBeNull()
const file = join(dir!, 'extensions', 'orca-agent-status.ts')
expect(existsSync(file)).toBe(true)
})
it('mirrors the remote default Pi agent dir before adding Orca status extension', () => {
const piAgentDir = join(homeDir, '.pi', 'agent')
mkdirSync(join(piAgentDir, 'skills', 'my-skill'), { recursive: true })
mkdirSync(join(piAgentDir, 'extensions', 'user-ext'), { recursive: true })
writeFileSync(join(piAgentDir, 'auth.json'), 'secret token')
writeFileSync(join(piAgentDir, 'skills', 'my-skill', 'SKILL.md'), 'critical user skill')
writeFileSync(join(piAgentDir, 'extensions', 'user-ext', 'ext.ts'), 'user extension')
manager.setSources({ piExtensionSource: '// pi extension' })
const dir = manager.materializePi('tab-pi:0')
expect(dir).not.toBeNull()
expect(readFileSync(join(dir!, 'auth.json'), 'utf8')).toBe('secret token')
expect(readFileSync(join(dir!, 'skills', 'my-skill', 'SKILL.md'), 'utf8')).toBe(
'critical user skill'
)
expect(readFileSync(join(dir!, 'extensions', 'user-ext', 'ext.ts'), 'utf8')).toBe(
'user extension'
)
expect(readdirSync(join(dir!, 'extensions')).sort()).toEqual([
'orca-agent-status.ts',
'user-ext'
])
})
it('mirrors a preexisting remote Pi agent dir instead of the default', () => {
const defaultAgentDir = join(homeDir, '.pi', 'agent')
const customAgentDir = join(homeDir, 'custom-pi-agent')
mkdirSync(defaultAgentDir, { recursive: true })
mkdirSync(join(customAgentDir, 'extensions'), { recursive: true })
writeFileSync(join(defaultAgentDir, 'auth.json'), 'default token')
writeFileSync(join(customAgentDir, 'auth.json'), 'custom token')
writeFileSync(join(customAgentDir, 'extensions', 'custom.ts'), 'custom extension')
manager.setSources({ piExtensionSource: '// pi extension' })
const dir = manager.materializePi('tab-custom-pi:0', customAgentDir)
expect(dir).not.toBeNull()
expect(readFileSync(join(dir!, 'auth.json'), 'utf8')).toBe('custom token')
expect(readFileSync(join(dir!, 'extensions', 'custom.ts'), 'utf8')).toBe('custom extension')
expect(readFileSync(join(dir!, 'extensions', 'orca-agent-status.ts'), 'utf8')).toBe(
'// pi extension'
)
})
it('does not override a missing preexisting Pi agent dir', () => {
manager.setSources({ piExtensionSource: '// pi extension' })
expect(manager.materializePi('tab-missing-pi:0', join(homeDir, 'missing-pi'))).toBeNull()
})
it('clearOverlay removes both overlay roots for an id', () => {
manager.setSources({
opencodePluginSource: 'opencode',
piExtensionSource: 'pi'
})
const opencodeDir = manager.materializeOpenCode('tab-3:0')!
const piRoot = manager.materializePi('tab-3:0')!
expect(existsSync(opencodeDir)).toBe(true)
expect(existsSync(piRoot)).toBe(true)
manager.clearOverlay('tab-3:0')
expect(existsSync(opencodeDir)).toBe(false)
expect(existsSync(piRoot)).toBe(false)
})
it.skipIf(process.platform === 'win32')(
'clearOverlay removes OpenCode overlay symlinks without deleting their targets',
() => {
const userConfigDir = join(homeDir, 'company-opencode')
const linkedTarget = join(homeDir, 'linked-plugin-target')
mkdirSync(join(userConfigDir, 'plugins'), { recursive: true })
mkdirSync(linkedTarget, { recursive: true })
writeFileSync(join(linkedTarget, 'keep.js'), 'do not delete')
symlinkSync(linkedTarget, join(userConfigDir, 'plugins', 'linked-plugin'), 'dir')
manager.setSources({ opencodePluginSource: 'orca plugin' })
const dir = manager.materializeOpenCode('tab-opencode-symlink:0', userConfigDir)!
expect(existsSync(join(dir, 'plugins', 'linked-plugin'))).toBe(true)
manager.clearOverlay('tab-opencode-symlink:0')
expect(existsSync(dir)).toBe(false)
expect(readFileSync(join(linkedTarget, 'keep.js'), 'utf8')).toBe('do not delete')
}
)
it('produces stable overlay dirs for a given id (idempotent re-materialization)', () => {
manager.setSources({ opencodePluginSource: 'first' })
const dirA = manager.materializeOpenCode('tab-stable:0')!
manager.setSources({ opencodePluginSource: 'second' })
const dirB = manager.materializeOpenCode('tab-stable:0')!
expect(dirA).toBe(dirB)
expect(readFileSync(join(dirA, 'plugins', 'orca-opencode-status.js'), 'utf8')).toBe('second')
})
it('hashes unsafe pane ids into portable overlay directory names', () => {
manager.setSources({ opencodePluginSource: 'plugin' })
const dir = manager.materializeOpenCode('tab/with\\unsafe:chars\n0')
expect(dir).not.toBeNull()
expect(basename(dir!)).toMatch(/^[a-f0-9]{32}$/)
expect(dir).not.toContain('tab/with')
expect(existsSync(join(dir!, 'plugins', 'orca-opencode-status.js'))).toBe(true)
})
})

276
src/relay/plugin-overlay.ts Normal file
View File

@ -0,0 +1,276 @@
// Why: relay-side equivalent of Orca's userData-backed plugin overlay system.
// Orca's local OpenCodeHookService and PiTitlebarExtensionService each
// materialize a per-PTY overlay and inject OPENCODE_CONFIG_DIR /
// PI_CODING_AGENT_DIR pointing at it. Those paths describe the local
// filesystem and would resolve to nothing on a remote box, so when a PTY runs
// on the relay, the relay must do the same materialization on its own disk.
//
// Plugin source strings ship over the JSON-RPC channel at session-ready
// (commit #7) — they are NOT bundled with the relay binary because the
// relay is versioned independently from Orca and the plugin source changes
// frequently as new agent events get added (see docs/design/agent-status-
// over-ssh.md §4 "Why ship the plugin source over the wire").
//
// We deliberately do not reuse OpenCodeHookService / PiTitlebarExtensionService
// directly: those modules import `electron` and ride on Orca's userData
// path. The relay's electron-free constraint forces a thin parallel
// implementation rooted at $HOME/.orca-relay/.
import { createHash } from 'crypto'
import {
existsSync,
mkdirSync,
readdirSync,
realpathSync,
statSync,
unlinkSync,
writeFileSync
} from 'fs'
import { homedir } from 'os'
import { basename, join } from 'path'
import { mirrorEntry, safeRemoveOverlay } from '../main/pty/overlay-mirror'
const RELAY_HOOKS_DIR = '.orca-relay'
const OPENCODE_OVERLAY_SUBDIR = 'opencode-overlays'
const PI_OVERLAY_SUBDIR = 'pi-overlays'
const OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js'
const PI_EXTENSION_FILE = 'orca-agent-status.ts'
const PI_AGENT_DIR_NAME = '.pi'
const PI_AGENT_SUBDIR = 'agent'
function safeDirName(input: string): string {
// Why: paneKey embeds tabId:paneId where tabId may itself contain
// filesystem-unsafe characters in some Orca builds. Hash to a fixed-width
// hex name so any input produces a portable directory name.
return createHash('sha256').update(input).digest('hex').slice(0, 32)
}
function isUsableId(id: string): boolean {
return typeof id === 'string' && id.length > 0 && id.length <= 1024
}
export type PluginSources = {
/** Source body of `orca-opencode-status.js` to drop into <overlay>/plugins/. */
opencodePluginSource?: string
/** Source body of `orca-agent-status.ts` to drop into <overlay>/extensions/. */
piExtensionSource?: string
}
export class PluginOverlayManager {
private opencodePluginSource: string | null = null
private piExtensionSource: string | null = null
private homeDir: string
private opencodeRoot: string
private piRoot: string
constructor(opts?: { homeDir?: string }) {
const home = opts?.homeDir ?? homedir()
this.homeDir = home
this.opencodeRoot = join(home, RELAY_HOOKS_DIR, OPENCODE_OVERLAY_SUBDIR)
this.piRoot = join(home, RELAY_HOOKS_DIR, PI_OVERLAY_SUBDIR)
}
/** Replace the cached source bodies. Called from relay.ts when Orca sends
* `agent_hook.installPlugins`. The first install enables the augmenter
* output; subsequent installs (e.g. Orca version upgrade in flight) refresh
* the cached source so future spawns see the new strings.
* Note: existing per-PTY overlays already on disk keep the previous source
* until that PTY exits a long-running PTY does NOT pick up the new
* source, matching the local-Orca behavior where the plugin file is
* written once at spawn time. */
setSources(sources: PluginSources): void {
if (typeof sources.opencodePluginSource === 'string') {
this.opencodePluginSource = sources.opencodePluginSource
}
if (typeof sources.piExtensionSource === 'string') {
this.piExtensionSource = sources.piExtensionSource
}
}
hasOpenCodeSource(): boolean {
return this.opencodePluginSource !== null
}
hasPiSource(): boolean {
return this.piExtensionSource !== null
}
private mirrorOpenCodeConfig(sourceDir: string, overlayDir: string): void {
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
const sourcePath = join(sourceDir, entry.name)
if (entry.name === 'plugins') {
const isSymlink = entry.isSymbolicLink()
let isLinkPointingToDir = false
if (isSymlink) {
try {
isLinkPointingToDir = statSync(sourcePath).isDirectory()
} catch {
isLinkPointingToDir = false
}
}
if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) {
const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath
const overlayPluginsDir = join(overlayDir, 'plugins')
mkdirSync(overlayPluginsDir, { recursive: true })
for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) {
if (pluginEntry.name === OPENCODE_PLUGIN_FILE) {
continue
}
mirrorEntry(
join(resolvedSource, pluginEntry.name),
join(overlayPluginsDir, pluginEntry.name)
)
}
continue
}
}
mirrorEntry(sourcePath, join(overlayDir, entry.name))
}
}
private writeOpenCodePlugin(overlayDir: string): void {
const pluginsDir = join(overlayDir, 'plugins')
mkdirSync(pluginsDir, { recursive: true })
const pluginPath = join(pluginsDir, OPENCODE_PLUGIN_FILE)
try {
unlinkSync(pluginPath)
} catch {
// Fresh overlay or no same-named stale symlink.
}
writeFileSync(pluginPath, this.opencodePluginSource!)
}
/** Materialize the OpenCode plugin overlay for `id` (typically the
* renderer-supplied paneKey or, fallback, the relay-internal pty-id) and
* return the directory path. Returns null when no source is cached or
* the overlay write fails caller falls back to no plugin (the agent
* CLI runs without status reporting), which is the existing fail-open
* behavior on the local side. */
materializeOpenCode(id: string, existingConfigDir?: string): string | null {
if (!this.opencodePluginSource || !isUsableId(id)) {
return null
}
const dir = join(this.opencodeRoot, safeDirName(id))
try {
safeRemoveOverlay(dir, this.opencodeRoot)
mkdirSync(dir, { recursive: true })
if (existingConfigDir) {
if (!existsSync(existingConfigDir)) {
return null
}
// Why: OPENCODE_CONFIG_DIR is a single config root. Mirror the user's
// remote root into the overlay before adding Orca's plugin so status
// reporting does not hide their auth, models, keybinds, or plugins.
this.mirrorOpenCodeConfig(existingConfigDir, dir)
}
this.writeOpenCodePlugin(dir)
return dir
} catch (err) {
process.stderr.write(
`[plugin-overlay] failed to materialize OpenCode overlay: ${err instanceof Error ? err.message : String(err)}\n`
)
return null
}
}
private getDefaultPiAgentDir(): string {
return join(this.homeDir, PI_AGENT_DIR_NAME, PI_AGENT_SUBDIR)
}
private mirrorPiAgentDir(sourceAgentDir: string, overlayDir: string): void {
if (!existsSync(sourceAgentDir)) {
return
}
for (const entry of readdirSync(sourceAgentDir, { withFileTypes: true })) {
const sourcePath = join(sourceAgentDir, entry.name)
if (entry.name === 'extensions') {
const isSymlink = entry.isSymbolicLink()
let isLinkPointingToDir = false
if (isSymlink) {
try {
isLinkPointingToDir = statSync(sourcePath).isDirectory()
} catch {
isLinkPointingToDir = false
}
}
if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) {
const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath
const overlayExtensionsDir = join(overlayDir, 'extensions')
mkdirSync(overlayExtensionsDir, { recursive: true })
for (const extensionEntry of readdirSync(resolvedSource, { withFileTypes: true })) {
if (extensionEntry.name === PI_EXTENSION_FILE) {
continue
}
mirrorEntry(
join(resolvedSource, extensionEntry.name),
join(overlayExtensionsDir, extensionEntry.name)
)
}
continue
}
}
mirrorEntry(sourcePath, join(overlayDir, basename(sourcePath)))
}
}
/** Materialize the Pi extension overlay for `id` and return the directory
* path that should be assigned to PI_CODING_AGENT_DIR. */
materializePi(id: string, existingAgentDir?: string): string | null {
if (!this.piExtensionSource || !isUsableId(id)) {
return null
}
const dir = join(this.piRoot, safeDirName(id))
try {
// Why: PI_CODING_AGENT_DIR is Pi's whole state root. Mirror the remote
// user's default agent dir so Orca's status extension does not hide auth,
// sessions, skills, prompts, themes, or user extensions inside SSH panes.
safeRemoveOverlay(dir, this.piRoot)
mkdirSync(dir, { recursive: true })
const sourceAgentDir = existingAgentDir ?? this.getDefaultPiAgentDir()
if (existingAgentDir && !existsSync(existingAgentDir)) {
return null
}
this.mirrorPiAgentDir(sourceAgentDir, dir)
const extensionsDir = join(dir, 'extensions')
mkdirSync(extensionsDir, { recursive: true })
writeFileSync(join(extensionsDir, PI_EXTENSION_FILE), this.piExtensionSource)
return dir
} catch (err) {
process.stderr.write(
`[plugin-overlay] failed to materialize Pi overlay: ${err instanceof Error ? err.message : String(err)}\n`
)
return null
}
}
/** Drop a paneKey's overlay dirs on PTY exit. Best-effort; cleanup over a
* recursive tree may fail on exotic filesystems but the worst-case
* outcome is unbounded growth on a long-lived relay, which the per-pane
* caches alone do not bound. */
clearOverlay(id: string): void {
if (!isUsableId(id)) {
return
}
const safe = safeDirName(id)
for (const root of [this.opencodeRoot, this.piRoot]) {
try {
safeRemoveOverlay(join(root, safe), root)
} catch (err) {
// Why: log the failed cleanup so a permission/IO error is observable.
// The leak is the failure mode the per-pane cache eviction exists to
// prevent — silent swallows would let it accumulate invisibly on
// long-running relays.
process.stderr.write(
`[plugin-overlay] failed to remove overlay dir ${join(root, safe)}: ${err instanceof Error ? err.message : String(err)}\n`
)
}
}
}
}

View File

@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { assertPluginSourceUnderByteCap, PLUGIN_SOURCE_MAX_BYTES } from './plugin-source-limit'
describe('plugin source byte limit', () => {
it('allows non-string values because installPlugins treats them as absent sources', () => {
expect(() => assertPluginSourceUnderByteCap('piExtensionSource', undefined)).not.toThrow()
})
it('allows sources at the byte cap', () => {
const source = 'a'.repeat(PLUGIN_SOURCE_MAX_BYTES)
expect(() => assertPluginSourceUnderByteCap('opencodePluginSource', source)).not.toThrow()
})
it('rejects sources over the byte cap using utf8 byte length, not string length', () => {
const source = 'é'.repeat(Math.floor(PLUGIN_SOURCE_MAX_BYTES / 2) + 1)
expect(source.length).toBeLessThanOrEqual(PLUGIN_SOURCE_MAX_BYTES)
expect(Buffer.byteLength(source, 'utf8')).toBeGreaterThan(PLUGIN_SOURCE_MAX_BYTES)
expect(() => assertPluginSourceUnderByteCap('piExtensionSource', source)).toThrow(
`piExtensionSource exceeds ${PLUGIN_SOURCE_MAX_BYTES} byte cap`
)
})
})

View File

@ -0,0 +1,12 @@
export const PLUGIN_SOURCE_MAX_BYTES = 256 * 1024
export function assertPluginSourceUnderByteCap(fieldName: string, value: unknown): void {
if (typeof value !== 'string') {
return
}
// Why: the relay receives JSON strings over the wire; cap actual UTF-8
// bytes instead of UTF-16 code units so non-ASCII source cannot bypass it.
if (Buffer.byteLength(value, 'utf8') > PLUGIN_SOURCE_MAX_BYTES) {
throw new Error(`${fieldName} exceeds ${PLUGIN_SOURCE_MAX_BYTES} byte cap`)
}
}

View File

@ -1,5 +1,8 @@
/* oxlint-disable max-lines */
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({
mockPtySpawn: vi.fn(),
@ -435,6 +438,113 @@ describe('PtyHandler', () => {
expect(callArgs.env.ORCA_TAB_ID).toBe('tab-1')
})
it('passes the PTY id and renderer paneKey to env augmenters', async () => {
const seenContexts: { id: string; paneKey?: string; env: Record<string, string> }[] = []
handler.addEnvAugmenter((ctx) => {
seenContexts.push(ctx)
return {
OVERLAY_ID: ctx.paneKey ?? ctx.id
}
})
await dispatcher.callRequest('pty.spawn', {
env: { ORCA_PANE_KEY: 'tab-context:0' }
})
await dispatcher.callRequest('pty.spawn', {})
const firstEnv = mockPtySpawn.mock.calls[0][2] as { env: Record<string, string> }
const secondEnv = mockPtySpawn.mock.calls[1][2] as { env: Record<string, string> }
expect(seenContexts[0]).toMatchObject({
id: 'pty-1',
paneKey: 'tab-context:0',
env: { ORCA_PANE_KEY: 'tab-context:0' }
})
expect(seenContexts[1]).toMatchObject({ id: 'pty-2', paneKey: undefined })
expect(firstEnv.env.OVERLAY_ID).toBe('tab-context:0')
expect(secondEnv.env.OVERLAY_ID).toBe('pty-2')
})
it('passes process and renderer env to env augmenters before augmenter overrides are applied', async () => {
const oldProcessValue = process.env.OPENCODE_CONFIG_DIR
process.env.OPENCODE_CONFIG_DIR = '/remote/default-opencode'
try {
handler.addEnvAugmenter((ctx) => ({
SEEN_OPENCODE_CONFIG_DIR: ctx.env.OPENCODE_CONFIG_DIR,
SEEN_PI_CODING_AGENT_DIR: ctx.env.PI_CODING_AGENT_DIR
}))
await dispatcher.callRequest('pty.spawn', {
env: {
OPENCODE_CONFIG_DIR: '/remote/renderer-opencode',
PI_CODING_AGENT_DIR: '/remote/pi'
}
})
} finally {
if (oldProcessValue === undefined) {
delete process.env.OPENCODE_CONFIG_DIR
} else {
process.env.OPENCODE_CONFIG_DIR = oldProcessValue
}
}
const spawnEnv = mockPtySpawn.mock.calls[0][2] as { env: Record<string, string> }
expect(spawnEnv.env.SEEN_OPENCODE_CONFIG_DIR).toBe('/remote/renderer-opencode')
expect(spawnEnv.env.SEEN_PI_CODING_AGENT_DIR).toBe('/remote/pi')
})
it.skipIf(process.platform === 'win32')(
'wraps bash spawns to restore overlay env after remote startup files',
async () => {
const oldShell = process.env.SHELL
const oldHome = process.env.HOME
const homeDir = mkdtempSync(join(tmpdir(), 'relay-pty-shell-launch-'))
process.env.SHELL = '/bin/bash'
process.env.HOME = homeDir
try {
if (!existsSync('/bin/bash')) {
return
}
handler.addEnvAugmenter(() => ({
OPENCODE_CONFIG_DIR: '/remote/overlay/opencode',
ORCA_OPENCODE_CONFIG_DIR: '/remote/overlay/opencode',
PI_CODING_AGENT_DIR: '/remote/overlay/pi',
ORCA_PI_CODING_AGENT_DIR: '/remote/overlay/pi'
}))
await dispatcher.callRequest('pty.spawn', { env: { HOME: homeDir } })
} finally {
if (oldShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = oldShell
}
if (oldHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = oldHome
}
}
const shellArgs = mockPtySpawn.mock.calls[0][1]
const spawnOptions = mockPtySpawn.mock.calls[0][2] as { env: Record<string, string> }
const rcfile = join(homeDir, '.orca-relay', 'shell-ready', 'bash', 'rcfile')
expect(shellArgs).toEqual(['--rcfile', rcfile])
expect(spawnOptions.env.ORCA_OPENCODE_CONFIG_DIR).toBe('/remote/overlay/opencode')
expect(spawnOptions.env.ORCA_PI_CODING_AGENT_DIR).toBe('/remote/overlay/pi')
expect(readFileSync(rcfile, 'utf8')).toContain(
'export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"'
)
expect(readFileSync(rcfile, 'utf8')).toContain(
'export PI_CODING_AGENT_DIR="${ORCA_PI_CODING_AGENT_DIR}"'
)
rmSync(homeDir, { recursive: true, force: true })
}
)
it('revive restores pane identity env alongside hook-server coordinates', async () => {
await dispatcher.callRequest('pty.spawn', {
cols: 90,
@ -494,7 +604,32 @@ describe('PtyHandler', () => {
expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-2:1' }])
})
it('dispose kills all PTYs with SIGKILL', async () => {
it('immediate shutdown invokes the exit listener once even if onExit arrives later', async () => {
let onExitCb: ((evt: { exitCode: number }) => void) | undefined
const mockKill = vi.fn()
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
kill: mockKill,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
onExitCb = cb
})
})
const exits: { id: string; paneKey?: string }[] = []
handler.setExitListener((evt) => exits.push(evt))
await dispatcher.callRequest('pty.spawn', {
env: { ORCA_PANE_KEY: 'tab-shutdown:0' }
})
await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true })
onExitCb!({ exitCode: 0 })
expect(mockKill).toHaveBeenCalledWith('SIGKILL')
expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-shutdown:0' }])
expect(handler.activePtyCount).toBe(0)
})
it('dispose kills all PTYs with SIGKILL and invokes exit listeners', async () => {
const mockKill = vi.fn()
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
@ -502,9 +637,11 @@ describe('PtyHandler', () => {
onData: vi.fn(),
onExit: vi.fn()
})
const exits: { id: string; paneKey?: string }[] = []
handler.setExitListener((evt) => exits.push(evt))
await dispatcher.callRequest('pty.spawn', {})
await dispatcher.callRequest('pty.spawn', {})
await dispatcher.callRequest('pty.spawn', { env: { ORCA_PANE_KEY: 'tab-dispose:0' } })
await dispatcher.callRequest('pty.spawn', { env: { ORCA_PANE_KEY: 'tab-dispose:1' } })
expect(handler.activePtyCount).toBe(2)
handler.dispose()
@ -513,6 +650,10 @@ describe('PtyHandler', () => {
// wedged process, uninterruptible sleep) would survive SIGTERM + immediate
// destroy() as an orphan on the remote host. SIGKILL is not ignorable.
expect(mockKill).toHaveBeenCalledWith('SIGKILL')
expect(exits).toEqual([
{ id: 'pty-1', paneKey: 'tab-dispose:0' },
{ id: 'pty-2', paneKey: 'tab-dispose:1' }
])
expect(handler.activePtyCount).toBe(0)
})
})

View File

@ -9,6 +9,7 @@ import {
getForegroundProcessName,
listShellProfiles
} from './pty-shell-utils'
import { getRelayShellLaunchConfig } from './pty-shell-launch'
// Why: node-pty is a native addon that may not be installed on the remote.
// Dynamic import keeps the require() lazy so loadPty() returns null gracefully
@ -39,6 +40,10 @@ type ManagedPty = {
* entry-point calls into a clean "not found" error instead of a silent no-op
* (POSIX proc.kill is neutralized inside disposeManagedPty). */
disposed?: boolean
/** True once external cleanup observers have been notified. Forced cleanup
* paths can run before node-pty emits onExit; this prevents duplicate
* overlay/cache cleanup if onExit arrives later. */
exitListenerNotified?: boolean
/** Renderer-supplied paneKey from spawn env (ORCA_PANE_KEY). Captured so
* external observers (the relay-hook-server cache) can evict per-pane
* state when this PTY exits. Symmetric with Orca's local pty.ts. */
@ -102,7 +107,16 @@ type SerializedPtyEntry = {
}
export type PtyExitListener = (event: { id: string; paneKey?: string }) => void
export type PtyEnvAugmenter = () => Record<string, string>
/** Returns env to merge into the PTY's spawn env. Receives spawn context so
* augmenters that need a per-PTY identity (e.g. OPENCODE_CONFIG_DIR overlay
* paths derived from the renderer's paneKey) can compute it without pulling
* the renderer's env in twice. */
export type PtyEnvAugmenter = (ctx: {
id: string
paneKey?: string
shell: string
env: Record<string, string>
}) => Record<string, string>
export class PtyHandler {
private ptys = new Map<string, ManagedPty>()
@ -110,11 +124,13 @@ export class PtyHandler {
private dispatcher: RelayDispatcher
private graceTimeMs: number
private graceTimer: ReturnType<typeof setTimeout> | null = null
// Why: external observers (the relay's hook-server cache) need to drop
// per-pane state when a PTY exits. Multiple listeners is unnecessary today
// — the hook server is the only consumer — so a single optional callback
// keeps the surface tight. A throw inside the listener is swallowed so it
// can never block disposeManagedPty / map cleanup.
// Why: external observers need to drop per-pane state when a PTY exits.
// Today the relay composes multiple consumers (hook-server cache eviction
// and plugin-overlay dir cleanup) into a single callback at the call site
// (see relay.ts setExitListener). A single optional slot is intentional —
// callers compose externally rather than us maintaining a listener list.
// A throw inside the listener is swallowed so it can never block
// disposeManagedPty / map cleanup.
private exitListener: PtyExitListener | null = null
// Why: env augmenters injected at relay boot (currently the relay-hook
// server's ORCA_AGENT_HOOK_* coords). Run on every spawn so every PTY
@ -156,18 +172,22 @@ export class PtyHandler {
* drift between the two paths revived shells after a relay restart must
* see the fresh ORCA_AGENT_HOOK_* coords just like freshly-spawned ones,
* otherwise agent-status over SSH silently breaks on every revive. */
private buildSpawnEnv(rendererEnv?: Record<string, string>): Record<string, string> {
private buildSpawnEnv(
rendererEnv: Record<string, string> | undefined,
ctx: { id: string; paneKey?: string; shell: string }
): Record<string, string> {
const baseEnv = { ...process.env, ...rendererEnv } as Record<string, string>
const augmented: Record<string, string> = {}
for (const augmenter of this.envAugmenters) {
try {
Object.assign(augmented, augmenter())
Object.assign(augmented, augmenter({ ...ctx, env: baseEnv }))
} catch (err) {
process.stderr.write(
`[pty-handler] env augmenter threw: ${err instanceof Error ? err.message : String(err)}\n`
)
}
}
return { ...process.env, ...rendererEnv, ...augmented } as Record<string, string>
return { ...baseEnv, ...augmented }
}
/** Wire onData/onExit listeners for a managed PTY and store it. */
@ -181,6 +201,9 @@ export class PtyHandler {
this.dispatcher.notify('pty.data', { id: managed.id, data })
})
managed.pty.onExit(({ exitCode }: { exitCode: number }) => {
if (managed.disposed) {
return
}
// Why: neutralize managed.pty.kill synchronously BEFORE anything else
// in this callback. node-pty's UnixTerminal has
// `_socket.once('close', () => this.kill('SIGHUP'))` wired at destroy
@ -199,19 +222,7 @@ export class PtyHandler {
managed.killTimer = undefined
}
this.dispatcher.notify('pty.exit', { id: managed.id, code: exitCode })
// Why: notify external observers BEFORE deleting the map entry so a
// listener that needs to read paneKey from the managed entry still
// can. Wrap in try/catch so a throwing listener cannot block fd
// release or map cleanup.
if (this.exitListener) {
try {
this.exitListener({ id: managed.id, paneKey: managed.paneKey })
} catch (err) {
process.stderr.write(
`[pty-handler] onExit listener threw: ${err instanceof Error ? err.message : String(err)}\n`
)
}
}
this.notifyExitListener(managed)
this.ptys.delete(managed.id)
// Why: release the ptmx fd on the natural-exit path. Without this the
// node-pty wrapper's _socket stays alive until GC and the master fd
@ -220,6 +231,26 @@ export class PtyHandler {
})
}
private notifyExitListener(managed: ManagedPty): void {
if (managed.exitListenerNotified) {
return
}
managed.exitListenerNotified = true
// Why: external observers own relay-hook cache eviction and plugin-overlay
// cleanup. Natural exits, immediate shutdown, SIGKILL fallback, and relay
// process disposal all need the same cleanup even when node-pty never
// delivers onExit.
if (this.exitListener) {
try {
this.exitListener({ id: managed.id, paneKey: managed.paneKey })
} catch (err) {
process.stderr.write(
`[pty-handler] exit listener threw: ${err instanceof Error ? err.message : String(err)}\n`
)
}
}
}
private registerHandlers(): void {
this.dispatcher.onRequest('pty.spawn', (p, context) => this.spawn(p, context))
this.dispatcher.onRequest('pty.attach', (p) => this.attach(p))
@ -262,28 +293,31 @@ export class PtyHandler {
const shell = resolveDefaultShell()
const id = `pty-${this.nextId++}`
// Why: server-side augmenter values (ORCA_AGENT_HOOK_*) override any
// renderer-supplied env so the live hook-server coords always reach the
// agent CLI — they come from the relay, not the renderer. See
// buildSpawnEnv for the precedence contract.
const spawnEnv = this.buildSpawnEnv(env)
// Why: server-side augmenter values (ORCA_AGENT_HOOK_* and plugin overlay
// dirs) override renderer-supplied env so live remote paths and hook coords
// win over local userData paths. The context lets overlay augmenters derive
// per-PTY OpenCode/Pi directories from the stable paneKey when present.
const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined
const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell })
const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv)
// Why: SSH exec channels give the relay a minimal environment without
// .zprofile/.bash_profile sourced. Spawning a login shell ensures PATH
// includes Homebrew, nvm, and user-installed CLIs (claude, codex, gh).
const term = pty.spawn(shell, ['-l'], {
// When overlays are injected, the launch wrapper keeps those paths after
// user startup files re-export their defaults.
const term = pty.spawn(shell, shellLaunch.args, {
name: 'xterm-256color',
cols,
rows,
cwd,
env: spawnEnv
env: { ...spawnEnv, ...shellLaunch.env }
})
// Why: capture the renderer-supplied paneKey on the managed entry so the
// exit listener can evict per-pane caches without the relay needing a
// separate ptyId→paneKey map. ORCA_PANE_KEY is shaped `${tabId}:${paneId}`
// and is bounded by the renderer; the relay treats it as opaque.
const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined
const tabId = typeof env?.ORCA_TAB_ID === 'string' ? env.ORCA_TAB_ID : undefined
const worktreeId = typeof env?.ORCA_WORKTREE_ID === 'string' ? env.ORCA_WORKTREE_ID : undefined
const managed: ManagedPty = {
@ -309,6 +343,7 @@ export class PtyHandler {
// SIGKILL's onExit is missed (kernel edge case, uninterruptible
// sleep), the managed entry + ptmx fd would leak forever. Dispose
// synchronously so the entry is gone regardless of onExit timing.
this.notifyExitListener(still)
disposeManagedPty(still)
this.ptys.delete(id)
}
@ -391,6 +426,7 @@ export class PtyHandler {
// here makes the map hygiene a hard guarantee, not "hopefully onExit
// runs". If onExit DOES fire later, its own `this.ptys.delete(id)` is
// a no-op.
this.notifyExitListener(managed)
this.ptys.delete(id)
} else {
managed.pty.kill('SIGTERM')
@ -414,6 +450,7 @@ export class PtyHandler {
// graceful-shutdown's SIGKILL fallback is a hard guarantee, not
// "hopefully onExit will run". The disposed guard inside
// disposeManagedPty makes a later onExit's dispose a no-op.
this.notifyExitListener(still)
disposeManagedPty(still)
this.ptys.delete(id)
}
@ -545,12 +582,19 @@ export class PtyHandler {
if (entry.worktreeId) {
revivedEnv.ORCA_WORKTREE_ID = entry.worktreeId
}
const term = ptyMod.spawn(resolveDefaultShell(), ['-l'], {
const shell = resolveDefaultShell()
const spawnEnv = this.buildSpawnEnv(revivedEnv, {
id: entry.id,
paneKey: entry.paneKey,
shell
})
const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv)
const term = ptyMod.spawn(shell, shellLaunch.args, {
name: 'xterm-256color',
cols: entry.cols,
rows: entry.rows,
cwd: entry.cwd,
env: this.buildSpawnEnv(revivedEnv)
env: { ...spawnEnv, ...shellLaunch.env }
})
this.wireAndStore({
id: entry.id,
@ -612,6 +656,7 @@ export class PtyHandler {
} catch {
/* child may already be dead */
}
this.notifyExitListener(managed)
disposeManagedPty(managed)
}
this.ptys.clear()

View File

@ -0,0 +1,58 @@
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { getRelayShellLaunchConfig } from './pty-shell-launch'
describe('getRelayShellLaunchConfig', () => {
let homeDir: string
beforeEach(() => {
homeDir = mkdtempSync(join(tmpdir(), 'relay-shell-launch-'))
})
afterEach(() => {
rmSync(homeDir, { recursive: true, force: true })
})
it.skipIf(process.platform === 'win32')(
'preserves a user ZDOTDIR exported from .zshenv for later startup files',
() => {
const config = getRelayShellLaunchConfig('/bin/zsh', {
HOME: homeDir,
ORCA_OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-overlay'
})
const zshRoot = join(homeDir, '.orca-relay', 'shell-ready', 'zsh')
expect(config.args).toEqual(['-l'])
expect(config.env.ZDOTDIR).toBe(zshRoot)
expect(readFileSync(join(zshRoot, '.zshenv'), 'utf8')).toContain(
'export ORCA_USER_ZDOTDIR="${ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"'
)
expect(readFileSync(join(zshRoot, '.zprofile'), 'utf8')).toContain(
'_orca_home="${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"'
)
expect(readFileSync(join(zshRoot, '.zshrc'), 'utf8')).toContain(
'_orca_home="${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"'
)
expect(readFileSync(join(zshRoot, '.zlogin'), 'utf8')).toContain(
'_orca_home="${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"'
)
}
)
it.skipIf(process.platform === 'win32')('rewrites stale persistent wrapper files', () => {
const zshRoot = join(homeDir, '.orca-relay', 'shell-ready', 'zsh')
mkdirSync(zshRoot, { recursive: true })
writeFileSync(join(zshRoot, '.zshenv'), '# stale relay wrapper\n')
getRelayShellLaunchConfig('/bin/zsh', {
HOME: homeDir,
ORCA_OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-overlay'
})
expect(readFileSync(join(zshRoot, '.zshenv'), 'utf8')).toContain(
'export ORCA_USER_ZDOTDIR="${ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"'
)
})
})

View File

@ -0,0 +1,165 @@
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { homedir } from 'os'
import { basename, dirname, join } from 'path'
const RELAY_SHELL_READY_DIR = '.orca-relay/shell-ready'
const POSIX_LOGIN_ARGS = ['-l']
export type RelayShellLaunchConfig = {
args: string[]
env: Record<string, string>
}
function quotePosixSingle(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`
}
function hasOverlayRestoreEnv(env: Record<string, string>): boolean {
return Boolean(env.ORCA_OPENCODE_CONFIG_DIR || env.ORCA_PI_CODING_AGENT_DIR)
}
function getWrapperRoot(env: Record<string, string>): string {
return join(env.HOME || process.env.HOME || homedir(), RELAY_SHELL_READY_DIR)
}
function normalizeOriginalZdotdirCandidate(value: string | undefined): string | null {
if (!value) {
return null
}
const normalized = value.replace(/\/+$/, '')
if (!normalized || normalized.endsWith('/shell-ready/zsh')) {
return null
}
return value
}
function resolveOriginalZdotdir(env: Record<string, string>): string {
return (
normalizeOriginalZdotdirCandidate(env.ZDOTDIR) ||
normalizeOriginalZdotdirCandidate(env.ORCA_ORIG_ZDOTDIR) ||
env.HOME ||
process.env.HOME ||
''
)
}
function ensureOverlayRestoreWrappers(root: string): void {
const zshDir = join(root, 'zsh')
const bashDir = join(root, 'bash')
const zshEnv = `# Orca relay zsh overlay wrapper
export ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
case "\${ORCA_ORIG_ZDOTDIR%/}" in
*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;
esac
[[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && source "$ORCA_ORIG_ZDOTDIR/.zshenv"
export ORCA_USER_ZDOTDIR="\${ZDOTDIR:-\${ORCA_ORIG_ZDOTDIR:-$HOME}}"
case "\${ORCA_USER_ZDOTDIR%/}" in
*/shell-ready/zsh) export ORCA_USER_ZDOTDIR="$HOME" ;;
esac
export ZDOTDIR=${quotePosixSingle(zshDir)}
`
const zshProfile = `# Orca relay zsh overlay wrapper
_orca_home="\${ORCA_USER_ZDOTDIR:-\${ORCA_ORIG_ZDOTDIR:-$HOME}}"
case "\${_orca_home%/}" in
*/shell-ready/zsh) _orca_home="$HOME" ;;
esac
[[ -f "$_orca_home/.zprofile" ]] && source "$_orca_home/.zprofile"
`
const zshRc = `# Orca relay zsh overlay wrapper
_orca_home="\${ORCA_USER_ZDOTDIR:-\${ORCA_ORIG_ZDOTDIR:-$HOME}}"
case "\${_orca_home%/}" in
*/shell-ready/zsh) _orca_home="$HOME" ;;
esac
if [[ -o interactive && -f "$_orca_home/.zshrc" ]]; then
source "$_orca_home/.zshrc"
fi
if [[ ! -o login ]]; then
# Why: remote startup files can re-export user defaults after relay spawn.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
fi
`
const zshLogin = `# Orca relay zsh overlay wrapper
_orca_home="\${ORCA_USER_ZDOTDIR:-\${ORCA_ORIG_ZDOTDIR:-$HOME}}"
case "\${_orca_home%/}" in
*/shell-ready/zsh) _orca_home="$HOME" ;;
esac
if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then
source "$_orca_home/.zlogin"
fi
# Why: .zlogin is the final zsh login startup file before the prompt.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
`
const bashRc = `# Orca relay bash overlay wrapper
[[ -f /etc/profile ]] && source /etc/profile
if [[ -f "$HOME/.bash_profile" ]]; then
source "$HOME/.bash_profile"
elif [[ -f "$HOME/.bash_login" ]]; then
source "$HOME/.bash_login"
elif [[ -f "$HOME/.profile" ]]; then
source "$HOME/.profile"
fi
# Why: remote startup files can re-export user defaults after relay spawn.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
`
const files = [
[join(zshDir, '.zshenv'), zshEnv],
[join(zshDir, '.zprofile'), zshProfile],
[join(zshDir, '.zshrc'), zshRc],
[join(zshDir, '.zlogin'), zshLogin],
[join(bashDir, 'rcfile'), bashRc]
] as const
for (const [path, content] of files) {
mkdirSync(dirname(path), { recursive: true })
let existing: string | null = null
try {
existing = readFileSync(path, 'utf8')
} catch {
existing = null
}
// Why: relay wrapper files persist under ~/.orca-relay across app
// upgrades. Existence alone is not enough; stale wrappers would miss
// later fixes such as preserving post-.zshenv ZDOTDIR.
if (existing !== content) {
writeFileSync(path, content, 'utf8')
}
chmodSync(path, 0o644)
}
}
export function getRelayShellLaunchConfig(
shellPath: string,
env: Record<string, string>
): RelayShellLaunchConfig {
if (!hasOverlayRestoreEnv(env) || process.platform === 'win32') {
return { args: POSIX_LOGIN_ARGS, env: {} }
}
const shellName = basename(shellPath).toLowerCase()
if (shellName !== 'zsh' && shellName !== 'bash') {
return { args: POSIX_LOGIN_ARGS, env: {} }
}
const root = getWrapperRoot(env)
ensureOverlayRestoreWrappers(root)
if (shellName === 'zsh') {
return {
args: POSIX_LOGIN_ARGS,
env: {
ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(env),
ZDOTDIR: join(root, 'zsh')
}
}
}
return {
args: ['--rcfile', join(root, 'bash', 'rcfile')],
env: {}
}
}

View File

@ -1,4 +1,9 @@
#!/usr/bin/env node
/* oxlint-disable max-lines -- Why: the relay entry point centralizes process
lifecycle (stdio, --connect bridge, grace timer, signal handlers, socket
server) and handler registration in one file so the boot sequence stays in
topological order. Splitting by line count would scatter ordered side-
effects across modules and obscure the lifecycle. */
// Orca Relay — lightweight daemon deployed to remote hosts.
// Communicates over stdin/stdout using the framed JSON-RPC protocol.
@ -24,11 +29,14 @@ import { GitHandler } from './git-handler'
import { PreflightHandler } from './preflight-handler'
import { PortScanHandler } from './port-scan-handler'
import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server'
import { PluginOverlayManager } from './plugin-overlay'
import {
AGENT_HOOK_INSTALL_PLUGINS_METHOD,
AGENT_HOOK_NOTIFICATION_METHOD,
AGENT_HOOK_REQUEST_REPLAY_METHOD
} from '../shared/agent-hook-relay'
import { assertPluginSourceUnderByteCap } from './plugin-source-limit'
import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env'
const DEFAULT_GRACE_MS = 5 * 60 * 1000
const SOCK_NAME = 'relay.sock'
@ -247,26 +255,77 @@ async function main(): Promise<void> {
)
}
})
// Why: wait for hook-server startup before the readiness sentinel. A PTY
// spawned before the augmenter exists can never receive ORCA_AGENT_HOOK_*
// later, so success registers the augmenter first; failure is the deliberate
// fail-open path where agent status is disabled for this relay process.
// Why: await the hook-server bind before announcing readiness so the very
// first PTY spawn (which can land within milliseconds of the sentinel)
// already sees populated ORCA_AGENT_HOOK_* env. The bind is a local-loopback
// listen — measured in ms — so the latency cost is trivial and removes a
// class of "first agent invocation has no status" races. Bind failure is
// treated as soft: log and continue, the augmenter returns {} and agent
// status simply does not flow.
try {
await hookServer.start()
ptyHandler.addEnvAugmenter(() => hookServer.buildPtyEnv())
} catch (err) {
process.stderr.write(
`[relay] agent-hook server failed to start: ${err instanceof Error ? err.message : String(err)}\n`
)
}
// Why: evict the per-pane last-status cache when the backing PTY exits so
// a terminated pane's last working/done payload cannot resurface as a
// ghost event after a later reconnect — see §5 Path 3.
ptyHandler.setExitListener(({ paneKey }) => {
// Why: every relay-spawned PTY needs the live ORCA_AGENT_HOOK_* coords. The
// augmenter is read on every spawn so a hook-server bind that succeeded
// late (or after a stop/start) lands in the next PTY's env without a
// restart.
ptyHandler.addEnvAugmenter(() => hookServer.buildPtyEnv())
// Why: per-PTY plugin overlays for OpenCode and Pi. `OPENCODE_CONFIG_DIR`
// and `PI_CODING_AGENT_DIR` only make sense on the relay's own filesystem
// — paths the renderer would synthesize for the Orca host's userData are
// meaningless on the remote. The overlay manager materializes a per-PTY
// dir on the remote (rooted at $HOME/.orca-relay/) so the agent CLI inside
// the relay-spawned PTY loads the bundled status plugin and posts to the
// relay's hook server. Source bodies arrive over JSON-RPC (see
// `agent_hook.installPlugins` below) — not bundled with the relay binary.
const pluginOverlay = new PluginOverlayManager()
ptyHandler.addEnvAugmenter((ctx) => {
const env: Record<string, string> = {}
// Why: prefer paneKey for overlay identity so a renderer-side remount
// that reuses the paneKey lands in the same overlay dir. Falls back to
// the relay-internal pty-id when paneKey is absent (e.g. CLI-launched
// PTYs that don't go through the renderer).
const overlayId = ctx.paneKey ?? ctx.id
if (pluginOverlay.hasOpenCodeSource()) {
const sourceDir = resolveOpenCodeSourceConfigDir(ctx.env, ctx.shell)
const dir = pluginOverlay.materializeOpenCode(overlayId, sourceDir)
if (dir) {
env.OPENCODE_CONFIG_DIR = dir
env.ORCA_OPENCODE_CONFIG_DIR = dir
if (sourceDir) {
env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = sourceDir
}
}
}
if (pluginOverlay.hasPiSource()) {
const sourceDir = resolvePiSourceAgentDir(ctx.env, ctx.shell)
const dir = pluginOverlay.materializePi(overlayId, sourceDir)
if (dir) {
env.PI_CODING_AGENT_DIR = dir
env.ORCA_PI_CODING_AGENT_DIR = dir
if (sourceDir) {
env.ORCA_PI_SOURCE_AGENT_DIR = sourceDir
}
}
}
return env
})
// Why: evict the per-pane last-status cache AND any plugin overlay dirs
// when the backing PTY exits so terminated panes do not (a) resurface as
// ghost events after a later reconnect (§5 Path 3) or (b) leak overlay
// dirs on a long-lived relay.
ptyHandler.setExitListener(({ paneKey, id }) => {
if (paneKey) {
hookServer.clearPaneState(paneKey)
}
pluginOverlay.clearOverlay(paneKey ?? id)
})
// Why: request-driven replay. Orca issues this *after* it re-wires the
@ -280,12 +339,31 @@ async function main(): Promise<void> {
return { replayed }
})
// Why: stub for the plugin-source sync handler used by OpenCode/Pi. The
// real implementation is wired in commit #7 (deferred to keep this commit
// tight). Stubbed out here as a method-found handler so a probing client
// can detect support without -32601 noise on first connect.
dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async () => {
return { installed: false }
// Why: Orca ships the OpenCode plugin / Pi extension source bodies over
// the wire at session-ready (the renderer's bundled hook-service strings
// change as new agent events are added — pinning them to the relay binary
// would force a relay redeploy on every Orca update). Cache them so each
// subsequent PTY spawn can materialize a per-PTY overlay rooted under
// $HOME/.orca-relay/. See docs/design/agent-status-over-ssh.md §4.
// Why: bound the per-source size so a buggy/hostile Orca can't OOM the
// relay by pushing a giant string. The HTTP path has HOOK_REQUEST_MAX_BYTES
// = 1 MB; the JSON-RPC path needs an equivalent ceiling. Real plugin sources
// are <50 KB today; 256 KB leaves generous headroom.
dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => {
const opencode = params.opencodePluginSource
const pi = params.piExtensionSource
assertPluginSourceUnderByteCap('opencodePluginSource', opencode)
assertPluginSourceUnderByteCap('piExtensionSource', pi)
pluginOverlay.setSources({
opencodePluginSource: typeof opencode === 'string' ? opencode : undefined,
piExtensionSource: typeof pi === 'string' ? pi : undefined
})
return {
installed: {
opencode: pluginOverlay.hasOpenCodeSource(),
pi: pluginOverlay.hasPiSource()
}
}
})
// ── Socket server for reconnection ──────────────────────────────────
@ -488,7 +566,7 @@ function cleanupSocket(sockPath: string): void {
void main().catch((err) => {
process.stderr.write(
`[relay] Fatal startup error: ${err instanceof Error ? err.message : String(err)}\n`
`[relay] Fatal startup error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`
)
process.exit(1)
})

View File

@ -42,7 +42,9 @@ const MAX_WARNED_KEYS = 32
export const HOOK_REQUEST_SLOWLORIS_MS = 5_000
/** Bound paneKey size `${tabId}:${paneId}` is well under 200 chars in
* practice; cap defends per-pane caches against pathological input. */
* practice; cap defends per-pane caches against pathological input.
* Exported so non-HTTP ingest paths (e.g. Orca's `ingestRemote`) can apply
* the same cap as defense-in-depth. */
export const MAX_PANE_KEY_LEN = 200
/** Per-listener-instance state that holds caches needing per-PTY teardown

View File

@ -1,5 +1,4 @@
import os from 'os'
import path from 'path'
import { test, expect } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
@ -204,27 +203,6 @@ test.describe('Localhost SSH', () => {
await execInTerminal(orcaPage, ptyId, emitMarkerCommand(terminalMarker))
await waitForTerminalOutput(orcaPage, terminalMarker, 20_000)
// Why: managed hook scripts intentionally ignore Electron userData so
// dev/prod Orca instances converge on the same installed command.
const codexHookPath = path.join(os.homedir(), '.orca', 'agent-hooks', 'codex-hook.sh')
const quotedCodexHookPath = shellQuote(codexHookPath)
const codexHookStatus = await orcaPage.evaluate(() => window.api.agentHooks.codexStatus())
expect(codexHookStatus.state).toBe('installed')
const installMarker = marker('CODEX_HOOK_INSTALLED')
const installFailedMarker = marker('CODEX_HOOK_INSTALL_FAILED')
await execInTerminal(
orcaPage,
ptyId,
[
`if [ -x ${quotedCodexHookPath} ] && grep -F ${quotedCodexHookPath} "$HOME/.codex/hooks.json" >/dev/null 2>&1; then`,
` ${emitMarkerCommand(installMarker)}`,
'else',
` ${emitMarkerCommand(installFailedMarker)}`,
'fi'
].join('\n')
)
await waitForTerminalOutput(orcaPage, installMarker, 20_000)
const envMarker = marker('AGENT_HOOK_ENV_OK')
const envFailedMarker = marker('AGENT_HOOK_ENV_BAD')
await execInTerminal(
@ -241,25 +219,44 @@ test.describe('Localhost SSH', () => {
)
await waitForTerminalOutput(orcaPage, envMarker, 20_000)
const prompt = `orca ssh e2e prompt ${Date.now()}`
const hookPostedMarker = marker('AGENT_HOOK_POSTED')
const hookPayloadFile = `/tmp/orca-e2e-hook-payload-${Date.now()}.json`
const pluginOverlayMarker = marker('AGENT_PLUGIN_OVERLAYS_OK')
const pluginOverlayFailedMarker = marker('AGENT_PLUGIN_OVERLAYS_BAD')
await execInTerminal(
orcaPage,
ptyId,
[
`if [ ! -x ${quotedCodexHookPath} ]; then`,
' echo __ORCA_CODEX_HOOK_SCRIPT_MISSING__',
'elif [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
'opencode_status_file="$OPENCODE_CONFIG_DIR/plugins/orca-opencode-status.js"',
'pi_status_file="$PI_CODING_AGENT_DIR/extensions/orca-agent-status.ts"',
'if [ -n "$OPENCODE_CONFIG_DIR" ] && [ -f "$opencode_status_file" ] && [ -n "$PI_CODING_AGENT_DIR" ] && [ -f "$pi_status_file" ]; then',
` ${emitMarkerCommand(pluginOverlayMarker)}`,
'else',
` printf '%s opencode=%s opencode_file=%s pi=%s pi_file=%s\\n' ${shellQuote(pluginOverlayFailedMarker)} "$OPENCODE_CONFIG_DIR" "$opencode_status_file" "$PI_CODING_AGENT_DIR" "$pi_status_file"`,
'fi'
].join('\n')
)
await waitForTerminalOutput(orcaPage, pluginOverlayMarker, 20_000)
const prompt = `orca ssh e2e prompt ${Date.now()}`
const hookPostedMarker = marker('AGENT_HOOK_POSTED')
await execInTerminal(
orcaPage,
ptyId,
[
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' echo __ORCA_AGENT_HOOK_ENV_MISSING__',
'else',
` printf '%s' ${shellQuote(
JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt })
)} > ${shellQuote(hookPayloadFile)}`,
` /bin/sh ${quotedCodexHookPath} < ${shellQuote(hookPayloadFile)}`,
' hook_status=$?',
` rm -f ${shellQuote(hookPayloadFile)}`,
` if [ "$hook_status" -eq 0 ]; then ${emitMarkerCommand(hookPostedMarker)}; fi`,
` hook_payload=${shellQuote(JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt }))}`,
' if curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload=${hook_payload}" >/dev/null; then',
` ${emitMarkerCommand(hookPostedMarker)}`,
' fi',
'fi'
].join('\n')
)