From 2cadcbc4e1ffbda3f04dd0c6fbc02cd816f55ac2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 14 May 2026 16:06:23 -0700 Subject: [PATCH] Support agent status hooks over SSH (#1865) Co-authored-by: Orca --- config/tsconfig.relay.json | 6 +- .../installer-utils-remote.test.ts | 269 ++++++++++++ .../agent-hooks/installer-utils-remote.ts | 385 ++++++++++++++++++ .../remote-hook-service-installers.test.ts | 224 ++++++++++ src/main/agent-hooks/server.ts | 22 +- src/main/claude/hook-service.test.ts | 166 ++++++++ src/main/claude/hook-service.ts | 87 +++- src/main/codex/config-toml-trust.ts | 18 +- src/main/codex/hook-service.ts | 106 ++++- src/main/cursor/hook-service.ts | 77 +++- src/main/gemini/hook-service.ts | 69 +++- src/main/ipc/pty.test.ts | 4 + src/main/pty/overlay-mirror.ts | 12 +- src/main/pty/shell-startup-env.test.ts | 19 +- src/main/pty/shell-startup-env.ts | 90 ++-- src/main/ssh/ssh-relay-session.test.ts | 67 ++- src/main/ssh/ssh-relay-session.ts | 80 +++- src/relay/agent-hook-server.ts | 12 +- src/relay/plugin-overlay-env.test.ts | 81 ++++ src/relay/plugin-overlay-env.ts | 35 ++ src/relay/plugin-overlay.test.ts | 186 +++++++++ src/relay/plugin-overlay.ts | 276 +++++++++++++ src/relay/plugin-source-limit.test.ts | 24 ++ src/relay/plugin-source-limit.ts | 12 + src/relay/pty-handler.test.ts | 147 ++++++- src/relay/pty-handler.ts | 109 +++-- src/relay/pty-shell-launch.test.ts | 58 +++ src/relay/pty-shell-launch.ts | 165 ++++++++ src/relay/relay.ts | 110 ++++- src/shared/agent-hook-listener.ts | 4 +- tests/e2e/ssh-localhost.spec.ts | 67 ++- 31 files changed, 2829 insertions(+), 158 deletions(-) create mode 100644 src/main/agent-hooks/installer-utils-remote.test.ts create mode 100644 src/main/agent-hooks/installer-utils-remote.ts create mode 100644 src/main/agent-hooks/remote-hook-service-installers.test.ts create mode 100644 src/main/claude/hook-service.test.ts create mode 100644 src/relay/plugin-overlay-env.test.ts create mode 100644 src/relay/plugin-overlay-env.ts create mode 100644 src/relay/plugin-overlay.test.ts create mode 100644 src/relay/plugin-overlay.ts create mode 100644 src/relay/plugin-source-limit.test.ts create mode 100644 src/relay/plugin-source-limit.ts create mode 100644 src/relay/pty-shell-launch.test.ts create mode 100644 src/relay/pty-shell-launch.ts diff --git a/config/tsconfig.relay.json b/config/tsconfig.relay.json index 247b9ac74..242069f76 100644 --- a/config/tsconfig.relay.json +++ b/config/tsconfig.relay.json @@ -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, diff --git a/src/main/agent-hooks/installer-utils-remote.test.ts b/src/main/agent-hooks/installer-utils-remote.test.ts new file mode 100644 index 000000000..304ef4484 --- /dev/null +++ b/src/main/agent-hooks/installer-utils-remote.test.ts @@ -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 + dirs: Set + modes: Map + 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) + }) +}) diff --git a/src/main/agent-hooks/installer-utils-remote.ts b/src/main/agent-hooks/installer-utils-remote.ts new file mode 100644 index 000000000..63e10b45d --- /dev/null +++ b/src/main/agent-hooks/installer-utils-remote.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return new Promise((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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return new Promise((resolve, reject) => { + sftp.chmod(remotePath, mode, (err) => { + if (err) { + reject(err) + return + } + resolve() + }) + }) +} + +async function readdir(sftp: SFTPWrapper, remotePath: string): Promise { + 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 { + 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 { + 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)) +} diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts new file mode 100644 index 000000000..95db6b6ba --- /dev/null +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -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 + dirs: Set + modes: Map + failRenameTo: Set +} + +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 + } + 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 + } + 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 + } + 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() + } + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 241a0b2e8..cd715bcc5 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -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 -} diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts new file mode 100644 index 000000000..00bfdc5cb --- /dev/null +++ b/src/main/claude/hook-service.test.ts @@ -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 + dirs: Set + modes: Map +} + +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) + }) +}) diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index f2caf4881..d1d31db44 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -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 { + // 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) diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index 1bd8594e8..179ce2572 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -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 diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 6d54ff655..f7cada61a 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -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 { + 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(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) diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index 805a8d905..ed7f7f3bd 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -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 { + 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 = { ...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) diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index 4cd0e401a..7b0d418ee 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -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 { + 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) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 195685c90..1c5deb8d3 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -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 diff --git a/src/main/pty/overlay-mirror.ts b/src/main/pty/overlay-mirror.ts index d529ac139..94e690714 100644 --- a/src/main/pty/overlay-mirror.ts +++ b/src/main/pty/overlay-mirror.ts @@ -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. } } diff --git a/src/main/pty/shell-startup-env.test.ts b/src/main/pty/shell-startup-env.test.ts index 91418fd08..d41e6c7fd 100644 --- a/src/main/pty/shell-startup-env.test.ts +++ b/src/main/pty/shell-startup-env.test.ts @@ -34,13 +34,17 @@ describe('readShellStartupEnvVar', () => { }) function mockStartupFiles(files: Record) { + 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') diff --git a/src/main/pty/shell-startup-env.ts b/src/main/pty/shell-startup-env.ts index fea07dd0f..96ba85aae 100644 --- a/src/main/pty/shell-startup-env.ts +++ b/src/main/pty/shell-startup-env.ts @@ -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 } } diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index 48c248480..52660c519 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -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) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 61074ed65..d75807f86 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -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 { + 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) } diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 7865d018b..f2fc8e5d2 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -106,6 +106,11 @@ export class RelayAgentHookServer { await new Promise((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() } diff --git a/src/relay/plugin-overlay-env.test.ts b/src/relay/plugin-overlay-env.test.ts new file mode 100644 index 000000000..d23a76769 --- /dev/null +++ b/src/relay/plugin-overlay-env.test.ts @@ -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') + }) +}) diff --git a/src/relay/plugin-overlay-env.ts b/src/relay/plugin-overlay-env.ts new file mode 100644 index 000000000..431a49430 --- /dev/null +++ b/src/relay/plugin-overlay-env.ts @@ -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, + shell: string | undefined +): string | undefined { + return readShellStartupEnvVar(name, env.HOME ?? process.env.HOME, shell ?? env.SHELL) +} + +export function resolveOpenCodeSourceConfigDir( + env: Record, + 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, + 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 + ) +} diff --git a/src/relay/plugin-overlay.test.ts b/src/relay/plugin-overlay.test.ts new file mode 100644 index 000000000..5d7a7f03c --- /dev/null +++ b/src/relay/plugin-overlay.test.ts @@ -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 /plugins/', () => { + 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 /extensions/', () => { + 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) + }) +}) diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts new file mode 100644 index 000000000..905d4b5f3 --- /dev/null +++ b/src/relay/plugin-overlay.ts @@ -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 /plugins/. */ + opencodePluginSource?: string + /** Source body of `orca-agent-status.ts` to drop into /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` + ) + } + } + } +} diff --git a/src/relay/plugin-source-limit.test.ts b/src/relay/plugin-source-limit.test.ts new file mode 100644 index 000000000..870688022 --- /dev/null +++ b/src/relay/plugin-source-limit.test.ts @@ -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` + ) + }) +}) diff --git a/src/relay/plugin-source-limit.ts b/src/relay/plugin-source-limit.ts new file mode 100644 index 000000000..d06c24248 --- /dev/null +++ b/src/relay/plugin-source-limit.ts @@ -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`) + } +} diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index ec34881e9..e806237a4 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -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 }[] = [] + 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 } + const secondEnv = mockPtySpawn.mock.calls[1][2] as { env: Record } + 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 } + 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 } + 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) }) }) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index b36b7b431..b1b540cb8 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -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 +/** 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 +}) => Record export class PtyHandler { private ptys = new Map() @@ -110,11 +124,13 @@ export class PtyHandler { private dispatcher: RelayDispatcher private graceTimeMs: number private graceTimer: ReturnType | 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): Record { + private buildSpawnEnv( + rendererEnv: Record | undefined, + ctx: { id: string; paneKey?: string; shell: string } + ): Record { + const baseEnv = { ...process.env, ...rendererEnv } as Record const augmented: Record = {} 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 + 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() diff --git a/src/relay/pty-shell-launch.test.ts b/src/relay/pty-shell-launch.test.ts new file mode 100644 index 000000000..9307f22f8 --- /dev/null +++ b/src/relay/pty-shell-launch.test.ts @@ -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}}"' + ) + }) +}) diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts new file mode 100644 index 000000000..f2f3f739b --- /dev/null +++ b/src/relay/pty-shell-launch.ts @@ -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 +} + +function quotePosixSingle(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +function hasOverlayRestoreEnv(env: Record): boolean { + return Boolean(env.ORCA_OPENCODE_CONFIG_DIR || env.ORCA_PI_CODING_AGENT_DIR) +} + +function getWrapperRoot(env: Record): 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 { + 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 +): 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: {} + } +} diff --git a/src/relay/relay.ts b/src/relay/relay.ts index df6e075ad..b1bc2df32 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -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 { ) } }) - // 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 = {} + // 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 { 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) }) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index f8469ee72..f2887b308 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -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 diff --git a/tests/e2e/ssh-localhost.spec.ts b/tests/e2e/ssh-localhost.spec.ts index 14747355c..75aae7799 100644 --- a/tests/e2e/ssh-localhost.spec.ts +++ b/tests/e2e/ssh-localhost.spec.ts @@ -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') )