Support agent status hooks over SSH (#1865)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
dcf5bf0dce
commit
2cadcbc4e1
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,269 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
|
||||
import {
|
||||
readHooksJsonRemote,
|
||||
writeHooksJsonRemote,
|
||||
writeManagedScriptRemote,
|
||||
writeTextFileRemoteAtomic
|
||||
} from './installer-utils-remote'
|
||||
|
||||
type FakeFs = {
|
||||
files: Map<string, string>
|
||||
dirs: Set<string>
|
||||
modes: Map<string, number>
|
||||
openSshRenameCount: number
|
||||
}
|
||||
|
||||
function createFakeSftp(
|
||||
opts: {
|
||||
plainRenameOverwrites?: boolean
|
||||
openSshRename?: boolean
|
||||
failDotFileWrites?: boolean
|
||||
} = {}
|
||||
): {
|
||||
sftp: SFTPWrapper
|
||||
fs: FakeFs
|
||||
} {
|
||||
const plainRenameOverwrites = opts.plainRenameOverwrites ?? true
|
||||
const fs: FakeFs = {
|
||||
files: new Map(),
|
||||
dirs: new Set(['/']),
|
||||
modes: new Map(),
|
||||
openSshRenameCount: 0
|
||||
}
|
||||
const noEntryError = (path: string): { code: number; message: string } => ({
|
||||
code: 2,
|
||||
message: `ENOENT ${path}`
|
||||
})
|
||||
const fakeStats = (mode: number): { mode: number } => ({ mode })
|
||||
|
||||
const sftp = {
|
||||
readFile: (path: string, _enc: string, cb: (err: unknown, data?: string) => void): void => {
|
||||
const v = fs.files.get(path)
|
||||
if (v === undefined) {
|
||||
cb(noEntryError(path))
|
||||
return
|
||||
}
|
||||
cb(null, v)
|
||||
},
|
||||
writeFile: (
|
||||
path: string,
|
||||
content: string,
|
||||
options: string | { mode?: number },
|
||||
cb: (err: unknown) => void
|
||||
): void => {
|
||||
if (opts.failDotFileWrites && path.includes('/.')) {
|
||||
cb({ code: 4, message: `write failed ${path}` })
|
||||
return
|
||||
}
|
||||
fs.files.set(path, content)
|
||||
if (typeof options !== 'string' && options.mode !== undefined) {
|
||||
fs.modes.set(path, options.mode)
|
||||
}
|
||||
cb(null)
|
||||
},
|
||||
rename: (src: string, dst: string, cb: (err: unknown) => void): void => {
|
||||
const v = fs.files.get(src)
|
||||
if (v === undefined) {
|
||||
cb(noEntryError(src))
|
||||
return
|
||||
}
|
||||
if (!plainRenameOverwrites && fs.files.has(dst)) {
|
||||
cb({ code: 4, message: `SSH_FX_FAILURE destination exists ${dst}` })
|
||||
return
|
||||
}
|
||||
fs.files.set(dst, v)
|
||||
fs.files.delete(src)
|
||||
const mode = fs.modes.get(src)
|
||||
if (mode !== undefined) {
|
||||
fs.modes.set(dst, mode)
|
||||
fs.modes.delete(src)
|
||||
}
|
||||
cb(null)
|
||||
},
|
||||
unlink: (path: string, cb: (err: unknown) => void): void => {
|
||||
if (!fs.files.has(path)) {
|
||||
cb(noEntryError(path))
|
||||
return
|
||||
}
|
||||
fs.files.delete(path)
|
||||
fs.modes.delete(path)
|
||||
cb(null)
|
||||
},
|
||||
chmod: (path: string, mode: number, cb: (err: unknown) => void): void => {
|
||||
fs.modes.set(path, mode)
|
||||
cb(null)
|
||||
},
|
||||
stat: (path: string, cb: (err: unknown, stats?: { mode: number }) => void): void => {
|
||||
if (!fs.files.has(path)) {
|
||||
cb(noEntryError(path))
|
||||
return
|
||||
}
|
||||
cb(null, fakeStats(fs.modes.get(path) ?? 0o100644))
|
||||
},
|
||||
readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => {
|
||||
if (fs.dirs.has(path)) {
|
||||
cb(null, [])
|
||||
return
|
||||
}
|
||||
cb(noEntryError(path))
|
||||
},
|
||||
mkdir: (path: string, cb: (err: unknown) => void): void => {
|
||||
fs.dirs.add(path)
|
||||
cb(null)
|
||||
},
|
||||
...(opts.openSshRename
|
||||
? {
|
||||
ext_openssh_rename: (src: string, dst: string, cb: (err: unknown) => void): void => {
|
||||
fs.openSshRenameCount += 1
|
||||
const v = fs.files.get(src)
|
||||
if (v === undefined) {
|
||||
cb(noEntryError(src))
|
||||
return
|
||||
}
|
||||
fs.files.set(dst, v)
|
||||
fs.files.delete(src)
|
||||
const mode = fs.modes.get(src)
|
||||
if (mode !== undefined) {
|
||||
fs.modes.set(dst, mode)
|
||||
fs.modes.delete(src)
|
||||
}
|
||||
cb(null)
|
||||
}
|
||||
}
|
||||
: {})
|
||||
} as unknown as SFTPWrapper
|
||||
return { sftp, fs }
|
||||
}
|
||||
|
||||
describe('installer-utils-remote', () => {
|
||||
it('returns {} when settings.json does not exist on the remote', async () => {
|
||||
const { sftp } = createFakeSftp()
|
||||
const result = await readHooksJsonRemote(sftp, '/home/u/.claude/settings.json')
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('returns null when settings.json is malformed JSON', async () => {
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
fs.files.set('/home/u/.claude/settings.json', 'not json {{')
|
||||
const result = await readHooksJsonRemote(sftp, '/home/u/.claude/settings.json')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('rethrows non-ENOENT read errors so callers can distinguish I/O failures from parse failures', async () => {
|
||||
const sftp = {
|
||||
readFile: (_path: string, _enc: string, cb: (err: unknown) => void): void => {
|
||||
// Why: SSH_FX_PERMISSION_DENIED (3) is a real I/O failure that should
|
||||
// not collapse into the same null result the parse-error path uses.
|
||||
cb({ code: 3, message: 'permission denied' })
|
||||
}
|
||||
} as unknown as SFTPWrapper
|
||||
await expect(readHooksJsonRemote(sftp, '/home/u/.claude/settings.json')).rejects.toMatchObject({
|
||||
code: 3
|
||||
})
|
||||
})
|
||||
|
||||
it('atomically writes settings.json via tmp + rename', async () => {
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
await writeHooksJsonRemote(sftp, '/home/u/.claude/settings.json', {
|
||||
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'foo' }] }] }
|
||||
})
|
||||
expect(fs.files.has('/home/u/.claude/settings.json')).toBe(true)
|
||||
expect(fs.dirs.has('/home/u/.claude')).toBe(true)
|
||||
const contents = fs.files.get('/home/u/.claude/settings.json')!
|
||||
const parsed = JSON.parse(contents)
|
||||
expect(parsed.hooks.Stop[0].hooks[0].command).toBe('foo')
|
||||
// Tmp must be cleaned up.
|
||||
const tmp = Array.from(fs.files.keys()).find((k) => k.includes('.tmp'))
|
||||
expect(tmp).toBeUndefined()
|
||||
expect(fs.modes.get('/home/u/.claude/settings.json')).toBe(0o600)
|
||||
})
|
||||
|
||||
it('preserves existing config file mode across atomic replacement', async () => {
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
const path = '/home/u/.codex/config.toml'
|
||||
fs.files.set(path, 'old')
|
||||
fs.modes.set(path, 0o640)
|
||||
|
||||
await writeTextFileRemoteAtomic(sftp, path, 'new')
|
||||
|
||||
expect(fs.modes.get(path)).toBe(0o640)
|
||||
})
|
||||
|
||||
it('uses OpenSSH overwrite rename when an atomic write updates an existing file', async () => {
|
||||
const { sftp, fs } = createFakeSftp({
|
||||
plainRenameOverwrites: false,
|
||||
openSshRename: true
|
||||
})
|
||||
const path = '/home/u/.claude/settings.json'
|
||||
fs.files.set(path, JSON.stringify({ hooks: {} }))
|
||||
|
||||
await writeHooksJsonRemote(sftp, path, {
|
||||
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'new' }] }] }
|
||||
})
|
||||
|
||||
expect(fs.openSshRenameCount).toBe(1)
|
||||
expect(JSON.parse(fs.files.get(path)!).hooks.Stop[0].hooks[0].command).toBe('new')
|
||||
})
|
||||
|
||||
it('leaves existing files intact when overwrite rename is unavailable', async () => {
|
||||
const { sftp, fs } = createFakeSftp({ plainRenameOverwrites: false })
|
||||
const path = '/home/u/.claude/settings.json'
|
||||
fs.files.set(path, JSON.stringify({ hooks: { Stop: [] } }))
|
||||
|
||||
await expect(
|
||||
writeHooksJsonRemote(sftp, path, {
|
||||
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'fallback' }] }] }
|
||||
})
|
||||
).rejects.toMatchObject({ code: 4 })
|
||||
|
||||
expect(JSON.parse(fs.files.get(path)!).hooks.Stop).toEqual([])
|
||||
expect(Array.from(fs.files.keys()).some((key) => key.includes('.tmp'))).toBe(false)
|
||||
})
|
||||
|
||||
it('writes the managed script and chmods 0o755', async () => {
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
await writeManagedScriptRemote(sftp, '/home/u/.orca/agent-hooks/claude-hook.sh', '#!/bin/sh\n')
|
||||
expect(fs.files.get('/home/u/.orca/agent-hooks/claude-hook.sh')).toBe('#!/bin/sh\n')
|
||||
expect(fs.modes.get('/home/u/.orca/agent-hooks/claude-hook.sh')).toBe(0o755)
|
||||
})
|
||||
|
||||
it('replaces an existing managed script atomically via temp file rename', async () => {
|
||||
const { sftp, fs } = createFakeSftp({
|
||||
plainRenameOverwrites: false,
|
||||
openSshRename: true
|
||||
})
|
||||
const path = '/home/u/.orca/agent-hooks/claude-hook.sh'
|
||||
fs.files.set(path, 'old script')
|
||||
|
||||
await writeManagedScriptRemote(sftp, path, 'new script')
|
||||
|
||||
expect(fs.files.get(path)).toBe('new script')
|
||||
expect(fs.modes.get(path)).toBe(0o755)
|
||||
expect(Array.from(fs.files.keys()).some((key) => key.includes('.orca-backup-'))).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves the existing managed script intact when temp write fails', async () => {
|
||||
const { sftp, fs } = createFakeSftp({ failDotFileWrites: true })
|
||||
const path = '/home/u/.orca/agent-hooks/claude-hook.sh'
|
||||
fs.files.set(path, 'old script')
|
||||
|
||||
await expect(writeManagedScriptRemote(sftp, path, 'new script')).rejects.toMatchObject({
|
||||
code: 4
|
||||
})
|
||||
expect(fs.files.get(path)).toBe('old script')
|
||||
})
|
||||
|
||||
it('skips a no-op write when contents already match', async () => {
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
const path = '/home/u/.claude/settings.json'
|
||||
await writeHooksJsonRemote(sftp, path, { hooks: {} })
|
||||
const beforeKey = fs.files.get(path)
|
||||
// Re-writing the same payload should produce the same content; there is
|
||||
// no rename/tmp cycle visible to a downstream observer beyond the
|
||||
// identical file body.
|
||||
await writeHooksJsonRemote(sftp, path, { hooks: {} })
|
||||
expect(fs.files.get(path)).toBe(beforeKey)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,385 @@
|
|||
// Why: SFTP-backed equivalents of `installer-utils.ts` for the remote-install
|
||||
// flow. Each function takes an `sftp` handle plus paths the agent CLI expects
|
||||
// on the remote (e.g. `~/.claude/settings.json`). Lives in `agent-hooks/`
|
||||
// because it shares the contract with the local installer (script body,
|
||||
// hook-event shape, atomic-rename semantics) and any drift between them is
|
||||
// exactly the bug we want to avoid.
|
||||
//
|
||||
// We deliberately keep the JSON merge logic in the existing
|
||||
// `installer-utils.ts` and only swap fs primitives — the JSON shape and
|
||||
// managed-command matching must stay identical to the local install.
|
||||
//
|
||||
// See docs/design/agent-status-over-ssh.md §8 (commit #8).
|
||||
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { SFTPWrapper, FileEntryWithStats } from 'ssh2'
|
||||
|
||||
import { isPlainObject, type HooksConfig } from './installer-utils'
|
||||
|
||||
const DEFAULT_REMOTE_CONFIG_MODE = 0o600
|
||||
|
||||
/** Read+JSON-parse a remote file. Returns `null` on parse failure (caller
|
||||
* surfaces "could not parse" status to the UI), `{}` on missing file
|
||||
* (matches local behavior — first-install case). Rethrows on other I/O
|
||||
* failures (permission denied, EIO, channel closed) so the caller can
|
||||
* distinguish transient SFTP errors from a malformed-JSON case rather
|
||||
* than collapsing both into a misleading "could not parse" diagnostic. */
|
||||
export async function readHooksJsonRemote(
|
||||
sftp: SFTPWrapper,
|
||||
remotePath: string
|
||||
): Promise<HooksConfig | null> {
|
||||
let body: string
|
||||
try {
|
||||
body = await readFile(sftp, remotePath)
|
||||
} catch (err) {
|
||||
if (isNoEntryError(err)) {
|
||||
return {}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(body)
|
||||
return isPlainObject(parsed) ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically write a JSON config to the remote — write to a tmp path then
|
||||
* rename, mirroring the local writeHooksJson contract. The .bak rotation is
|
||||
* intentionally NOT carried over: the remote file is the user's, and a
|
||||
* per-target backup convention belongs alongside the remote installer UI
|
||||
* (out of scope for this commit). */
|
||||
export async function writeHooksJsonRemote(
|
||||
sftp: SFTPWrapper,
|
||||
remotePath: string,
|
||||
config: HooksConfig
|
||||
): Promise<void> {
|
||||
const dir = dirnamePosix(remotePath)
|
||||
await mkdirpRemote(sftp, dir)
|
||||
const serialized = `${JSON.stringify(config, null, 2)}\n`
|
||||
// Why: skip the write when on-disk content is identical so repeated
|
||||
// install() calls do not bump the file's mtime / inode unnecessarily.
|
||||
try {
|
||||
const existing = await readFile(sftp, remotePath)
|
||||
if (existing === serialized) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// ENOENT or read error — fall through to the write below.
|
||||
}
|
||||
// Why: tmp + rename so a partial network drop mid-write does not leave a
|
||||
// truncated settings.json that the agent CLI would refuse to load.
|
||||
const tmp = `${dir}/.${Date.now()}-${randomUUID()}.tmp`
|
||||
try {
|
||||
const mode = await getRemoteFileModeOrDefault(sftp, remotePath, DEFAULT_REMOTE_CONFIG_MODE)
|
||||
await writeFile(sftp, tmp, serialized, mode)
|
||||
await chmod(sftp, tmp, mode)
|
||||
await rename(sftp, tmp, remotePath)
|
||||
} finally {
|
||||
// Best-effort cleanup if rename failed.
|
||||
try {
|
||||
await unlink(sftp, tmp)
|
||||
} catch {
|
||||
// already gone or never created
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Write the managed hook script to the remote and chmod 0o755. POSIX-only —
|
||||
* the relay deliberately does not support Windows-remote in v1 (see design
|
||||
* doc §3 + §6). */
|
||||
export async function writeManagedScriptRemote(
|
||||
sftp: SFTPWrapper,
|
||||
remotePath: string,
|
||||
content: string
|
||||
): Promise<void> {
|
||||
const dir = dirnamePosix(remotePath)
|
||||
await mkdirpRemote(sftp, dir)
|
||||
try {
|
||||
const existing = await readFile(sftp, remotePath)
|
||||
if (existing === content) {
|
||||
await chmod(sftp, remotePath, 0o755)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// ENOENT or read error — fall through to the atomic write below.
|
||||
}
|
||||
|
||||
// Why: existing configs may already invoke this script. Write/chmod a temp
|
||||
// file first, then rename it into place so interrupted reinstalls do not
|
||||
// leave the configured hook path truncated or non-executable.
|
||||
const tmp = `${dir}/.${Date.now()}-${randomUUID()}.tmp`
|
||||
try {
|
||||
await writeFile(sftp, tmp, content, 0o755)
|
||||
await chmod(sftp, tmp, 0o755)
|
||||
await rename(sftp, tmp, remotePath)
|
||||
} finally {
|
||||
try {
|
||||
await unlink(sftp, tmp)
|
||||
} catch {
|
||||
// already gone or never created
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function readTextFileRemote(
|
||||
sftp: SFTPWrapper,
|
||||
remotePath: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(sftp, remotePath)
|
||||
} catch (err) {
|
||||
if (isNoEntryError(err)) {
|
||||
return null
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeTextFileRemoteAtomic(
|
||||
sftp: SFTPWrapper,
|
||||
remotePath: string,
|
||||
content: string
|
||||
): Promise<void> {
|
||||
const dir = dirnamePosix(remotePath)
|
||||
await mkdirpRemote(sftp, dir)
|
||||
try {
|
||||
const existing = await readFile(sftp, remotePath)
|
||||
if (existing === content) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// ENOENT or read error — fall through to the atomic write below.
|
||||
}
|
||||
|
||||
const tmp = `${dir}/.${Date.now()}-${randomUUID()}.tmp`
|
||||
try {
|
||||
const mode = await getRemoteFileModeOrDefault(sftp, remotePath, DEFAULT_REMOTE_CONFIG_MODE)
|
||||
await writeFile(sftp, tmp, content, mode)
|
||||
await chmod(sftp, tmp, mode)
|
||||
await rename(sftp, tmp, remotePath)
|
||||
} finally {
|
||||
try {
|
||||
await unlink(sftp, tmp)
|
||||
} catch {
|
||||
// already gone or never created
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Private SFTP primitives ────────────────────────────────────────
|
||||
|
||||
async function readFile(sftp: SFTPWrapper, remotePath: string): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
sftp.readFile(remotePath, 'utf8', (err, data) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve(typeof data === 'string' ? data : data.toString('utf8'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function writeFile(
|
||||
sftp: SFTPWrapper,
|
||||
remotePath: string,
|
||||
content: string,
|
||||
mode?: number
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options =
|
||||
mode === undefined ? { encoding: 'utf8' as const } : { encoding: 'utf8' as const, mode }
|
||||
sftp.writeFile(remotePath, content, options, (err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function statMode(sftp: SFTPWrapper, remotePath: string): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.stat(remotePath, (err, stats) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve(stats.mode & 0o7777)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function getRemoteFileModeOrDefault(
|
||||
sftp: SFTPWrapper,
|
||||
remotePath: string,
|
||||
defaultMode: number
|
||||
): Promise<number> {
|
||||
try {
|
||||
return await statMode(sftp, remotePath)
|
||||
} catch (err) {
|
||||
if (isNoEntryError(err)) {
|
||||
return defaultMode
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function rename(sftp: SFTPWrapper, src: string, dst: string): Promise<void> {
|
||||
if (typeof sftp.ext_openssh_rename === 'function') {
|
||||
try {
|
||||
await renameOpenSsh(sftp, src, dst)
|
||||
return
|
||||
} catch (err) {
|
||||
if (!isUnsupportedExtensionError(err)) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: servers without OpenSSH overwrite-rename cannot safely replace an
|
||||
// existing live config path. Renaming dst aside would leave settings.json
|
||||
// missing if the SFTP channel dies before src is moved into place, so fail
|
||||
// closed and keep the existing file intact.
|
||||
await renamePlain(sftp, src, dst)
|
||||
}
|
||||
|
||||
async function renamePlain(sftp: SFTPWrapper, src: string, dst: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.rename(src, dst, (err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function renameOpenSsh(sftp: SFTPWrapper, src: string, dst: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.ext_openssh_rename(src, dst, (err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function unlink(sftp: SFTPWrapper, remotePath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.unlink(remotePath, (err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function chmod(sftp: SFTPWrapper, remotePath: string, mode: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.chmod(remotePath, mode, (err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function readdir(sftp: SFTPWrapper, remotePath: string): Promise<FileEntryWithStats[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.readdir(remotePath, (err, list) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve(list)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function mkdir(sftp: SFTPWrapper, remotePath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.mkdir(remotePath, (err) => {
|
||||
if (err) {
|
||||
// SSH_FX_FAILURE (4) often means "already exists" on OpenSSH; we
|
||||
// probe with stat afterwards rather than parse the error code.
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function mkdirpRemote(sftp: SFTPWrapper, remotePath: string): Promise<void> {
|
||||
if (remotePath === '/' || remotePath === '' || remotePath === '.') {
|
||||
return
|
||||
}
|
||||
// Why: walk the path top-down rather than bottom-up so an existing parent
|
||||
// chain doesn't cost a full readdir per segment. POSIX-only — Windows-
|
||||
// remote is out of scope for v1.
|
||||
const segments = remotePath.split('/').filter((s) => s.length > 0)
|
||||
let current = remotePath.startsWith('/') ? '' : '.'
|
||||
for (const seg of segments) {
|
||||
current = current === '' ? `/${seg}` : current === '.' ? seg : `${current}/${seg}`
|
||||
try {
|
||||
await readdir(sftp, current)
|
||||
} catch {
|
||||
try {
|
||||
await mkdir(sftp, current)
|
||||
} catch (err) {
|
||||
// Why: re-raise only when the dir really isn't there. SSH_FX_FAILURE
|
||||
// on a concurrent mkdir from another client is harmless — readdir on
|
||||
// the next iteration will succeed.
|
||||
if (!isAlreadyExistsError(err)) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function dirnamePosix(p: string): string {
|
||||
const idx = p.lastIndexOf('/')
|
||||
if (idx <= 0) {
|
||||
return idx === 0 ? '/' : '.'
|
||||
}
|
||||
return p.slice(0, idx)
|
||||
}
|
||||
|
||||
function isNoEntryError(err: unknown): boolean {
|
||||
if (!err || typeof err !== 'object') {
|
||||
return false
|
||||
}
|
||||
// ssh2 surfaces SFTP errors with `code === 2` (SSH_FX_NO_SUCH_FILE).
|
||||
return (err as { code?: unknown }).code === 2
|
||||
}
|
||||
|
||||
function isAlreadyExistsError(err: unknown): boolean {
|
||||
if (!err || typeof err !== 'object') {
|
||||
return false
|
||||
}
|
||||
// SSH_FX_FAILURE (4) is OpenSSH's catch-all for "exists" alongside other
|
||||
// mkdir failures; we accept the ambiguity and let the next readdir prove
|
||||
// success.
|
||||
return (err as { code?: unknown }).code === 4
|
||||
}
|
||||
|
||||
function isUnsupportedExtensionError(err: unknown): boolean {
|
||||
if (!err || typeof err !== 'object') {
|
||||
return false
|
||||
}
|
||||
const code = (err as { code?: unknown }).code
|
||||
const message = (err as { message?: unknown }).message
|
||||
return code === 8 || (typeof message === 'string' && /unsupported/i.test(message))
|
||||
}
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => '/tmp/orca-user-data'
|
||||
}
|
||||
}))
|
||||
|
||||
import { CodexHookService } from '../codex/hook-service'
|
||||
import { CursorHookService } from '../cursor/hook-service'
|
||||
import { GeminiHookService } from '../gemini/hook-service'
|
||||
import { ClaudeHookService } from '../claude/hook-service'
|
||||
|
||||
type FakeFs = {
|
||||
files: Map<string, string>
|
||||
dirs: Set<string>
|
||||
modes: Map<string, number>
|
||||
failRenameTo: Set<string>
|
||||
}
|
||||
|
||||
function createFakeSftp(): { sftp: SFTPWrapper; fs: FakeFs } {
|
||||
const fs: FakeFs = {
|
||||
files: new Map(),
|
||||
dirs: new Set(['/']),
|
||||
modes: new Map(),
|
||||
failRenameTo: new Set()
|
||||
}
|
||||
const noEntryError = (path: string): { code: number; message: string } => ({
|
||||
code: 2,
|
||||
message: `ENOENT ${path}`
|
||||
})
|
||||
const fakeStats = (mode: number): { mode: number } => ({ mode })
|
||||
|
||||
const sftp = {
|
||||
readFile: (path: string, _enc: string, cb: (err: unknown, data?: string) => void): void => {
|
||||
const v = fs.files.get(path)
|
||||
if (v === undefined) {
|
||||
cb(noEntryError(path))
|
||||
return
|
||||
}
|
||||
cb(null, v)
|
||||
},
|
||||
writeFile: (
|
||||
path: string,
|
||||
content: string,
|
||||
options: string | { mode?: number },
|
||||
cb: (err: unknown) => void
|
||||
): void => {
|
||||
fs.files.set(path, content)
|
||||
if (typeof options !== 'string' && options.mode !== undefined) {
|
||||
fs.modes.set(path, options.mode)
|
||||
}
|
||||
cb(null)
|
||||
},
|
||||
rename: (src: string, dst: string, cb: (err: unknown) => void): void => {
|
||||
if (fs.failRenameTo.has(dst)) {
|
||||
cb({ code: 4, message: `rename failed ${dst}` })
|
||||
return
|
||||
}
|
||||
const v = fs.files.get(src)
|
||||
if (v === undefined) {
|
||||
cb(noEntryError(src))
|
||||
return
|
||||
}
|
||||
fs.files.set(dst, v)
|
||||
fs.files.delete(src)
|
||||
const mode = fs.modes.get(src)
|
||||
if (mode !== undefined) {
|
||||
fs.modes.set(dst, mode)
|
||||
fs.modes.delete(src)
|
||||
}
|
||||
cb(null)
|
||||
},
|
||||
unlink: (path: string, cb: (err: unknown) => void): void => {
|
||||
fs.files.delete(path)
|
||||
fs.modes.delete(path)
|
||||
cb(null)
|
||||
},
|
||||
chmod: (path: string, mode: number, cb: (err: unknown) => void): void => {
|
||||
fs.modes.set(path, mode)
|
||||
cb(null)
|
||||
},
|
||||
stat: (path: string, cb: (err: unknown, stats?: { mode: number }) => void): void => {
|
||||
if (!fs.files.has(path)) {
|
||||
cb(noEntryError(path))
|
||||
return
|
||||
}
|
||||
cb(null, fakeStats(fs.modes.get(path) ?? 0o100644))
|
||||
},
|
||||
readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => {
|
||||
if (fs.dirs.has(path)) {
|
||||
cb(null, [])
|
||||
return
|
||||
}
|
||||
cb(noEntryError(path))
|
||||
},
|
||||
mkdir: (path: string, cb: (err: unknown) => void): void => {
|
||||
fs.dirs.add(path)
|
||||
cb(null)
|
||||
}
|
||||
} as unknown as SFTPWrapper
|
||||
return { sftp, fs }
|
||||
}
|
||||
|
||||
describe('remote hook service installers', () => {
|
||||
it('always writes POSIX scripts for SSH remotes even from a Windows host', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
try {
|
||||
const installers = [
|
||||
{
|
||||
path: '/home/dev/.orca/agent-hooks/claude-hook.sh',
|
||||
install: (sftp: SFTPWrapper) => new ClaudeHookService().installRemote(sftp, '/home/dev')
|
||||
},
|
||||
{
|
||||
path: '/home/dev/.orca/agent-hooks/codex-hook.sh',
|
||||
install: (sftp: SFTPWrapper) => new CodexHookService().installRemote(sftp, '/home/dev')
|
||||
},
|
||||
{
|
||||
path: '/home/dev/.orca/agent-hooks/gemini-hook.sh',
|
||||
install: (sftp: SFTPWrapper) => new GeminiHookService().installRemote(sftp, '/home/dev')
|
||||
},
|
||||
{
|
||||
path: '/home/dev/.orca/agent-hooks/cursor-hook.sh',
|
||||
install: (sftp: SFTPWrapper) => new CursorHookService().installRemote(sftp, '/home/dev')
|
||||
}
|
||||
]
|
||||
|
||||
for (const { install, path } of installers) {
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
const status = await install(sftp)
|
||||
expect(status.state).toBe('installed')
|
||||
const script = fs.files.get(path)
|
||||
expect(script).toMatch(/^#!\/bin\/sh\n/)
|
||||
expect(script).not.toContain('@echo off')
|
||||
expect(script).not.toContain('powershell -NoProfile')
|
||||
}
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('installs remote Codex hooks with matching trust entries', async () => {
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
|
||||
const status = await new CodexHookService().installRemote(sftp, '/home/dev/')
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
expect(status.configPath).toBe('/home/dev/.codex/hooks.json')
|
||||
const hooks = JSON.parse(fs.files.get('/home/dev/.codex/hooks.json')!) as {
|
||||
hooks: Record<string, { hooks: { command: string }[] }[]>
|
||||
}
|
||||
for (const eventName of [
|
||||
'SessionStart',
|
||||
'UserPromptSubmit',
|
||||
'PreToolUse',
|
||||
'PermissionRequest',
|
||||
'PostToolUse',
|
||||
'Stop'
|
||||
]) {
|
||||
const command = hooks.hooks[eventName]?.[0]?.hooks?.[0]?.command
|
||||
expect(command).toContain('/home/dev/.orca/agent-hooks/codex-hook.sh')
|
||||
expect(command).toMatch(/^if \[ -x /)
|
||||
}
|
||||
expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh')
|
||||
expect(fs.modes.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toBe(0o755)
|
||||
const toml = fs.files.get('/home/dev/.codex/config.toml')
|
||||
expect(toml).toContain('/home/dev/.codex/hooks.json:permission_request:0:0')
|
||||
expect(toml).toContain('trusted_hash = "sha256:')
|
||||
})
|
||||
|
||||
it('reports Codex trust-write failures without rolling back installed hooks', async () => {
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
fs.failRenameTo.add('/home/dev/.codex/config.toml')
|
||||
|
||||
const status = await new CodexHookService().installRemote(sftp, '/home/dev')
|
||||
|
||||
expect(status.state).toBe('error')
|
||||
expect(status.managedHooksPresent).toBe(true)
|
||||
expect(status.detail).toContain('trust entries could not be written')
|
||||
expect(fs.files.get('/home/dev/.codex/hooks.json')).toContain('codex-hook.sh')
|
||||
expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh')
|
||||
})
|
||||
|
||||
it('installs remote Gemini and Cursor configs using their CLI-specific schemas', async () => {
|
||||
const gemini = createFakeSftp()
|
||||
const cursor = createFakeSftp()
|
||||
|
||||
await new GeminiHookService().installRemote(gemini.sftp, '/home/dev')
|
||||
await new CursorHookService().installRemote(cursor.sftp, '/home/dev')
|
||||
|
||||
const geminiConfig = JSON.parse(gemini.fs.files.get('/home/dev/.gemini/settings.json')!) as {
|
||||
hooks: Record<string, { hooks: { command: string }[] }[]>
|
||||
}
|
||||
for (const eventName of ['BeforeAgent', 'AfterAgent', 'AfterTool', 'PreToolUse']) {
|
||||
const command = geminiConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command
|
||||
expect(command).toContain('/home/dev/.orca/agent-hooks/gemini-hook.sh')
|
||||
expect(command).toMatch(/^if \[ -x /)
|
||||
}
|
||||
|
||||
const cursorConfig = JSON.parse(cursor.fs.files.get('/home/dev/.cursor/hooks.json')!) as {
|
||||
version: number
|
||||
hooks: Record<string, { command?: string; hooks?: unknown[] }[]>
|
||||
}
|
||||
expect(cursorConfig.version).toBe(1)
|
||||
for (const eventName of [
|
||||
'beforeSubmitPrompt',
|
||||
'stop',
|
||||
'preToolUse',
|
||||
'postToolUse',
|
||||
'postToolUseFailure',
|
||||
'beforeShellExecution',
|
||||
'beforeMCPExecution',
|
||||
'afterAgentResponse'
|
||||
]) {
|
||||
const definition = cursorConfig.hooks[eventName]?.[0]
|
||||
expect(definition?.command).toContain('/home/dev/.orca/agent-hooks/cursor-hook.sh')
|
||||
expect(definition?.hooks).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
// Why: locks in the remote-install contract so a refactor cannot silently
|
||||
// drift the produced settings.json shape, the wrapper-quoted command path,
|
||||
// or the script body that lands on the remote box. Local install behavior
|
||||
// is exercised through `installer-utils.test.ts` and the per-CLI status
|
||||
// audit; this file covers ONLY the SFTP-backed path added in commit #8.
|
||||
import { vi, describe, expect, it } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => '/tmp/userData'
|
||||
}
|
||||
}))
|
||||
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
import { ClaudeHookService } from './hook-service'
|
||||
|
||||
type FakeFs = {
|
||||
files: Map<string, string>
|
||||
dirs: Set<string>
|
||||
modes: Map<string, number>
|
||||
}
|
||||
|
||||
function createFakeSftp(): { sftp: SFTPWrapper; fs: FakeFs } {
|
||||
const fs: FakeFs = {
|
||||
files: new Map(),
|
||||
dirs: new Set(['/']),
|
||||
modes: new Map()
|
||||
}
|
||||
const noEntryError = (path: string): { code: number; message: string } => ({
|
||||
code: 2,
|
||||
message: `ENOENT ${path}`
|
||||
})
|
||||
const fakeStats = (mode: number): { mode: number } => ({ mode })
|
||||
const sftp = {
|
||||
readFile: (path: string, _enc: string, cb: (err: unknown, data?: string) => void): void => {
|
||||
const v = fs.files.get(path)
|
||||
if (v === undefined) {
|
||||
cb(noEntryError(path))
|
||||
return
|
||||
}
|
||||
cb(null, v)
|
||||
},
|
||||
writeFile: (
|
||||
path: string,
|
||||
content: string,
|
||||
options: string | { mode?: number },
|
||||
cb: (err: unknown) => void
|
||||
): void => {
|
||||
fs.files.set(path, content)
|
||||
if (typeof options !== 'string' && options.mode !== undefined) {
|
||||
fs.modes.set(path, options.mode)
|
||||
}
|
||||
cb(null)
|
||||
},
|
||||
rename: (src: string, dst: string, cb: (err: unknown) => void): void => {
|
||||
const v = fs.files.get(src)
|
||||
if (v === undefined) {
|
||||
cb(noEntryError(src))
|
||||
return
|
||||
}
|
||||
fs.files.set(dst, v)
|
||||
fs.files.delete(src)
|
||||
const mode = fs.modes.get(src)
|
||||
if (mode !== undefined) {
|
||||
fs.modes.set(dst, mode)
|
||||
fs.modes.delete(src)
|
||||
}
|
||||
cb(null)
|
||||
},
|
||||
unlink: (path: string, cb: (err: unknown) => void): void => {
|
||||
fs.files.delete(path)
|
||||
fs.modes.delete(path)
|
||||
cb(null)
|
||||
},
|
||||
chmod: (path: string, mode: number, cb: (err: unknown) => void): void => {
|
||||
fs.modes.set(path, mode)
|
||||
cb(null)
|
||||
},
|
||||
stat: (path: string, cb: (err: unknown, stats?: { mode: number }) => void): void => {
|
||||
if (!fs.files.has(path)) {
|
||||
cb(noEntryError(path))
|
||||
return
|
||||
}
|
||||
cb(null, fakeStats(fs.modes.get(path) ?? 0o100644))
|
||||
},
|
||||
readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => {
|
||||
if (fs.dirs.has(path)) {
|
||||
cb(null, [])
|
||||
return
|
||||
}
|
||||
cb(noEntryError(path))
|
||||
},
|
||||
mkdir: (path: string, cb: (err: unknown) => void): void => {
|
||||
fs.dirs.add(path)
|
||||
cb(null)
|
||||
}
|
||||
} as unknown as SFTPWrapper
|
||||
return { sftp, fs }
|
||||
}
|
||||
|
||||
describe('ClaudeHookService.installRemote', () => {
|
||||
it('writes settings.json + managed script under the remote $HOME', async () => {
|
||||
const svc = new ClaudeHookService()
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
const status = await svc.installRemote(sftp, '/home/dev')
|
||||
expect(status.state).toBe('installed')
|
||||
expect(status.configPath).toBe('/home/dev/.claude/settings.json')
|
||||
const settings = fs.files.get('/home/dev/.claude/settings.json')
|
||||
expect(settings).toBeTruthy()
|
||||
const parsed = JSON.parse(settings!)
|
||||
// Why: every load-bearing event must be present and point at the
|
||||
// remote-shaped script path with the `if [ -x ... ]; then ... fi`
|
||||
// wrapper applied. Drift in any of these is a real bug — Claude
|
||||
// Code rejects unknown shapes silently and the agent-hooks pipeline
|
||||
// goes dark.
|
||||
for (const event of [
|
||||
'UserPromptSubmit',
|
||||
'Stop',
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
'PermissionRequest'
|
||||
]) {
|
||||
expect(parsed.hooks[event]).toBeTruthy()
|
||||
const cmd = parsed.hooks[event][0].hooks[0].command as string
|
||||
expect(cmd).toContain('/home/dev/.orca/agent-hooks/claude-hook.sh')
|
||||
expect(cmd).toMatch(/^if \[ -x /)
|
||||
}
|
||||
// Managed script body
|
||||
expect(fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toContain('#!/bin/sh')
|
||||
expect(fs.modes.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toBe(0o755)
|
||||
})
|
||||
|
||||
it('reports parse error when remote settings.json is malformed', async () => {
|
||||
const svc = new ClaudeHookService()
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
fs.files.set('/home/dev/.claude/settings.json', 'not json')
|
||||
const status = await svc.installRemote(sftp, '/home/dev')
|
||||
expect(status.state).toBe('error')
|
||||
expect(status.detail).toContain('Could not parse')
|
||||
})
|
||||
|
||||
it('preserves user-authored hook entries on a fresh install', async () => {
|
||||
const svc = new ClaudeHookService()
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
fs.files.set(
|
||||
'/home/dev/.claude/settings.json',
|
||||
JSON.stringify({
|
||||
hooks: {
|
||||
Stop: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: '/usr/local/bin/my-user-hook' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
)
|
||||
await svc.installRemote(sftp, '/home/dev')
|
||||
const parsed = JSON.parse(fs.files.get('/home/dev/.claude/settings.json')!)
|
||||
// Original user-authored entry survives alongside the new managed entry.
|
||||
const stopDefs = parsed.hooks.Stop as { hooks: { command: string }[] }[]
|
||||
const userCmds = stopDefs.flatMap((d) => d.hooks.map((h) => h.command))
|
||||
expect(userCmds).toContain('/usr/local/bin/my-user-hook')
|
||||
expect(userCmds.some((c) => c.includes('claude-hook.sh'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
import {
|
||||
createManagedCommandMatcher,
|
||||
|
|
@ -11,6 +12,11 @@ import {
|
|||
writeManagedScript,
|
||||
type HookDefinition
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
readHooksJsonRemote,
|
||||
writeHooksJsonRemote,
|
||||
writeManagedScriptRemote
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
|
||||
const CLAUDE_EVENTS = [
|
||||
{ eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } },
|
||||
|
|
@ -61,8 +67,8 @@ function getManagedCommand(scriptPath: string): string {
|
|||
return wrapPosixHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
function getManagedScript(): string {
|
||||
if (process.platform === 'win32') {
|
||||
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
if (target === 'local' && process.platform === 'win32') {
|
||||
return [
|
||||
'@echo off',
|
||||
'setlocal',
|
||||
|
|
@ -215,6 +221,83 @@ export class ClaudeHookService {
|
|||
return this.getStatus()
|
||||
}
|
||||
|
||||
// Why: install Orca's managed Claude hooks on the remote box rather than
|
||||
// the local Mac/Linux machine. Caller passes the user's SFTP handle from
|
||||
// the SshConnection plus the resolved remote `$HOME` (used to compute
|
||||
// ~/.claude/settings.json on the target). POSIX-only by design — see
|
||||
// docs/design/agent-status-over-ssh.md §3 / §6 (Windows-remote deferred).
|
||||
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
|
||||
// Why: remote-Windows is out of scope for v1 — we ship POSIX-shaped paths
|
||||
// (`~/.claude/settings.json`) and a `.sh` managed script body. The remote
|
||||
// platform is gated by the relay's capability RPC at a higher layer; we
|
||||
// cannot detect it from `process.platform` here (that's the local box).
|
||||
const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.claude/settings.json`
|
||||
const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/claude-hook.sh`
|
||||
// Why: SFTP reads/writes fail far more often than local fs (network drops,
|
||||
// EACCES on remote dirs, disk full, channel closed). Wrap the entire
|
||||
// install flow in try/catch so a transient I/O failure surfaces as a
|
||||
// structured `state: 'error'` result for the UI, not an unstructured
|
||||
// rejection the caller has to remember to handle. A `null` config
|
||||
// specifically means "file present but unparseable" — keep that branch
|
||||
// distinct so the user sees an actionable message.
|
||||
try {
|
||||
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'claude',
|
||||
state: 'error',
|
||||
configPath: remoteConfigPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse remote Claude settings.json'
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the POSIX wrapper is identical regardless of where the script
|
||||
// lands; only the path differs. Reuse the same wrapper helper.
|
||||
const command = wrapPosixHookCommand(remoteScriptPath)
|
||||
const nextHooks = { ...config.hooks }
|
||||
const isManagedCommand = createManagedCommandMatcher('claude-hook.sh')
|
||||
|
||||
for (const event of CLAUDE_EVENTS) {
|
||||
const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : []
|
||||
const cleaned = removeManagedCommands(current, isManagedCommand)
|
||||
const definition: HookDefinition = {
|
||||
...event.definition,
|
||||
hooks: [{ type: 'command', command }]
|
||||
}
|
||||
nextHooks[event.eventName] = [...cleaned, definition]
|
||||
}
|
||||
config.hooks = nextHooks
|
||||
|
||||
// Why: write the script first, then the settings — settings.json
|
||||
// referencing a missing script body would fire `command not found` on
|
||||
// every tool call until the user re-runs install. Doing it in this
|
||||
// order means a partial-failure mid-install at worst leaves the user
|
||||
// with a working script no settings.json points at (a no-op), instead
|
||||
// of broken settings.json.
|
||||
// Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is
|
||||
// running on Windows; never derive remote script syntax from local OS.
|
||||
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
|
||||
await writeHooksJsonRemote(sftp, remoteConfigPath, config)
|
||||
|
||||
return {
|
||||
agent: 'claude',
|
||||
state: 'installed',
|
||||
configPath: remoteConfigPath,
|
||||
managedHooksPresent: true,
|
||||
detail: null
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
agent: 'claude',
|
||||
state: 'error',
|
||||
configPath: remoteConfigPath,
|
||||
managedHooksPresent: false,
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remove(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const config = readHooksJson(configPath)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/* eslint-disable max-lines -- Why: getStatus + install + remove all share the managed-command and trust-key derivation. Splitting would hide that the three operations must agree on group index, event label, and command bytes. */
|
||||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
import {
|
||||
createManagedCommandMatcher,
|
||||
|
|
@ -12,12 +13,20 @@ import {
|
|||
writeManagedScript,
|
||||
type HookDefinition
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
readHooksJsonRemote,
|
||||
readTextFileRemote,
|
||||
writeHooksJsonRemote,
|
||||
writeManagedScriptRemote,
|
||||
writeTextFileRemoteAtomic
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
import {
|
||||
computeTrustKey,
|
||||
computeTrustedHash,
|
||||
parseTrustKey,
|
||||
readHookTrustEntries,
|
||||
removeHookTrustEntries,
|
||||
upsertHookTrustEntriesInContent,
|
||||
upsertHookTrustEntries,
|
||||
type CodexEventLabel,
|
||||
type CodexHookTrustState,
|
||||
|
|
@ -71,8 +80,8 @@ function getManagedCommand(scriptPath: string): string {
|
|||
return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
function getManagedScript(): string {
|
||||
if (process.platform === 'win32') {
|
||||
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
if (target === 'local' && process.platform === 'win32') {
|
||||
return [
|
||||
'@echo off',
|
||||
'setlocal',
|
||||
|
|
@ -329,6 +338,99 @@ export class CodexHookService {
|
|||
return this.getStatus()
|
||||
}
|
||||
|
||||
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
|
||||
const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.codex/hooks.json`
|
||||
const remoteTomlPath = `${remoteHome.replace(/\/$/, '')}/.codex/config.toml`
|
||||
const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh`
|
||||
try {
|
||||
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'codex',
|
||||
state: 'error',
|
||||
configPath: remoteConfigPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse remote Codex hooks.json'
|
||||
}
|
||||
}
|
||||
|
||||
const command = wrapPosixHookCommand(remoteScriptPath)
|
||||
const nextHooks = { ...config.hooks }
|
||||
const managedEvents = new Set<string>(CODEX_EVENTS)
|
||||
const isManagedCommand = createManagedCommandMatcher('codex-hook.sh')
|
||||
|
||||
for (const [eventName, definitions] of Object.entries(nextHooks)) {
|
||||
if (managedEvents.has(eventName) || !Array.isArray(definitions)) {
|
||||
continue
|
||||
}
|
||||
const cleaned = removeManagedCommands(definitions, isManagedCommand)
|
||||
if (cleaned.length === 0) {
|
||||
delete nextHooks[eventName]
|
||||
} else {
|
||||
nextHooks[eventName] = cleaned
|
||||
}
|
||||
}
|
||||
|
||||
const trustEntries: CodexTrustEntry[] = []
|
||||
for (const eventName of CODEX_EVENTS) {
|
||||
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
|
||||
const cleaned = removeManagedCommands(current, isManagedCommand)
|
||||
const definition: HookDefinition = {
|
||||
hooks: [{ type: 'command', command }]
|
||||
}
|
||||
nextHooks[eventName] = [...cleaned, definition]
|
||||
trustEntries.push({
|
||||
sourcePath: remoteConfigPath,
|
||||
eventLabel: CODEX_EVENT_LABEL[eventName],
|
||||
groupIndex: cleaned.length,
|
||||
handlerIndex: 0,
|
||||
command
|
||||
})
|
||||
}
|
||||
|
||||
config.hooks = nextHooks
|
||||
// Why: script/settings first, trust TOML last. A partial trust write
|
||||
// leaves Codex asking for approval rather than executing a missing script.
|
||||
// Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is
|
||||
// running on Windows; never derive remote script syntax from local OS.
|
||||
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
|
||||
await writeHooksJsonRemote(sftp, remoteConfigPath, config)
|
||||
try {
|
||||
const existingToml = (await readTextFileRemote(sftp, remoteTomlPath)) ?? ''
|
||||
const updatedToml = upsertHookTrustEntriesInContent(existingToml, trustEntries)
|
||||
if (updatedToml !== existingToml) {
|
||||
await writeTextFileRemoteAtomic(sftp, remoteTomlPath, updatedToml)
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
agent: 'codex',
|
||||
state: 'error',
|
||||
configPath: remoteConfigPath,
|
||||
managedHooksPresent: true,
|
||||
detail: `Hooks installed but trust entries could not be written: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}. Run /hooks in Codex on the remote host to approve.`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agent: 'codex',
|
||||
state: 'installed',
|
||||
configPath: remoteConfigPath,
|
||||
managedHooksPresent: true,
|
||||
detail: null
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
agent: 'codex',
|
||||
state: 'error',
|
||||
configPath: remoteConfigPath,
|
||||
managedHooksPresent: false,
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remove(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const config = readHooksJson(configPath)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
import {
|
||||
createManagedCommandMatcher,
|
||||
|
|
@ -11,6 +12,11 @@ import {
|
|||
writeManagedScript,
|
||||
type HookDefinition
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
readHooksJsonRemote,
|
||||
writeHooksJsonRemote,
|
||||
writeManagedScriptRemote
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
|
||||
// Why: cursor-agent exposes a declarative hooks.json surface at
|
||||
// ~/.cursor/hooks.json (https://cursor.com/docs/hooks) with camelCase event
|
||||
|
|
@ -55,8 +61,8 @@ function getManagedCommand(scriptPath: string): string {
|
|||
return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
function getManagedScript(): string {
|
||||
if (process.platform === 'win32') {
|
||||
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
if (target === 'local' && process.platform === 'win32') {
|
||||
return [
|
||||
'@echo off',
|
||||
'setlocal',
|
||||
|
|
@ -235,6 +241,73 @@ export class CursorHookService {
|
|||
return this.getStatus()
|
||||
}
|
||||
|
||||
// Why: install Orca's managed Cursor hooks on the remote box. Mirrors
|
||||
// ClaudeHookService.installRemote — POSIX-only, uses the same SFTP-backed
|
||||
// primitives, and emits Cursor's documented schema (top-level `command`
|
||||
// on each definition + top-level `version: 1`) so cursor-agent on the
|
||||
// remote actually invokes the script. See docs/design/agent-status-over-ssh.md
|
||||
// §8.
|
||||
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
|
||||
const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.cursor/hooks.json`
|
||||
const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/cursor-hook.sh`
|
||||
try {
|
||||
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'cursor',
|
||||
state: 'error',
|
||||
configPath: remoteConfigPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse remote Cursor hooks.json'
|
||||
}
|
||||
}
|
||||
|
||||
const command = wrapPosixHookCommand(remoteScriptPath)
|
||||
const nextHooks = { ...config.hooks }
|
||||
const isManagedCommand = createManagedCommandMatcher('cursor-hook.sh')
|
||||
|
||||
for (const eventName of CURSOR_EVENTS) {
|
||||
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
|
||||
// Why: same dual-shape sweep as the local install — repeated
|
||||
// installs converge on a single managed entry.
|
||||
const cleaned = removeManagedCommands(current, isManagedCommand).filter(
|
||||
(definition) => !isManagedCommand(definition.command as string | undefined)
|
||||
)
|
||||
const definition: HookDefinition = { command }
|
||||
nextHooks[eventName] = [...cleaned, definition]
|
||||
}
|
||||
|
||||
const nextConfig: Record<string, unknown> = { ...config, hooks: nextHooks }
|
||||
if (nextConfig.version === undefined) {
|
||||
nextConfig.version = 1
|
||||
}
|
||||
|
||||
// Why: script-then-config order so a partial-failure mid-install at
|
||||
// worst leaves a working script no settings.json points at — see
|
||||
// ClaudeHookService.installRemote.
|
||||
// Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is
|
||||
// running on Windows; never derive remote script syntax from local OS.
|
||||
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
|
||||
await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig)
|
||||
|
||||
return {
|
||||
agent: 'cursor',
|
||||
state: 'installed',
|
||||
configPath: remoteConfigPath,
|
||||
managedHooksPresent: true,
|
||||
detail: null
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
agent: 'cursor',
|
||||
state: 'error',
|
||||
configPath: remoteConfigPath,
|
||||
managedHooksPresent: false,
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remove(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const config = readHooksJson(configPath)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
import {
|
||||
createManagedCommandMatcher,
|
||||
|
|
@ -11,6 +12,11 @@ import {
|
|||
writeManagedScript,
|
||||
type HookDefinition
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
readHooksJsonRemote,
|
||||
writeHooksJsonRemote,
|
||||
writeManagedScriptRemote
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
|
||||
// Why: Gemini CLI fires `BeforeAgent` when a turn starts and `AfterAgent` when
|
||||
// it completes. `AfterTool` marks the resumption of model work after a tool
|
||||
|
|
@ -41,8 +47,8 @@ function getManagedCommand(scriptPath: string): string {
|
|||
return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
function getManagedScript(): string {
|
||||
if (process.platform === 'win32') {
|
||||
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
if (target === 'local' && process.platform === 'win32') {
|
||||
return [
|
||||
'@echo off',
|
||||
'setlocal',
|
||||
|
|
@ -183,6 +189,65 @@ export class GeminiHookService {
|
|||
return this.getStatus()
|
||||
}
|
||||
|
||||
// Why: install Orca's managed Gemini hooks on the remote box. Mirrors
|
||||
// ClaudeHookService.installRemote — POSIX-only, uses the same SFTP-backed
|
||||
// primitives, and lays down the same script body the local install
|
||||
// generates so a remote-side Gemini CLI behaves identically. See
|
||||
// docs/design/agent-status-over-ssh.md §8.
|
||||
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
|
||||
const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.gemini/settings.json`
|
||||
const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/gemini-hook.sh`
|
||||
try {
|
||||
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'gemini',
|
||||
state: 'error',
|
||||
configPath: remoteConfigPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse remote Gemini settings.json'
|
||||
}
|
||||
}
|
||||
|
||||
const command = wrapPosixHookCommand(remoteScriptPath)
|
||||
const nextHooks = { ...config.hooks }
|
||||
const isManagedCommand = createManagedCommandMatcher('gemini-hook.sh')
|
||||
|
||||
for (const eventName of GEMINI_EVENTS) {
|
||||
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
|
||||
const cleaned = removeManagedCommands(current, isManagedCommand)
|
||||
const definition: HookDefinition = {
|
||||
hooks: [{ type: 'command', command }]
|
||||
}
|
||||
nextHooks[eventName] = [...cleaned, definition]
|
||||
}
|
||||
config.hooks = nextHooks
|
||||
|
||||
// Why: write the script first so an interrupted install never leaves
|
||||
// settings.json pointing at a missing script. See ClaudeHookService.
|
||||
// Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is
|
||||
// running on Windows; never derive remote script syntax from local OS.
|
||||
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
|
||||
await writeHooksJsonRemote(sftp, remoteConfigPath, config)
|
||||
|
||||
return {
|
||||
agent: 'gemini',
|
||||
state: 'installed',
|
||||
configPath: remoteConfigPath,
|
||||
managedHooksPresent: true,
|
||||
detail: null
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
agent: 'gemini',
|
||||
state: 'error',
|
||||
configPath: remoteConfigPath,
|
||||
managedHooksPresent: false,
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remove(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const config = readHooksJson(configPath)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,13 +34,17 @@ describe('readShellStartupEnvVar', () => {
|
|||
})
|
||||
|
||||
function mockStartupFiles(files: Record<string, string>) {
|
||||
const hasAbsoluteKeys = Object.keys(files).some((path) => path.startsWith('/'))
|
||||
existsSyncMock.mockImplementation((p: string) => {
|
||||
const file = p.split('/').pop() ?? ''
|
||||
return file in files
|
||||
return p in files || (!hasAbsoluteKeys && file in files)
|
||||
})
|
||||
readFileSyncMock.mockImplementation((p: string) => {
|
||||
const file = p.split('/').pop() ?? ''
|
||||
if (file in files) {
|
||||
if (p in files) {
|
||||
return files[p]
|
||||
}
|
||||
if (!hasAbsoluteKeys && file in files) {
|
||||
return files[file]
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
|
|
@ -96,6 +100,17 @@ describe('readShellStartupEnvVar', () => {
|
|||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/newest/zlogin')
|
||||
})
|
||||
|
||||
it('uses ZDOTDIR exported from .zshenv for later zsh startup files', () => {
|
||||
mockStartupFiles({
|
||||
'/home/alice/.zshenv': 'export ZDOTDIR="$HOME/.config/zsh"\n',
|
||||
'/home/alice/.config/zsh/.zshrc': 'export OPENCODE_CONFIG_DIR="$HOME/company/opencode"\n'
|
||||
})
|
||||
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice', '/bin/zsh')).toBe(
|
||||
'/home/alice/company/opencode'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles double-quoted values', () => {
|
||||
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR="/quoted/path"\n' })
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/quoted/path')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -18,10 +18,13 @@ import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider'
|
|||
import { SshGitProvider } from '../providers/ssh-git-provider'
|
||||
import { agentHookServer } from '../agent-hooks/server'
|
||||
import {
|
||||
AGENT_HOOK_INSTALL_PLUGINS_METHOD,
|
||||
AGENT_HOOK_NOTIFICATION_METHOD,
|
||||
AGENT_HOOK_REQUEST_REPLAY_METHOD,
|
||||
isRemoteAgentHooksEnabled
|
||||
} from '../../shared/agent-hook-relay'
|
||||
import { _internals as openCodeInternals } from '../opencode/hook-service'
|
||||
import { getPiAgentStatusExtensionSource } from '../pi/agent-status-extension-source'
|
||||
import {
|
||||
registerSshPtyProvider,
|
||||
unregisterSshPtyProvider,
|
||||
|
|
@ -52,6 +55,11 @@ export class SshRelaySession {
|
|||
private mux: SshChannelMultiplexer | null = null
|
||||
private abortController: AbortController | null = null
|
||||
private muxDisposeCleanup: (() => void) | null = null
|
||||
// Why: store the notification-handler disposer so teardownProviders can
|
||||
// release it on reconnect/shutdown. Symmetric with muxDisposeCleanup; while
|
||||
// the old mux's handler array is GC'd along with the mux today, holding the
|
||||
// disposer is cheap insurance against future code that retains the old mux.
|
||||
private muxNotificationCleanup: (() => void) | null = null
|
||||
// Why: when the relay exec channel closes but the SSH connection stays
|
||||
// up, the onStateChange reconnect path never fires. This callback lets
|
||||
// ssh.ts wire up relay-level reconnect from outside the session.
|
||||
|
|
@ -152,7 +160,13 @@ export class SshRelaySession {
|
|||
// here fails fast so doConnect() can report the real error.
|
||||
await mux.request('session.resolveHome', { path: '~' })
|
||||
|
||||
await this.registerProviders(mux)
|
||||
const registered = await this.registerProviders(mux, ownsAttempt)
|
||||
if (!registered) {
|
||||
if (!mux.isDisposed()) {
|
||||
mux.dispose()
|
||||
}
|
||||
throw new Error('Session disposed during establish')
|
||||
}
|
||||
|
||||
// Why: the mux's transport can close during registerProviders (e.g.
|
||||
// the --connect bridge exits). registerRelayRoots swallows mux errors
|
||||
|
|
@ -405,6 +419,11 @@ export class SshRelaySession {
|
|||
return false
|
||||
}
|
||||
|
||||
await this.installPluginsOnRelay(mux)
|
||||
if (shouldContinue && !shouldContinue()) {
|
||||
return false
|
||||
}
|
||||
|
||||
const ptyProvider = new SshPtyProvider(this.targetId, mux)
|
||||
registerSshPtyProvider(this.targetId, ptyProvider)
|
||||
|
||||
|
|
@ -419,6 +438,47 @@ export class SshRelaySession {
|
|||
return true
|
||||
}
|
||||
|
||||
// Why: ship the OpenCode plugin / Pi extension source bodies to the relay
|
||||
// so it can materialize per-PTY overlay dirs and inject OPENCODE_CONFIG_DIR
|
||||
// / PI_CODING_AGENT_DIR into spawn env. The strings change as we add agent
|
||||
// events (recent additions: cursor, pi); pinning them to the relay binary
|
||||
// would force a relay redeploy on every Orca update. See
|
||||
// docs/design/agent-status-over-ssh.md §4 + §8 (commit #7).
|
||||
//
|
||||
// Best-effort: a -32601 from an older relay (no handler installed) is
|
||||
// swallowed; the user just doesn't get OpenCode/Pi status reporting until
|
||||
// they upgrade. Hook-script-based agents use a separate explicit remote
|
||||
// installer flow because that mutates user-owned agent config files.
|
||||
private async installPluginsOnRelay(mux: SshChannelMultiplexer): Promise<void> {
|
||||
if (!isRemoteAgentHooksEnabled()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await mux.request(AGENT_HOOK_INSTALL_PLUGINS_METHOD, {
|
||||
opencodePluginSource: openCodeInternals.getOpenCodePluginSource(),
|
||||
piExtensionSource: getPiAgentStatusExtensionSource()
|
||||
})
|
||||
} catch (err) {
|
||||
// Why: -32601 = older relay without the handler (treat as soft skip).
|
||||
// CONNECTION_LOST / DISPOSED come from the multiplexer when it tears
|
||||
// down mid-flight (routine on session shutdown / reconnect race) — not
|
||||
// a real failure to surface; suppress to avoid log spam on every clean
|
||||
// disconnect.
|
||||
const code = (err as { code?: unknown })?.code
|
||||
if (code === -32601 || code === 'CONNECTION_LOST' || code === 'DISPOSED') {
|
||||
return
|
||||
}
|
||||
if (mux.isDisposed()) {
|
||||
return
|
||||
}
|
||||
console.warn(
|
||||
`[ssh-relay-session] agent_hook.installPlugins failed for ${this.targetId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: route the relay's `agent.hook` JSON-RPC notification into Orca's
|
||||
// shared `agentHookServer` via `ingestRemote`. The wire envelope carries
|
||||
// `connectionId: null` (the relay does not know Orca's local handle); we
|
||||
|
|
@ -433,7 +493,13 @@ export class SshRelaySession {
|
|||
if (!isRemoteAgentHooksEnabled()) {
|
||||
return
|
||||
}
|
||||
mux.onNotification((method, params) => {
|
||||
// Why: capture the disposer so teardownProviders can release the
|
||||
// notification handler symmetrically with muxDisposeCleanup. Even though
|
||||
// the disposed mux's handler array is GC'd along with it today, retaining
|
||||
// the disposer makes "registerProviders called twice on the same mux"
|
||||
// safe by future-proofing against duplicate handler registration.
|
||||
this.muxNotificationCleanup?.()
|
||||
this.muxNotificationCleanup = mux.onNotification((method, params) => {
|
||||
if (method !== AGENT_HOOK_NOTIFICATION_METHOD) {
|
||||
return
|
||||
}
|
||||
|
|
@ -470,10 +536,14 @@ export class SshRelaySession {
|
|||
// *after* the handler is wired so the request-driven replay shape
|
||||
// strictly trails our subscription on the dispatcher's single write
|
||||
// callback. Best-effort: a relay that does not know the method
|
||||
// (e.g. older relay binary) returns -32601, which we swallow.
|
||||
// (e.g. older relay binary) returns -32601; CONNECTION_LOST / DISPOSED
|
||||
// arise from mux teardown mid-flight on routine reconnect/shutdown.
|
||||
void mux.request(AGENT_HOOK_REQUEST_REPLAY_METHOD).catch((err) => {
|
||||
const code = (err as { code?: unknown })?.code
|
||||
if (code === -32601) {
|
||||
if (code === -32601 || code === 'CONNECTION_LOST' || code === 'DISPOSED') {
|
||||
return
|
||||
}
|
||||
if (mux.isDisposed()) {
|
||||
return
|
||||
}
|
||||
// Why: a normal disconnect/teardown rejects the in-flight request with
|
||||
|
|
@ -493,6 +563,8 @@ export class SshRelaySession {
|
|||
private teardownProviders(reason: 'shutdown' | 'connection_lost'): void {
|
||||
this.muxDisposeCleanup?.()
|
||||
this.muxDisposeCleanup = null
|
||||
this.muxNotificationCleanup?.()
|
||||
this.muxNotificationCleanup = null
|
||||
if (this.mux && !this.mux.isDisposed()) {
|
||||
this.mux.dispose(reason)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,11 @@ export class RelayAgentHookServer {
|
|||
await new Promise<void>((resolve, reject) => {
|
||||
const onStartupError = (err: Error): void => {
|
||||
this.server?.off('listening', onListening)
|
||||
// Why: null the server reference on bind failure so a subsequent
|
||||
// start() can retry. Without this, a failed bind (e.g. EMFILE) leaves
|
||||
// this.server populated and the early-return at the top of start()
|
||||
// wedges the relay into a permanently broken state until stop() runs.
|
||||
this.server = null
|
||||
reject(err)
|
||||
}
|
||||
const onListening = (): void => {
|
||||
|
|
@ -234,9 +239,14 @@ export class RelayAgentHookServer {
|
|||
}
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Why: agent hooks must fail open — return success on parse / size /
|
||||
// timeout errors so a buggy agent script never blocks the agent run.
|
||||
// Log the swallowed error to stderr so future programmer bugs are not
|
||||
// invisible (the 204 response would otherwise mask them entirely).
|
||||
process.stderr.write(
|
||||
`[relay-hook-server] hook request failed: ${err instanceof Error ? err.message : String(err)}\n`
|
||||
)
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { readShellStartupEnvVar } from '../main/pty/shell-startup-env'
|
||||
|
||||
function firstNonEmpty(...values: (string | undefined)[]): string | undefined {
|
||||
return values.find((value) => typeof value === 'string' && value.length > 0)
|
||||
}
|
||||
|
||||
function readStartupEnv(
|
||||
name: string,
|
||||
env: Record<string, string>,
|
||||
shell: string | undefined
|
||||
): string | undefined {
|
||||
return readShellStartupEnvVar(name, env.HOME ?? process.env.HOME, shell ?? env.SHELL)
|
||||
}
|
||||
|
||||
export function resolveOpenCodeSourceConfigDir(
|
||||
env: Record<string, string>,
|
||||
shell: string | undefined
|
||||
): string | undefined {
|
||||
return firstNonEmpty(
|
||||
env.ORCA_OPENCODE_SOURCE_CONFIG_DIR,
|
||||
readStartupEnv('OPENCODE_CONFIG_DIR', env, shell),
|
||||
env.OPENCODE_CONFIG_DIR
|
||||
)
|
||||
}
|
||||
|
||||
export function resolvePiSourceAgentDir(
|
||||
env: Record<string, string>,
|
||||
shell: string | undefined
|
||||
): string | undefined {
|
||||
return firstNonEmpty(
|
||||
env.ORCA_PI_SOURCE_AGENT_DIR,
|
||||
readStartupEnv('PI_CODING_AGENT_DIR', env, shell),
|
||||
env.PI_CODING_AGENT_DIR
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync
|
||||
} from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { basename, join } from 'path'
|
||||
import { PluginOverlayManager } from './plugin-overlay'
|
||||
|
||||
describe('PluginOverlayManager', () => {
|
||||
let homeDir: string
|
||||
let manager: PluginOverlayManager
|
||||
|
||||
beforeEach(() => {
|
||||
homeDir = mkdtempSync(join(tmpdir(), 'plugin-overlay-'))
|
||||
manager = new PluginOverlayManager({ homeDir })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('reports no source until install runs', () => {
|
||||
expect(manager.hasOpenCodeSource()).toBe(false)
|
||||
expect(manager.hasPiSource()).toBe(false)
|
||||
expect(manager.materializeOpenCode('tab-1:0')).toBeNull()
|
||||
expect(manager.materializePi('tab-1:0')).toBeNull()
|
||||
})
|
||||
|
||||
it('materializes OpenCode plugin into <overlay>/plugins/<file>', () => {
|
||||
manager.setSources({ opencodePluginSource: 'export const X = 1' })
|
||||
const dir = manager.materializeOpenCode('tab-1:0')
|
||||
expect(dir).not.toBeNull()
|
||||
const expected = join(dir!, 'plugins', 'orca-opencode-status.js')
|
||||
expect(existsSync(expected)).toBe(true)
|
||||
expect(readFileSync(expected, 'utf8')).toBe('export const X = 1')
|
||||
})
|
||||
|
||||
it('mirrors a preexisting remote OpenCode config dir before adding Orca plugin', () => {
|
||||
const userConfigDir = join(homeDir, 'company-opencode')
|
||||
mkdirSync(join(userConfigDir, 'plugins'), { recursive: true })
|
||||
writeFileSync(join(userConfigDir, 'opencode.json'), '{"provider":"custom"}')
|
||||
writeFileSync(join(userConfigDir, 'plugins', 'user-plugin.js'), 'user plugin')
|
||||
writeFileSync(join(userConfigDir, 'plugins', 'orca-opencode-status.js'), 'user same-name')
|
||||
|
||||
manager.setSources({ opencodePluginSource: 'orca plugin' })
|
||||
const dir = manager.materializeOpenCode('tab-opencode:0', userConfigDir)
|
||||
|
||||
expect(dir).not.toBeNull()
|
||||
expect(readFileSync(join(dir!, 'opencode.json'), 'utf8')).toBe('{"provider":"custom"}')
|
||||
expect(readFileSync(join(dir!, 'plugins', 'user-plugin.js'), 'utf8')).toBe('user plugin')
|
||||
expect(readFileSync(join(dir!, 'plugins', 'orca-opencode-status.js'), 'utf8')).toBe(
|
||||
'orca plugin'
|
||||
)
|
||||
expect(readFileSync(join(userConfigDir, 'plugins', 'orca-opencode-status.js'), 'utf8')).toBe(
|
||||
'user same-name'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not override a missing preexisting OpenCode config dir', () => {
|
||||
manager.setSources({ opencodePluginSource: 'orca plugin' })
|
||||
|
||||
expect(manager.materializeOpenCode('tab-missing:0', join(homeDir, 'missing'))).toBeNull()
|
||||
})
|
||||
|
||||
it('materializes Pi extension into <overlay>/extensions/<file>', () => {
|
||||
manager.setSources({ piExtensionSource: '// pi extension' })
|
||||
const dir = manager.materializePi('tab-2:0')
|
||||
expect(dir).not.toBeNull()
|
||||
const file = join(dir!, 'extensions', 'orca-agent-status.ts')
|
||||
expect(existsSync(file)).toBe(true)
|
||||
})
|
||||
|
||||
it('mirrors the remote default Pi agent dir before adding Orca status extension', () => {
|
||||
const piAgentDir = join(homeDir, '.pi', 'agent')
|
||||
mkdirSync(join(piAgentDir, 'skills', 'my-skill'), { recursive: true })
|
||||
mkdirSync(join(piAgentDir, 'extensions', 'user-ext'), { recursive: true })
|
||||
writeFileSync(join(piAgentDir, 'auth.json'), 'secret token')
|
||||
writeFileSync(join(piAgentDir, 'skills', 'my-skill', 'SKILL.md'), 'critical user skill')
|
||||
writeFileSync(join(piAgentDir, 'extensions', 'user-ext', 'ext.ts'), 'user extension')
|
||||
|
||||
manager.setSources({ piExtensionSource: '// pi extension' })
|
||||
const dir = manager.materializePi('tab-pi:0')
|
||||
|
||||
expect(dir).not.toBeNull()
|
||||
expect(readFileSync(join(dir!, 'auth.json'), 'utf8')).toBe('secret token')
|
||||
expect(readFileSync(join(dir!, 'skills', 'my-skill', 'SKILL.md'), 'utf8')).toBe(
|
||||
'critical user skill'
|
||||
)
|
||||
expect(readFileSync(join(dir!, 'extensions', 'user-ext', 'ext.ts'), 'utf8')).toBe(
|
||||
'user extension'
|
||||
)
|
||||
expect(readdirSync(join(dir!, 'extensions')).sort()).toEqual([
|
||||
'orca-agent-status.ts',
|
||||
'user-ext'
|
||||
])
|
||||
})
|
||||
|
||||
it('mirrors a preexisting remote Pi agent dir instead of the default', () => {
|
||||
const defaultAgentDir = join(homeDir, '.pi', 'agent')
|
||||
const customAgentDir = join(homeDir, 'custom-pi-agent')
|
||||
mkdirSync(defaultAgentDir, { recursive: true })
|
||||
mkdirSync(join(customAgentDir, 'extensions'), { recursive: true })
|
||||
writeFileSync(join(defaultAgentDir, 'auth.json'), 'default token')
|
||||
writeFileSync(join(customAgentDir, 'auth.json'), 'custom token')
|
||||
writeFileSync(join(customAgentDir, 'extensions', 'custom.ts'), 'custom extension')
|
||||
|
||||
manager.setSources({ piExtensionSource: '// pi extension' })
|
||||
const dir = manager.materializePi('tab-custom-pi:0', customAgentDir)
|
||||
|
||||
expect(dir).not.toBeNull()
|
||||
expect(readFileSync(join(dir!, 'auth.json'), 'utf8')).toBe('custom token')
|
||||
expect(readFileSync(join(dir!, 'extensions', 'custom.ts'), 'utf8')).toBe('custom extension')
|
||||
expect(readFileSync(join(dir!, 'extensions', 'orca-agent-status.ts'), 'utf8')).toBe(
|
||||
'// pi extension'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not override a missing preexisting Pi agent dir', () => {
|
||||
manager.setSources({ piExtensionSource: '// pi extension' })
|
||||
|
||||
expect(manager.materializePi('tab-missing-pi:0', join(homeDir, 'missing-pi'))).toBeNull()
|
||||
})
|
||||
|
||||
it('clearOverlay removes both overlay roots for an id', () => {
|
||||
manager.setSources({
|
||||
opencodePluginSource: 'opencode',
|
||||
piExtensionSource: 'pi'
|
||||
})
|
||||
const opencodeDir = manager.materializeOpenCode('tab-3:0')!
|
||||
const piRoot = manager.materializePi('tab-3:0')!
|
||||
expect(existsSync(opencodeDir)).toBe(true)
|
||||
expect(existsSync(piRoot)).toBe(true)
|
||||
|
||||
manager.clearOverlay('tab-3:0')
|
||||
|
||||
expect(existsSync(opencodeDir)).toBe(false)
|
||||
expect(existsSync(piRoot)).toBe(false)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'clearOverlay removes OpenCode overlay symlinks without deleting their targets',
|
||||
() => {
|
||||
const userConfigDir = join(homeDir, 'company-opencode')
|
||||
const linkedTarget = join(homeDir, 'linked-plugin-target')
|
||||
mkdirSync(join(userConfigDir, 'plugins'), { recursive: true })
|
||||
mkdirSync(linkedTarget, { recursive: true })
|
||||
writeFileSync(join(linkedTarget, 'keep.js'), 'do not delete')
|
||||
symlinkSync(linkedTarget, join(userConfigDir, 'plugins', 'linked-plugin'), 'dir')
|
||||
|
||||
manager.setSources({ opencodePluginSource: 'orca plugin' })
|
||||
const dir = manager.materializeOpenCode('tab-opencode-symlink:0', userConfigDir)!
|
||||
expect(existsSync(join(dir, 'plugins', 'linked-plugin'))).toBe(true)
|
||||
|
||||
manager.clearOverlay('tab-opencode-symlink:0')
|
||||
|
||||
expect(existsSync(dir)).toBe(false)
|
||||
expect(readFileSync(join(linkedTarget, 'keep.js'), 'utf8')).toBe('do not delete')
|
||||
}
|
||||
)
|
||||
|
||||
it('produces stable overlay dirs for a given id (idempotent re-materialization)', () => {
|
||||
manager.setSources({ opencodePluginSource: 'first' })
|
||||
const dirA = manager.materializeOpenCode('tab-stable:0')!
|
||||
manager.setSources({ opencodePluginSource: 'second' })
|
||||
const dirB = manager.materializeOpenCode('tab-stable:0')!
|
||||
expect(dirA).toBe(dirB)
|
||||
expect(readFileSync(join(dirA, 'plugins', 'orca-opencode-status.js'), 'utf8')).toBe('second')
|
||||
})
|
||||
|
||||
it('hashes unsafe pane ids into portable overlay directory names', () => {
|
||||
manager.setSources({ opencodePluginSource: 'plugin' })
|
||||
const dir = manager.materializeOpenCode('tab/with\\unsafe:chars\n0')
|
||||
|
||||
expect(dir).not.toBeNull()
|
||||
expect(basename(dir!)).toMatch(/^[a-f0-9]{32}$/)
|
||||
expect(dir).not.toContain('tab/with')
|
||||
expect(existsSync(join(dir!, 'plugins', 'orca-opencode-status.js'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
// Why: relay-side equivalent of Orca's userData-backed plugin overlay system.
|
||||
// Orca's local OpenCodeHookService and PiTitlebarExtensionService each
|
||||
// materialize a per-PTY overlay and inject OPENCODE_CONFIG_DIR /
|
||||
// PI_CODING_AGENT_DIR pointing at it. Those paths describe the local
|
||||
// filesystem and would resolve to nothing on a remote box, so when a PTY runs
|
||||
// on the relay, the relay must do the same materialization on its own disk.
|
||||
//
|
||||
// Plugin source strings ship over the JSON-RPC channel at session-ready
|
||||
// (commit #7) — they are NOT bundled with the relay binary because the
|
||||
// relay is versioned independently from Orca and the plugin source changes
|
||||
// frequently as new agent events get added (see docs/design/agent-status-
|
||||
// over-ssh.md §4 "Why ship the plugin source over the wire").
|
||||
//
|
||||
// We deliberately do not reuse OpenCodeHookService / PiTitlebarExtensionService
|
||||
// directly: those modules import `electron` and ride on Orca's userData
|
||||
// path. The relay's electron-free constraint forces a thin parallel
|
||||
// implementation rooted at $HOME/.orca-relay/.
|
||||
|
||||
import { createHash } from 'crypto'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync
|
||||
} from 'fs'
|
||||
import { homedir } from 'os'
|
||||
import { basename, join } from 'path'
|
||||
import { mirrorEntry, safeRemoveOverlay } from '../main/pty/overlay-mirror'
|
||||
|
||||
const RELAY_HOOKS_DIR = '.orca-relay'
|
||||
const OPENCODE_OVERLAY_SUBDIR = 'opencode-overlays'
|
||||
const PI_OVERLAY_SUBDIR = 'pi-overlays'
|
||||
const OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js'
|
||||
const PI_EXTENSION_FILE = 'orca-agent-status.ts'
|
||||
const PI_AGENT_DIR_NAME = '.pi'
|
||||
const PI_AGENT_SUBDIR = 'agent'
|
||||
|
||||
function safeDirName(input: string): string {
|
||||
// Why: paneKey embeds tabId:paneId where tabId may itself contain
|
||||
// filesystem-unsafe characters in some Orca builds. Hash to a fixed-width
|
||||
// hex name so any input produces a portable directory name.
|
||||
return createHash('sha256').update(input).digest('hex').slice(0, 32)
|
||||
}
|
||||
|
||||
function isUsableId(id: string): boolean {
|
||||
return typeof id === 'string' && id.length > 0 && id.length <= 1024
|
||||
}
|
||||
|
||||
export type PluginSources = {
|
||||
/** Source body of `orca-opencode-status.js` to drop into <overlay>/plugins/. */
|
||||
opencodePluginSource?: string
|
||||
/** Source body of `orca-agent-status.ts` to drop into <overlay>/extensions/. */
|
||||
piExtensionSource?: string
|
||||
}
|
||||
|
||||
export class PluginOverlayManager {
|
||||
private opencodePluginSource: string | null = null
|
||||
private piExtensionSource: string | null = null
|
||||
private homeDir: string
|
||||
private opencodeRoot: string
|
||||
private piRoot: string
|
||||
|
||||
constructor(opts?: { homeDir?: string }) {
|
||||
const home = opts?.homeDir ?? homedir()
|
||||
this.homeDir = home
|
||||
this.opencodeRoot = join(home, RELAY_HOOKS_DIR, OPENCODE_OVERLAY_SUBDIR)
|
||||
this.piRoot = join(home, RELAY_HOOKS_DIR, PI_OVERLAY_SUBDIR)
|
||||
}
|
||||
|
||||
/** Replace the cached source bodies. Called from relay.ts when Orca sends
|
||||
* `agent_hook.installPlugins`. The first install enables the augmenter
|
||||
* output; subsequent installs (e.g. Orca version upgrade in flight) refresh
|
||||
* the cached source so future spawns see the new strings.
|
||||
* Note: existing per-PTY overlays already on disk keep the previous source
|
||||
* until that PTY exits — a long-running PTY does NOT pick up the new
|
||||
* source, matching the local-Orca behavior where the plugin file is
|
||||
* written once at spawn time. */
|
||||
setSources(sources: PluginSources): void {
|
||||
if (typeof sources.opencodePluginSource === 'string') {
|
||||
this.opencodePluginSource = sources.opencodePluginSource
|
||||
}
|
||||
if (typeof sources.piExtensionSource === 'string') {
|
||||
this.piExtensionSource = sources.piExtensionSource
|
||||
}
|
||||
}
|
||||
|
||||
hasOpenCodeSource(): boolean {
|
||||
return this.opencodePluginSource !== null
|
||||
}
|
||||
|
||||
hasPiSource(): boolean {
|
||||
return this.piExtensionSource !== null
|
||||
}
|
||||
|
||||
private mirrorOpenCodeConfig(sourceDir: string, overlayDir: string): void {
|
||||
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
|
||||
const sourcePath = join(sourceDir, entry.name)
|
||||
|
||||
if (entry.name === 'plugins') {
|
||||
const isSymlink = entry.isSymbolicLink()
|
||||
let isLinkPointingToDir = false
|
||||
if (isSymlink) {
|
||||
try {
|
||||
isLinkPointingToDir = statSync(sourcePath).isDirectory()
|
||||
} catch {
|
||||
isLinkPointingToDir = false
|
||||
}
|
||||
}
|
||||
|
||||
if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) {
|
||||
const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath
|
||||
const overlayPluginsDir = join(overlayDir, 'plugins')
|
||||
mkdirSync(overlayPluginsDir, { recursive: true })
|
||||
for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) {
|
||||
if (pluginEntry.name === OPENCODE_PLUGIN_FILE) {
|
||||
continue
|
||||
}
|
||||
mirrorEntry(
|
||||
join(resolvedSource, pluginEntry.name),
|
||||
join(overlayPluginsDir, pluginEntry.name)
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
mirrorEntry(sourcePath, join(overlayDir, entry.name))
|
||||
}
|
||||
}
|
||||
|
||||
private writeOpenCodePlugin(overlayDir: string): void {
|
||||
const pluginsDir = join(overlayDir, 'plugins')
|
||||
mkdirSync(pluginsDir, { recursive: true })
|
||||
const pluginPath = join(pluginsDir, OPENCODE_PLUGIN_FILE)
|
||||
try {
|
||||
unlinkSync(pluginPath)
|
||||
} catch {
|
||||
// Fresh overlay or no same-named stale symlink.
|
||||
}
|
||||
writeFileSync(pluginPath, this.opencodePluginSource!)
|
||||
}
|
||||
|
||||
/** Materialize the OpenCode plugin overlay for `id` (typically the
|
||||
* renderer-supplied paneKey or, fallback, the relay-internal pty-id) and
|
||||
* return the directory path. Returns null when no source is cached or
|
||||
* the overlay write fails — caller falls back to no plugin (the agent
|
||||
* CLI runs without status reporting), which is the existing fail-open
|
||||
* behavior on the local side. */
|
||||
materializeOpenCode(id: string, existingConfigDir?: string): string | null {
|
||||
if (!this.opencodePluginSource || !isUsableId(id)) {
|
||||
return null
|
||||
}
|
||||
const dir = join(this.opencodeRoot, safeDirName(id))
|
||||
try {
|
||||
safeRemoveOverlay(dir, this.opencodeRoot)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
if (existingConfigDir) {
|
||||
if (!existsSync(existingConfigDir)) {
|
||||
return null
|
||||
}
|
||||
// Why: OPENCODE_CONFIG_DIR is a single config root. Mirror the user's
|
||||
// remote root into the overlay before adding Orca's plugin so status
|
||||
// reporting does not hide their auth, models, keybinds, or plugins.
|
||||
this.mirrorOpenCodeConfig(existingConfigDir, dir)
|
||||
}
|
||||
this.writeOpenCodePlugin(dir)
|
||||
return dir
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`[plugin-overlay] failed to materialize OpenCode overlay: ${err instanceof Error ? err.message : String(err)}\n`
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private getDefaultPiAgentDir(): string {
|
||||
return join(this.homeDir, PI_AGENT_DIR_NAME, PI_AGENT_SUBDIR)
|
||||
}
|
||||
|
||||
private mirrorPiAgentDir(sourceAgentDir: string, overlayDir: string): void {
|
||||
if (!existsSync(sourceAgentDir)) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const entry of readdirSync(sourceAgentDir, { withFileTypes: true })) {
|
||||
const sourcePath = join(sourceAgentDir, entry.name)
|
||||
|
||||
if (entry.name === 'extensions') {
|
||||
const isSymlink = entry.isSymbolicLink()
|
||||
let isLinkPointingToDir = false
|
||||
if (isSymlink) {
|
||||
try {
|
||||
isLinkPointingToDir = statSync(sourcePath).isDirectory()
|
||||
} catch {
|
||||
isLinkPointingToDir = false
|
||||
}
|
||||
}
|
||||
|
||||
if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) {
|
||||
const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath
|
||||
const overlayExtensionsDir = join(overlayDir, 'extensions')
|
||||
mkdirSync(overlayExtensionsDir, { recursive: true })
|
||||
for (const extensionEntry of readdirSync(resolvedSource, { withFileTypes: true })) {
|
||||
if (extensionEntry.name === PI_EXTENSION_FILE) {
|
||||
continue
|
||||
}
|
||||
mirrorEntry(
|
||||
join(resolvedSource, extensionEntry.name),
|
||||
join(overlayExtensionsDir, extensionEntry.name)
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
mirrorEntry(sourcePath, join(overlayDir, basename(sourcePath)))
|
||||
}
|
||||
}
|
||||
|
||||
/** Materialize the Pi extension overlay for `id` and return the directory
|
||||
* path that should be assigned to PI_CODING_AGENT_DIR. */
|
||||
materializePi(id: string, existingAgentDir?: string): string | null {
|
||||
if (!this.piExtensionSource || !isUsableId(id)) {
|
||||
return null
|
||||
}
|
||||
const dir = join(this.piRoot, safeDirName(id))
|
||||
try {
|
||||
// Why: PI_CODING_AGENT_DIR is Pi's whole state root. Mirror the remote
|
||||
// user's default agent dir so Orca's status extension does not hide auth,
|
||||
// sessions, skills, prompts, themes, or user extensions inside SSH panes.
|
||||
safeRemoveOverlay(dir, this.piRoot)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const sourceAgentDir = existingAgentDir ?? this.getDefaultPiAgentDir()
|
||||
if (existingAgentDir && !existsSync(existingAgentDir)) {
|
||||
return null
|
||||
}
|
||||
this.mirrorPiAgentDir(sourceAgentDir, dir)
|
||||
const extensionsDir = join(dir, 'extensions')
|
||||
mkdirSync(extensionsDir, { recursive: true })
|
||||
writeFileSync(join(extensionsDir, PI_EXTENSION_FILE), this.piExtensionSource)
|
||||
return dir
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`[plugin-overlay] failed to materialize Pi overlay: ${err instanceof Error ? err.message : String(err)}\n`
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop a paneKey's overlay dirs on PTY exit. Best-effort; cleanup over a
|
||||
* recursive tree may fail on exotic filesystems but the worst-case
|
||||
* outcome is unbounded growth on a long-lived relay, which the per-pane
|
||||
* caches alone do not bound. */
|
||||
clearOverlay(id: string): void {
|
||||
if (!isUsableId(id)) {
|
||||
return
|
||||
}
|
||||
const safe = safeDirName(id)
|
||||
for (const root of [this.opencodeRoot, this.piRoot]) {
|
||||
try {
|
||||
safeRemoveOverlay(join(root, safe), root)
|
||||
} catch (err) {
|
||||
// Why: log the failed cleanup so a permission/IO error is observable.
|
||||
// The leak is the failure mode the per-pane cache eviction exists to
|
||||
// prevent — silent swallows would let it accumulate invisibly on
|
||||
// long-running relays.
|
||||
process.stderr.write(
|
||||
`[plugin-overlay] failed to remove overlay dir ${join(root, safe)}: ${err instanceof Error ? err.message : String(err)}\n`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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`)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
/* oxlint-disable max-lines */
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({
|
||||
mockPtySpawn: vi.fn(),
|
||||
|
|
@ -435,6 +438,113 @@ describe('PtyHandler', () => {
|
|||
expect(callArgs.env.ORCA_TAB_ID).toBe('tab-1')
|
||||
})
|
||||
|
||||
it('passes the PTY id and renderer paneKey to env augmenters', async () => {
|
||||
const seenContexts: { id: string; paneKey?: string; env: Record<string, string> }[] = []
|
||||
handler.addEnvAugmenter((ctx) => {
|
||||
seenContexts.push(ctx)
|
||||
return {
|
||||
OVERLAY_ID: ctx.paneKey ?? ctx.id
|
||||
}
|
||||
})
|
||||
|
||||
await dispatcher.callRequest('pty.spawn', {
|
||||
env: { ORCA_PANE_KEY: 'tab-context:0' }
|
||||
})
|
||||
await dispatcher.callRequest('pty.spawn', {})
|
||||
|
||||
const firstEnv = mockPtySpawn.mock.calls[0][2] as { env: Record<string, string> }
|
||||
const secondEnv = mockPtySpawn.mock.calls[1][2] as { env: Record<string, string> }
|
||||
expect(seenContexts[0]).toMatchObject({
|
||||
id: 'pty-1',
|
||||
paneKey: 'tab-context:0',
|
||||
env: { ORCA_PANE_KEY: 'tab-context:0' }
|
||||
})
|
||||
expect(seenContexts[1]).toMatchObject({ id: 'pty-2', paneKey: undefined })
|
||||
expect(firstEnv.env.OVERLAY_ID).toBe('tab-context:0')
|
||||
expect(secondEnv.env.OVERLAY_ID).toBe('pty-2')
|
||||
})
|
||||
|
||||
it('passes process and renderer env to env augmenters before augmenter overrides are applied', async () => {
|
||||
const oldProcessValue = process.env.OPENCODE_CONFIG_DIR
|
||||
process.env.OPENCODE_CONFIG_DIR = '/remote/default-opencode'
|
||||
try {
|
||||
handler.addEnvAugmenter((ctx) => ({
|
||||
SEEN_OPENCODE_CONFIG_DIR: ctx.env.OPENCODE_CONFIG_DIR,
|
||||
SEEN_PI_CODING_AGENT_DIR: ctx.env.PI_CODING_AGENT_DIR
|
||||
}))
|
||||
|
||||
await dispatcher.callRequest('pty.spawn', {
|
||||
env: {
|
||||
OPENCODE_CONFIG_DIR: '/remote/renderer-opencode',
|
||||
PI_CODING_AGENT_DIR: '/remote/pi'
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
if (oldProcessValue === undefined) {
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.OPENCODE_CONFIG_DIR = oldProcessValue
|
||||
}
|
||||
}
|
||||
|
||||
const spawnEnv = mockPtySpawn.mock.calls[0][2] as { env: Record<string, string> }
|
||||
expect(spawnEnv.env.SEEN_OPENCODE_CONFIG_DIR).toBe('/remote/renderer-opencode')
|
||||
expect(spawnEnv.env.SEEN_PI_CODING_AGENT_DIR).toBe('/remote/pi')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'wraps bash spawns to restore overlay env after remote startup files',
|
||||
async () => {
|
||||
const oldShell = process.env.SHELL
|
||||
const oldHome = process.env.HOME
|
||||
const homeDir = mkdtempSync(join(tmpdir(), 'relay-pty-shell-launch-'))
|
||||
|
||||
process.env.SHELL = '/bin/bash'
|
||||
process.env.HOME = homeDir
|
||||
try {
|
||||
if (!existsSync('/bin/bash')) {
|
||||
return
|
||||
}
|
||||
|
||||
handler.addEnvAugmenter(() => ({
|
||||
OPENCODE_CONFIG_DIR: '/remote/overlay/opencode',
|
||||
ORCA_OPENCODE_CONFIG_DIR: '/remote/overlay/opencode',
|
||||
PI_CODING_AGENT_DIR: '/remote/overlay/pi',
|
||||
ORCA_PI_CODING_AGENT_DIR: '/remote/overlay/pi'
|
||||
}))
|
||||
|
||||
await dispatcher.callRequest('pty.spawn', { env: { HOME: homeDir } })
|
||||
} finally {
|
||||
if (oldShell === undefined) {
|
||||
delete process.env.SHELL
|
||||
} else {
|
||||
process.env.SHELL = oldShell
|
||||
}
|
||||
if (oldHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = oldHome
|
||||
}
|
||||
}
|
||||
|
||||
const shellArgs = mockPtySpawn.mock.calls[0][1]
|
||||
const spawnOptions = mockPtySpawn.mock.calls[0][2] as { env: Record<string, string> }
|
||||
const rcfile = join(homeDir, '.orca-relay', 'shell-ready', 'bash', 'rcfile')
|
||||
|
||||
expect(shellArgs).toEqual(['--rcfile', rcfile])
|
||||
expect(spawnOptions.env.ORCA_OPENCODE_CONFIG_DIR).toBe('/remote/overlay/opencode')
|
||||
expect(spawnOptions.env.ORCA_PI_CODING_AGENT_DIR).toBe('/remote/overlay/pi')
|
||||
expect(readFileSync(rcfile, 'utf8')).toContain(
|
||||
'export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"'
|
||||
)
|
||||
expect(readFileSync(rcfile, 'utf8')).toContain(
|
||||
'export PI_CODING_AGENT_DIR="${ORCA_PI_CODING_AGENT_DIR}"'
|
||||
)
|
||||
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
}
|
||||
)
|
||||
|
||||
it('revive restores pane identity env alongside hook-server coordinates', async () => {
|
||||
await dispatcher.callRequest('pty.spawn', {
|
||||
cols: 90,
|
||||
|
|
@ -494,7 +604,32 @@ describe('PtyHandler', () => {
|
|||
expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-2:1' }])
|
||||
})
|
||||
|
||||
it('dispose kills all PTYs with SIGKILL', async () => {
|
||||
it('immediate shutdown invokes the exit listener once even if onExit arrives later', async () => {
|
||||
let onExitCb: ((evt: { exitCode: number }) => void) | undefined
|
||||
const mockKill = vi.fn()
|
||||
mockPtySpawn.mockReturnValue({
|
||||
...mockPtyInstance,
|
||||
kill: mockKill,
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
|
||||
onExitCb = cb
|
||||
})
|
||||
})
|
||||
const exits: { id: string; paneKey?: string }[] = []
|
||||
handler.setExitListener((evt) => exits.push(evt))
|
||||
|
||||
await dispatcher.callRequest('pty.spawn', {
|
||||
env: { ORCA_PANE_KEY: 'tab-shutdown:0' }
|
||||
})
|
||||
await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true })
|
||||
onExitCb!({ exitCode: 0 })
|
||||
|
||||
expect(mockKill).toHaveBeenCalledWith('SIGKILL')
|
||||
expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-shutdown:0' }])
|
||||
expect(handler.activePtyCount).toBe(0)
|
||||
})
|
||||
|
||||
it('dispose kills all PTYs with SIGKILL and invokes exit listeners', async () => {
|
||||
const mockKill = vi.fn()
|
||||
mockPtySpawn.mockReturnValue({
|
||||
...mockPtyInstance,
|
||||
|
|
@ -502,9 +637,11 @@ describe('PtyHandler', () => {
|
|||
onData: vi.fn(),
|
||||
onExit: vi.fn()
|
||||
})
|
||||
const exits: { id: string; paneKey?: string }[] = []
|
||||
handler.setExitListener((evt) => exits.push(evt))
|
||||
|
||||
await dispatcher.callRequest('pty.spawn', {})
|
||||
await dispatcher.callRequest('pty.spawn', {})
|
||||
await dispatcher.callRequest('pty.spawn', { env: { ORCA_PANE_KEY: 'tab-dispose:0' } })
|
||||
await dispatcher.callRequest('pty.spawn', { env: { ORCA_PANE_KEY: 'tab-dispose:1' } })
|
||||
expect(handler.activePtyCount).toBe(2)
|
||||
|
||||
handler.dispose()
|
||||
|
|
@ -513,6 +650,10 @@ describe('PtyHandler', () => {
|
|||
// wedged process, uninterruptible sleep) would survive SIGTERM + immediate
|
||||
// destroy() as an orphan on the remote host. SIGKILL is not ignorable.
|
||||
expect(mockKill).toHaveBeenCalledWith('SIGKILL')
|
||||
expect(exits).toEqual([
|
||||
{ id: 'pty-1', paneKey: 'tab-dispose:0' },
|
||||
{ id: 'pty-2', paneKey: 'tab-dispose:1' }
|
||||
])
|
||||
expect(handler.activePtyCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
getForegroundProcessName,
|
||||
listShellProfiles
|
||||
} from './pty-shell-utils'
|
||||
import { getRelayShellLaunchConfig } from './pty-shell-launch'
|
||||
|
||||
// Why: node-pty is a native addon that may not be installed on the remote.
|
||||
// Dynamic import keeps the require() lazy so loadPty() returns null gracefully
|
||||
|
|
@ -39,6 +40,10 @@ type ManagedPty = {
|
|||
* entry-point calls into a clean "not found" error instead of a silent no-op
|
||||
* (POSIX proc.kill is neutralized inside disposeManagedPty). */
|
||||
disposed?: boolean
|
||||
/** True once external cleanup observers have been notified. Forced cleanup
|
||||
* paths can run before node-pty emits onExit; this prevents duplicate
|
||||
* overlay/cache cleanup if onExit arrives later. */
|
||||
exitListenerNotified?: boolean
|
||||
/** Renderer-supplied paneKey from spawn env (ORCA_PANE_KEY). Captured so
|
||||
* external observers (the relay-hook-server cache) can evict per-pane
|
||||
* state when this PTY exits. Symmetric with Orca's local pty.ts. */
|
||||
|
|
@ -102,7 +107,16 @@ type SerializedPtyEntry = {
|
|||
}
|
||||
|
||||
export type PtyExitListener = (event: { id: string; paneKey?: string }) => void
|
||||
export type PtyEnvAugmenter = () => Record<string, string>
|
||||
/** Returns env to merge into the PTY's spawn env. Receives spawn context so
|
||||
* augmenters that need a per-PTY identity (e.g. OPENCODE_CONFIG_DIR overlay
|
||||
* paths derived from the renderer's paneKey) can compute it without pulling
|
||||
* the renderer's env in twice. */
|
||||
export type PtyEnvAugmenter = (ctx: {
|
||||
id: string
|
||||
paneKey?: string
|
||||
shell: string
|
||||
env: Record<string, string>
|
||||
}) => Record<string, string>
|
||||
|
||||
export class PtyHandler {
|
||||
private ptys = new Map<string, ManagedPty>()
|
||||
|
|
@ -110,11 +124,13 @@ export class PtyHandler {
|
|||
private dispatcher: RelayDispatcher
|
||||
private graceTimeMs: number
|
||||
private graceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
// Why: external observers (the relay's hook-server cache) need to drop
|
||||
// per-pane state when a PTY exits. Multiple listeners is unnecessary today
|
||||
// — the hook server is the only consumer — so a single optional callback
|
||||
// keeps the surface tight. A throw inside the listener is swallowed so it
|
||||
// can never block disposeManagedPty / map cleanup.
|
||||
// Why: external observers need to drop per-pane state when a PTY exits.
|
||||
// Today the relay composes multiple consumers (hook-server cache eviction
|
||||
// and plugin-overlay dir cleanup) into a single callback at the call site
|
||||
// (see relay.ts setExitListener). A single optional slot is intentional —
|
||||
// callers compose externally rather than us maintaining a listener list.
|
||||
// A throw inside the listener is swallowed so it can never block
|
||||
// disposeManagedPty / map cleanup.
|
||||
private exitListener: PtyExitListener | null = null
|
||||
// Why: env augmenters injected at relay boot (currently the relay-hook
|
||||
// server's ORCA_AGENT_HOOK_* coords). Run on every spawn so every PTY
|
||||
|
|
@ -156,18 +172,22 @@ export class PtyHandler {
|
|||
* drift between the two paths — revived shells after a relay restart must
|
||||
* see the fresh ORCA_AGENT_HOOK_* coords just like freshly-spawned ones,
|
||||
* otherwise agent-status over SSH silently breaks on every revive. */
|
||||
private buildSpawnEnv(rendererEnv?: Record<string, string>): Record<string, string> {
|
||||
private buildSpawnEnv(
|
||||
rendererEnv: Record<string, string> | undefined,
|
||||
ctx: { id: string; paneKey?: string; shell: string }
|
||||
): Record<string, string> {
|
||||
const baseEnv = { ...process.env, ...rendererEnv } as Record<string, string>
|
||||
const augmented: Record<string, string> = {}
|
||||
for (const augmenter of this.envAugmenters) {
|
||||
try {
|
||||
Object.assign(augmented, augmenter())
|
||||
Object.assign(augmented, augmenter({ ...ctx, env: baseEnv }))
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`[pty-handler] env augmenter threw: ${err instanceof Error ? err.message : String(err)}\n`
|
||||
)
|
||||
}
|
||||
}
|
||||
return { ...process.env, ...rendererEnv, ...augmented } as Record<string, string>
|
||||
return { ...baseEnv, ...augmented }
|
||||
}
|
||||
|
||||
/** Wire onData/onExit listeners for a managed PTY and store it. */
|
||||
|
|
@ -181,6 +201,9 @@ export class PtyHandler {
|
|||
this.dispatcher.notify('pty.data', { id: managed.id, data })
|
||||
})
|
||||
managed.pty.onExit(({ exitCode }: { exitCode: number }) => {
|
||||
if (managed.disposed) {
|
||||
return
|
||||
}
|
||||
// Why: neutralize managed.pty.kill synchronously BEFORE anything else
|
||||
// in this callback. node-pty's UnixTerminal has
|
||||
// `_socket.once('close', () => this.kill('SIGHUP'))` wired at destroy
|
||||
|
|
@ -199,19 +222,7 @@ export class PtyHandler {
|
|||
managed.killTimer = undefined
|
||||
}
|
||||
this.dispatcher.notify('pty.exit', { id: managed.id, code: exitCode })
|
||||
// Why: notify external observers BEFORE deleting the map entry so a
|
||||
// listener that needs to read paneKey from the managed entry still
|
||||
// can. Wrap in try/catch so a throwing listener cannot block fd
|
||||
// release or map cleanup.
|
||||
if (this.exitListener) {
|
||||
try {
|
||||
this.exitListener({ id: managed.id, paneKey: managed.paneKey })
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`[pty-handler] onExit listener threw: ${err instanceof Error ? err.message : String(err)}\n`
|
||||
)
|
||||
}
|
||||
}
|
||||
this.notifyExitListener(managed)
|
||||
this.ptys.delete(managed.id)
|
||||
// Why: release the ptmx fd on the natural-exit path. Without this the
|
||||
// node-pty wrapper's _socket stays alive until GC and the master fd
|
||||
|
|
@ -220,6 +231,26 @@ export class PtyHandler {
|
|||
})
|
||||
}
|
||||
|
||||
private notifyExitListener(managed: ManagedPty): void {
|
||||
if (managed.exitListenerNotified) {
|
||||
return
|
||||
}
|
||||
managed.exitListenerNotified = true
|
||||
// Why: external observers own relay-hook cache eviction and plugin-overlay
|
||||
// cleanup. Natural exits, immediate shutdown, SIGKILL fallback, and relay
|
||||
// process disposal all need the same cleanup even when node-pty never
|
||||
// delivers onExit.
|
||||
if (this.exitListener) {
|
||||
try {
|
||||
this.exitListener({ id: managed.id, paneKey: managed.paneKey })
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`[pty-handler] exit listener threw: ${err instanceof Error ? err.message : String(err)}\n`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
this.dispatcher.onRequest('pty.spawn', (p, context) => this.spawn(p, context))
|
||||
this.dispatcher.onRequest('pty.attach', (p) => this.attach(p))
|
||||
|
|
@ -262,28 +293,31 @@ export class PtyHandler {
|
|||
const shell = resolveDefaultShell()
|
||||
const id = `pty-${this.nextId++}`
|
||||
|
||||
// Why: server-side augmenter values (ORCA_AGENT_HOOK_*) override any
|
||||
// renderer-supplied env so the live hook-server coords always reach the
|
||||
// agent CLI — they come from the relay, not the renderer. See
|
||||
// buildSpawnEnv for the precedence contract.
|
||||
const spawnEnv = this.buildSpawnEnv(env)
|
||||
// Why: server-side augmenter values (ORCA_AGENT_HOOK_* and plugin overlay
|
||||
// dirs) override renderer-supplied env so live remote paths and hook coords
|
||||
// win over local userData paths. The context lets overlay augmenters derive
|
||||
// per-PTY OpenCode/Pi directories from the stable paneKey when present.
|
||||
const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined
|
||||
const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell })
|
||||
const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv)
|
||||
|
||||
// Why: SSH exec channels give the relay a minimal environment without
|
||||
// .zprofile/.bash_profile sourced. Spawning a login shell ensures PATH
|
||||
// includes Homebrew, nvm, and user-installed CLIs (claude, codex, gh).
|
||||
const term = pty.spawn(shell, ['-l'], {
|
||||
// When overlays are injected, the launch wrapper keeps those paths after
|
||||
// user startup files re-export their defaults.
|
||||
const term = pty.spawn(shell, shellLaunch.args, {
|
||||
name: 'xterm-256color',
|
||||
cols,
|
||||
rows,
|
||||
cwd,
|
||||
env: spawnEnv
|
||||
env: { ...spawnEnv, ...shellLaunch.env }
|
||||
})
|
||||
|
||||
// Why: capture the renderer-supplied paneKey on the managed entry so the
|
||||
// exit listener can evict per-pane caches without the relay needing a
|
||||
// separate ptyId→paneKey map. ORCA_PANE_KEY is shaped `${tabId}:${paneId}`
|
||||
// and is bounded by the renderer; the relay treats it as opaque.
|
||||
const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined
|
||||
const tabId = typeof env?.ORCA_TAB_ID === 'string' ? env.ORCA_TAB_ID : undefined
|
||||
const worktreeId = typeof env?.ORCA_WORKTREE_ID === 'string' ? env.ORCA_WORKTREE_ID : undefined
|
||||
const managed: ManagedPty = {
|
||||
|
|
@ -309,6 +343,7 @@ export class PtyHandler {
|
|||
// SIGKILL's onExit is missed (kernel edge case, uninterruptible
|
||||
// sleep), the managed entry + ptmx fd would leak forever. Dispose
|
||||
// synchronously so the entry is gone regardless of onExit timing.
|
||||
this.notifyExitListener(still)
|
||||
disposeManagedPty(still)
|
||||
this.ptys.delete(id)
|
||||
}
|
||||
|
|
@ -391,6 +426,7 @@ export class PtyHandler {
|
|||
// here makes the map hygiene a hard guarantee, not "hopefully onExit
|
||||
// runs". If onExit DOES fire later, its own `this.ptys.delete(id)` is
|
||||
// a no-op.
|
||||
this.notifyExitListener(managed)
|
||||
this.ptys.delete(id)
|
||||
} else {
|
||||
managed.pty.kill('SIGTERM')
|
||||
|
|
@ -414,6 +450,7 @@ export class PtyHandler {
|
|||
// graceful-shutdown's SIGKILL fallback is a hard guarantee, not
|
||||
// "hopefully onExit will run". The disposed guard inside
|
||||
// disposeManagedPty makes a later onExit's dispose a no-op.
|
||||
this.notifyExitListener(still)
|
||||
disposeManagedPty(still)
|
||||
this.ptys.delete(id)
|
||||
}
|
||||
|
|
@ -545,12 +582,19 @@ export class PtyHandler {
|
|||
if (entry.worktreeId) {
|
||||
revivedEnv.ORCA_WORKTREE_ID = entry.worktreeId
|
||||
}
|
||||
const term = ptyMod.spawn(resolveDefaultShell(), ['-l'], {
|
||||
const shell = resolveDefaultShell()
|
||||
const spawnEnv = this.buildSpawnEnv(revivedEnv, {
|
||||
id: entry.id,
|
||||
paneKey: entry.paneKey,
|
||||
shell
|
||||
})
|
||||
const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv)
|
||||
const term = ptyMod.spawn(shell, shellLaunch.args, {
|
||||
name: 'xterm-256color',
|
||||
cols: entry.cols,
|
||||
rows: entry.rows,
|
||||
cwd: entry.cwd,
|
||||
env: this.buildSpawnEnv(revivedEnv)
|
||||
env: { ...spawnEnv, ...shellLaunch.env }
|
||||
})
|
||||
this.wireAndStore({
|
||||
id: entry.id,
|
||||
|
|
@ -612,6 +656,7 @@ export class PtyHandler {
|
|||
} catch {
|
||||
/* child may already be dead */
|
||||
}
|
||||
this.notifyExitListener(managed)
|
||||
disposeManagedPty(managed)
|
||||
}
|
||||
this.ptys.clear()
|
||||
|
|
|
|||
|
|
@ -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}}"'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { homedir } from 'os'
|
||||
import { basename, dirname, join } from 'path'
|
||||
|
||||
const RELAY_SHELL_READY_DIR = '.orca-relay/shell-ready'
|
||||
const POSIX_LOGIN_ARGS = ['-l']
|
||||
|
||||
export type RelayShellLaunchConfig = {
|
||||
args: string[]
|
||||
env: Record<string, string>
|
||||
}
|
||||
|
||||
function quotePosixSingle(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
function hasOverlayRestoreEnv(env: Record<string, string>): boolean {
|
||||
return Boolean(env.ORCA_OPENCODE_CONFIG_DIR || env.ORCA_PI_CODING_AGENT_DIR)
|
||||
}
|
||||
|
||||
function getWrapperRoot(env: Record<string, string>): string {
|
||||
return join(env.HOME || process.env.HOME || homedir(), RELAY_SHELL_READY_DIR)
|
||||
}
|
||||
|
||||
function normalizeOriginalZdotdirCandidate(value: string | undefined): string | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
const normalized = value.replace(/\/+$/, '')
|
||||
if (!normalized || normalized.endsWith('/shell-ready/zsh')) {
|
||||
return null
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function resolveOriginalZdotdir(env: Record<string, string>): string {
|
||||
return (
|
||||
normalizeOriginalZdotdirCandidate(env.ZDOTDIR) ||
|
||||
normalizeOriginalZdotdirCandidate(env.ORCA_ORIG_ZDOTDIR) ||
|
||||
env.HOME ||
|
||||
process.env.HOME ||
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
function ensureOverlayRestoreWrappers(root: string): void {
|
||||
const zshDir = join(root, 'zsh')
|
||||
const bashDir = join(root, 'bash')
|
||||
|
||||
const zshEnv = `# Orca relay zsh overlay wrapper
|
||||
export ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
|
||||
case "\${ORCA_ORIG_ZDOTDIR%/}" in
|
||||
*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;
|
||||
esac
|
||||
[[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && source "$ORCA_ORIG_ZDOTDIR/.zshenv"
|
||||
export ORCA_USER_ZDOTDIR="\${ZDOTDIR:-\${ORCA_ORIG_ZDOTDIR:-$HOME}}"
|
||||
case "\${ORCA_USER_ZDOTDIR%/}" in
|
||||
*/shell-ready/zsh) export ORCA_USER_ZDOTDIR="$HOME" ;;
|
||||
esac
|
||||
export ZDOTDIR=${quotePosixSingle(zshDir)}
|
||||
`
|
||||
const zshProfile = `# Orca relay zsh overlay wrapper
|
||||
_orca_home="\${ORCA_USER_ZDOTDIR:-\${ORCA_ORIG_ZDOTDIR:-$HOME}}"
|
||||
case "\${_orca_home%/}" in
|
||||
*/shell-ready/zsh) _orca_home="$HOME" ;;
|
||||
esac
|
||||
[[ -f "$_orca_home/.zprofile" ]] && source "$_orca_home/.zprofile"
|
||||
`
|
||||
const zshRc = `# Orca relay zsh overlay wrapper
|
||||
_orca_home="\${ORCA_USER_ZDOTDIR:-\${ORCA_ORIG_ZDOTDIR:-$HOME}}"
|
||||
case "\${_orca_home%/}" in
|
||||
*/shell-ready/zsh) _orca_home="$HOME" ;;
|
||||
esac
|
||||
if [[ -o interactive && -f "$_orca_home/.zshrc" ]]; then
|
||||
source "$_orca_home/.zshrc"
|
||||
fi
|
||||
if [[ ! -o login ]]; then
|
||||
# Why: remote startup files can re-export user defaults after relay spawn.
|
||||
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
|
||||
fi
|
||||
`
|
||||
const zshLogin = `# Orca relay zsh overlay wrapper
|
||||
_orca_home="\${ORCA_USER_ZDOTDIR:-\${ORCA_ORIG_ZDOTDIR:-$HOME}}"
|
||||
case "\${_orca_home%/}" in
|
||||
*/shell-ready/zsh) _orca_home="$HOME" ;;
|
||||
esac
|
||||
if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then
|
||||
source "$_orca_home/.zlogin"
|
||||
fi
|
||||
# Why: .zlogin is the final zsh login startup file before the prompt.
|
||||
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
|
||||
`
|
||||
const bashRc = `# Orca relay bash overlay wrapper
|
||||
[[ -f /etc/profile ]] && source /etc/profile
|
||||
if [[ -f "$HOME/.bash_profile" ]]; then
|
||||
source "$HOME/.bash_profile"
|
||||
elif [[ -f "$HOME/.bash_login" ]]; then
|
||||
source "$HOME/.bash_login"
|
||||
elif [[ -f "$HOME/.profile" ]]; then
|
||||
source "$HOME/.profile"
|
||||
fi
|
||||
# Why: remote startup files can re-export user defaults after relay spawn.
|
||||
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
|
||||
`
|
||||
|
||||
const files = [
|
||||
[join(zshDir, '.zshenv'), zshEnv],
|
||||
[join(zshDir, '.zprofile'), zshProfile],
|
||||
[join(zshDir, '.zshrc'), zshRc],
|
||||
[join(zshDir, '.zlogin'), zshLogin],
|
||||
[join(bashDir, 'rcfile'), bashRc]
|
||||
] as const
|
||||
|
||||
for (const [path, content] of files) {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
let existing: string | null = null
|
||||
try {
|
||||
existing = readFileSync(path, 'utf8')
|
||||
} catch {
|
||||
existing = null
|
||||
}
|
||||
// Why: relay wrapper files persist under ~/.orca-relay across app
|
||||
// upgrades. Existence alone is not enough; stale wrappers would miss
|
||||
// later fixes such as preserving post-.zshenv ZDOTDIR.
|
||||
if (existing !== content) {
|
||||
writeFileSync(path, content, 'utf8')
|
||||
}
|
||||
chmodSync(path, 0o644)
|
||||
}
|
||||
}
|
||||
|
||||
export function getRelayShellLaunchConfig(
|
||||
shellPath: string,
|
||||
env: Record<string, string>
|
||||
): RelayShellLaunchConfig {
|
||||
if (!hasOverlayRestoreEnv(env) || process.platform === 'win32') {
|
||||
return { args: POSIX_LOGIN_ARGS, env: {} }
|
||||
}
|
||||
|
||||
const shellName = basename(shellPath).toLowerCase()
|
||||
if (shellName !== 'zsh' && shellName !== 'bash') {
|
||||
return { args: POSIX_LOGIN_ARGS, env: {} }
|
||||
}
|
||||
|
||||
const root = getWrapperRoot(env)
|
||||
ensureOverlayRestoreWrappers(root)
|
||||
|
||||
if (shellName === 'zsh') {
|
||||
return {
|
||||
args: POSIX_LOGIN_ARGS,
|
||||
env: {
|
||||
ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(env),
|
||||
ZDOTDIR: join(root, 'zsh')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
args: ['--rcfile', join(root, 'bash', 'rcfile')],
|
||||
env: {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,9 @@
|
|||
#!/usr/bin/env node
|
||||
/* oxlint-disable max-lines -- Why: the relay entry point centralizes process
|
||||
lifecycle (stdio, --connect bridge, grace timer, signal handlers, socket
|
||||
server) and handler registration in one file so the boot sequence stays in
|
||||
topological order. Splitting by line count would scatter ordered side-
|
||||
effects across modules and obscure the lifecycle. */
|
||||
|
||||
// Orca Relay — lightweight daemon deployed to remote hosts.
|
||||
// Communicates over stdin/stdout using the framed JSON-RPC protocol.
|
||||
|
|
@ -24,11 +29,14 @@ import { GitHandler } from './git-handler'
|
|||
import { PreflightHandler } from './preflight-handler'
|
||||
import { PortScanHandler } from './port-scan-handler'
|
||||
import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server'
|
||||
import { PluginOverlayManager } from './plugin-overlay'
|
||||
import {
|
||||
AGENT_HOOK_INSTALL_PLUGINS_METHOD,
|
||||
AGENT_HOOK_NOTIFICATION_METHOD,
|
||||
AGENT_HOOK_REQUEST_REPLAY_METHOD
|
||||
} from '../shared/agent-hook-relay'
|
||||
import { assertPluginSourceUnderByteCap } from './plugin-source-limit'
|
||||
import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env'
|
||||
|
||||
const DEFAULT_GRACE_MS = 5 * 60 * 1000
|
||||
const SOCK_NAME = 'relay.sock'
|
||||
|
|
@ -247,26 +255,77 @@ async function main(): Promise<void> {
|
|||
)
|
||||
}
|
||||
})
|
||||
// Why: wait for hook-server startup before the readiness sentinel. A PTY
|
||||
// spawned before the augmenter exists can never receive ORCA_AGENT_HOOK_*
|
||||
// later, so success registers the augmenter first; failure is the deliberate
|
||||
// fail-open path where agent status is disabled for this relay process.
|
||||
// Why: await the hook-server bind before announcing readiness so the very
|
||||
// first PTY spawn (which can land within milliseconds of the sentinel)
|
||||
// already sees populated ORCA_AGENT_HOOK_* env. The bind is a local-loopback
|
||||
// listen — measured in ms — so the latency cost is trivial and removes a
|
||||
// class of "first agent invocation has no status" races. Bind failure is
|
||||
// treated as soft: log and continue, the augmenter returns {} and agent
|
||||
// status simply does not flow.
|
||||
try {
|
||||
await hookServer.start()
|
||||
ptyHandler.addEnvAugmenter(() => hookServer.buildPtyEnv())
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`[relay] agent-hook server failed to start: ${err instanceof Error ? err.message : String(err)}\n`
|
||||
)
|
||||
}
|
||||
|
||||
// Why: evict the per-pane last-status cache when the backing PTY exits so
|
||||
// a terminated pane's last working/done payload cannot resurface as a
|
||||
// ghost event after a later reconnect — see §5 Path 3.
|
||||
ptyHandler.setExitListener(({ paneKey }) => {
|
||||
// Why: every relay-spawned PTY needs the live ORCA_AGENT_HOOK_* coords. The
|
||||
// augmenter is read on every spawn so a hook-server bind that succeeded
|
||||
// late (or after a stop/start) lands in the next PTY's env without a
|
||||
// restart.
|
||||
ptyHandler.addEnvAugmenter(() => hookServer.buildPtyEnv())
|
||||
|
||||
// Why: per-PTY plugin overlays for OpenCode and Pi. `OPENCODE_CONFIG_DIR`
|
||||
// and `PI_CODING_AGENT_DIR` only make sense on the relay's own filesystem
|
||||
// — paths the renderer would synthesize for the Orca host's userData are
|
||||
// meaningless on the remote. The overlay manager materializes a per-PTY
|
||||
// dir on the remote (rooted at $HOME/.orca-relay/) so the agent CLI inside
|
||||
// the relay-spawned PTY loads the bundled status plugin and posts to the
|
||||
// relay's hook server. Source bodies arrive over JSON-RPC (see
|
||||
// `agent_hook.installPlugins` below) — not bundled with the relay binary.
|
||||
const pluginOverlay = new PluginOverlayManager()
|
||||
ptyHandler.addEnvAugmenter((ctx) => {
|
||||
const env: Record<string, string> = {}
|
||||
// Why: prefer paneKey for overlay identity so a renderer-side remount
|
||||
// that reuses the paneKey lands in the same overlay dir. Falls back to
|
||||
// the relay-internal pty-id when paneKey is absent (e.g. CLI-launched
|
||||
// PTYs that don't go through the renderer).
|
||||
const overlayId = ctx.paneKey ?? ctx.id
|
||||
if (pluginOverlay.hasOpenCodeSource()) {
|
||||
const sourceDir = resolveOpenCodeSourceConfigDir(ctx.env, ctx.shell)
|
||||
const dir = pluginOverlay.materializeOpenCode(overlayId, sourceDir)
|
||||
if (dir) {
|
||||
env.OPENCODE_CONFIG_DIR = dir
|
||||
env.ORCA_OPENCODE_CONFIG_DIR = dir
|
||||
if (sourceDir) {
|
||||
env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = sourceDir
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pluginOverlay.hasPiSource()) {
|
||||
const sourceDir = resolvePiSourceAgentDir(ctx.env, ctx.shell)
|
||||
const dir = pluginOverlay.materializePi(overlayId, sourceDir)
|
||||
if (dir) {
|
||||
env.PI_CODING_AGENT_DIR = dir
|
||||
env.ORCA_PI_CODING_AGENT_DIR = dir
|
||||
if (sourceDir) {
|
||||
env.ORCA_PI_SOURCE_AGENT_DIR = sourceDir
|
||||
}
|
||||
}
|
||||
}
|
||||
return env
|
||||
})
|
||||
|
||||
// Why: evict the per-pane last-status cache AND any plugin overlay dirs
|
||||
// when the backing PTY exits so terminated panes do not (a) resurface as
|
||||
// ghost events after a later reconnect (§5 Path 3) or (b) leak overlay
|
||||
// dirs on a long-lived relay.
|
||||
ptyHandler.setExitListener(({ paneKey, id }) => {
|
||||
if (paneKey) {
|
||||
hookServer.clearPaneState(paneKey)
|
||||
}
|
||||
pluginOverlay.clearOverlay(paneKey ?? id)
|
||||
})
|
||||
|
||||
// Why: request-driven replay. Orca issues this *after* it re-wires the
|
||||
|
|
@ -280,12 +339,31 @@ async function main(): Promise<void> {
|
|||
return { replayed }
|
||||
})
|
||||
|
||||
// Why: stub for the plugin-source sync handler used by OpenCode/Pi. The
|
||||
// real implementation is wired in commit #7 (deferred to keep this commit
|
||||
// tight). Stubbed out here as a method-found handler so a probing client
|
||||
// can detect support without -32601 noise on first connect.
|
||||
dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async () => {
|
||||
return { installed: false }
|
||||
// Why: Orca ships the OpenCode plugin / Pi extension source bodies over
|
||||
// the wire at session-ready (the renderer's bundled hook-service strings
|
||||
// change as new agent events are added — pinning them to the relay binary
|
||||
// would force a relay redeploy on every Orca update). Cache them so each
|
||||
// subsequent PTY spawn can materialize a per-PTY overlay rooted under
|
||||
// $HOME/.orca-relay/. See docs/design/agent-status-over-ssh.md §4.
|
||||
// Why: bound the per-source size so a buggy/hostile Orca can't OOM the
|
||||
// relay by pushing a giant string. The HTTP path has HOOK_REQUEST_MAX_BYTES
|
||||
// = 1 MB; the JSON-RPC path needs an equivalent ceiling. Real plugin sources
|
||||
// are <50 KB today; 256 KB leaves generous headroom.
|
||||
dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => {
|
||||
const opencode = params.opencodePluginSource
|
||||
const pi = params.piExtensionSource
|
||||
assertPluginSourceUnderByteCap('opencodePluginSource', opencode)
|
||||
assertPluginSourceUnderByteCap('piExtensionSource', pi)
|
||||
pluginOverlay.setSources({
|
||||
opencodePluginSource: typeof opencode === 'string' ? opencode : undefined,
|
||||
piExtensionSource: typeof pi === 'string' ? pi : undefined
|
||||
})
|
||||
return {
|
||||
installed: {
|
||||
opencode: pluginOverlay.hasOpenCodeSource(),
|
||||
pi: pluginOverlay.hasPiSource()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── Socket server for reconnection ──────────────────────────────────
|
||||
|
|
@ -488,7 +566,7 @@ function cleanupSocket(sockPath: string): void {
|
|||
|
||||
void main().catch((err) => {
|
||||
process.stderr.write(
|
||||
`[relay] Fatal startup error: ${err instanceof Error ? err.message : String(err)}\n`
|
||||
`[relay] Fatal startup error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`
|
||||
)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue