perf(ssh): cut warm high-latency connects from 88.7s to 7.7s (#9015)
Move managed agent-hook filesystem work behind one relay RPC so high-latency SSH connects pay one WAN round trip instead of hundreds. Keep installers serial, lock shared account config across relay processes, and fence cancelled connection generations from replacement state. Co-authored-by: nasagong <zinho2000@gachon.ac.kr> Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
parent
aca7d50bab
commit
4f81dfc128
|
|
@ -18,6 +18,14 @@ const __dirname = import.meta.dirname
|
|||
const ROOT = join(__dirname, '..', '..')
|
||||
const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts')
|
||||
const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts')
|
||||
const MANAGED_HOOK_RUNTIME_ENTRY = join(
|
||||
ROOT,
|
||||
'src',
|
||||
'main',
|
||||
'agent-hooks',
|
||||
'managed-hook-runtime.ts'
|
||||
)
|
||||
const JSONC_PARSER_ESM_ENTRY = join(ROOT, 'node_modules', 'jsonc-parser', 'lib', 'esm', 'main.js')
|
||||
|
||||
const PLATFORMS = [
|
||||
'linux-x64',
|
||||
|
|
@ -66,14 +74,33 @@ for (const platform of PLATFORMS) {
|
|||
}
|
||||
})
|
||||
|
||||
await build({
|
||||
entryPoints: [MANAGED_HOOK_RUNTIME_ENTRY],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: join(outDir, 'managed-hook-runtime.js'),
|
||||
// Why: jsonc-parser's default UMD build keeps relative dynamic requires
|
||||
// that break after bundling; its ESM entry is equivalent and self-contained.
|
||||
alias: { 'jsonc-parser': JSONC_PARSER_ESM_ENTRY },
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"'
|
||||
}
|
||||
})
|
||||
|
||||
// Why: include a content hash so the deploy check detects code changes
|
||||
// even when RELAY_VERSION hasn't been bumped. Hash both process artifacts
|
||||
// so a watcher-only change always deploys beside the matching relay host.
|
||||
// even when RELAY_VERSION hasn't been bumped. Hash every executable module
|
||||
// so a companion-only change always deploys beside the matching relay host.
|
||||
const relayContent = readFileSync(join(outDir, 'relay.js'))
|
||||
const watcherContent = readFileSync(join(outDir, 'relay-watcher.js'))
|
||||
const managedHookRuntimeContent = readFileSync(join(outDir, 'managed-hook-runtime.js'))
|
||||
const hash = createHash('sha256')
|
||||
.update(relayContent)
|
||||
.update(watcherContent)
|
||||
.update(managedHookRuntimeContent)
|
||||
.digest('hex')
|
||||
.slice(0, 12)
|
||||
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,397 @@
|
|||
import {
|
||||
link,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
unlink,
|
||||
utimes,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as ownerIdentity from './managed-hook-owner-identity'
|
||||
import { withManagedHookInstallLock } from './managed-hook-install-lock'
|
||||
|
||||
const tempHomes: string[] = []
|
||||
const STALE_TOKEN = '00000000-0000-4000-8000-000000000000'
|
||||
const STALE_CLAIM_TOKEN = '11111111-1111-4111-8111-111111111111'
|
||||
const fsFailure = vi.hoisted(() => ({
|
||||
canonicalUnlinkPath: null as string | null,
|
||||
contendWithoutLock: null as (() => void) | null
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const original = await importOriginal<{ link: typeof link; unlink: typeof unlink }>()
|
||||
return {
|
||||
...original,
|
||||
link: async (...args: Parameters<typeof original.link>) => {
|
||||
if (fsFailure.contendWithoutLock && String(args[1]).endsWith('managed-hook-install.lock')) {
|
||||
fsFailure.contendWithoutLock()
|
||||
throw Object.assign(new Error('injected publication contention'), { code: 'EEXIST' })
|
||||
}
|
||||
await original.link(...args)
|
||||
},
|
||||
unlink: async (path: Parameters<typeof original.unlink>[0]) => {
|
||||
if (String(path) === fsFailure.canonicalUnlinkPath) {
|
||||
fsFailure.canonicalUnlinkPath = null
|
||||
throw Object.assign(new Error('injected unlink failure'), { code: 'EACCES' })
|
||||
}
|
||||
await original.unlink(path)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function createTempHome(): Promise<string> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'orca-managed-hook-lock-'))
|
||||
tempHomes.push(home)
|
||||
return home
|
||||
}
|
||||
|
||||
async function requireHostIdentity(): Promise<string> {
|
||||
const hostIdentity = await ownerIdentity.readManagedHookHostIdentity()
|
||||
if (!hostIdentity) {
|
||||
throw new Error('test host identity is unavailable')
|
||||
}
|
||||
return hostIdentity
|
||||
}
|
||||
|
||||
async function createOwnedLock(
|
||||
home: string,
|
||||
processIdentity: string,
|
||||
hostIdentity?: string
|
||||
): Promise<void> {
|
||||
const lockHostIdentity = hostIdentity ?? (await requireHostIdentity())
|
||||
const lockParent = join(home, '.orca')
|
||||
const ownerPath = join(lockParent, `managed-hook-install.owner-${STALE_TOKEN}.json`)
|
||||
await mkdir(lockParent, { recursive: true })
|
||||
await writeFile(
|
||||
ownerPath,
|
||||
JSON.stringify({
|
||||
token: STALE_TOKEN,
|
||||
pid: process.pid,
|
||||
hostIdentity: lockHostIdentity,
|
||||
processIdentity
|
||||
})
|
||||
)
|
||||
await link(ownerPath, join(lockParent, 'managed-hook-install.lock'))
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
fsFailure.canonicalUnlinkPath = null
|
||||
fsFailure.contendWithoutLock = null
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
await Promise.all(tempHomes.splice(0).map((home) => rm(home, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('withManagedHookInstallLock', () => {
|
||||
it('enforces the deadline when publication contention observes a missing lock', async () => {
|
||||
const home = await createTempHome()
|
||||
const controller = new AbortController()
|
||||
const run = vi.fn()
|
||||
let now = 0
|
||||
fsFailure.contendWithoutLock = () => controller.abort()
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => (now += 10_000))
|
||||
|
||||
await expect(withManagedHookInstallLock(home, controller.signal, run)).rejects.toThrow(
|
||||
'Timed out waiting for another managed-hook install to finish'
|
||||
)
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('serializes installers across relay runtime instances', async () => {
|
||||
const home = await createTempHome()
|
||||
let releaseFirst!: () => void
|
||||
let markFirstStarted!: () => void
|
||||
const firstStarted = new Promise<void>((resolve) => (markFirstStarted = resolve))
|
||||
const secondRun = vi.fn(async () => 'second')
|
||||
const first = withManagedHookInstallLock(home, undefined, () => {
|
||||
markFirstStarted()
|
||||
return new Promise<string>((resolve) => (releaseFirst = () => resolve('first')))
|
||||
})
|
||||
await firstStarted
|
||||
const second = withManagedHookInstallLock(home, undefined, secondRun)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||
expect(secondRun).not.toHaveBeenCalled()
|
||||
releaseFirst()
|
||||
|
||||
await expect(first).resolves.toBe('first')
|
||||
await expect(second).resolves.toBe('second')
|
||||
})
|
||||
|
||||
it('atomically publishes a complete owner record before entering the installer', async () => {
|
||||
const home = await createTempHome()
|
||||
const lockParent = join(home, '.orca')
|
||||
const lockPath = join(lockParent, 'managed-hook-install.lock')
|
||||
|
||||
await withManagedHookInstallLock(home, undefined, async () => {
|
||||
const entries = await readdir(lockParent)
|
||||
const ownerEntry = entries.find((entry) => entry.startsWith('managed-hook-install.owner-'))
|
||||
expect(entries).toHaveLength(2)
|
||||
expect(ownerEntry).toBeDefined()
|
||||
if (!ownerEntry) {
|
||||
throw new Error('owner entry was not published')
|
||||
}
|
||||
const ownerPath = join(lockParent, ownerEntry)
|
||||
const owner = JSON.parse(await readFile(lockPath, 'utf8')) as {
|
||||
token: string
|
||||
pid: number
|
||||
hostIdentity: string
|
||||
processIdentity: string
|
||||
}
|
||||
expect(owner).toMatchObject({ pid: process.pid })
|
||||
expect(owner.token).toMatch(/^[\da-f-]{36}$/)
|
||||
expect(owner.hostIdentity.length).toBeGreaterThan(0)
|
||||
expect(owner.processIdentity.length).toBeGreaterThan(0)
|
||||
const [lockStats, ownerStats] = await Promise.all([lstat(lockPath), lstat(ownerPath)])
|
||||
expect(lockStats.ino).toBe(ownerStats.ino)
|
||||
expect(lockStats.nlink).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
it('fails fast instead of stealing an unverifiable legacy directory lock', async () => {
|
||||
const home = await createTempHome()
|
||||
await mkdir(join(home, '.orca', 'managed-hook-install.lock'), { recursive: true })
|
||||
const run = vi.fn()
|
||||
|
||||
await expect(withManagedHookInstallLock(home, undefined, run)).rejects.toThrow(
|
||||
'unverifiable owner'
|
||||
)
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('recovers after the same SSH host reboots even if its PID and start time are reused', async () => {
|
||||
const home = await createTempHome()
|
||||
await createOwnedLock(home, 'stale-process-incarnation')
|
||||
|
||||
await expect(
|
||||
withManagedHookInstallLock(home, undefined, async () => 'installed')
|
||||
).resolves.toBe('installed')
|
||||
})
|
||||
|
||||
it('does not compare PID identities or steal a lock owned by another SSH host', async () => {
|
||||
const home = await createTempHome()
|
||||
const lockPath = join(home, '.orca', 'managed-hook-install.lock')
|
||||
await createOwnedLock(home, 'stale-process-incarnation', 'another-host-boot')
|
||||
const originalLock = await readFile(lockPath, 'utf8')
|
||||
|
||||
await expect(withManagedHookInstallLock(home, undefined, vi.fn())).rejects.toThrow(
|
||||
'belongs to another host'
|
||||
)
|
||||
expect(await readFile(lockPath, 'utf8')).toBe(originalLock)
|
||||
})
|
||||
|
||||
it('does not steal a live lock when its owner process probe becomes unavailable', async () => {
|
||||
const home = await createTempHome()
|
||||
const lockPath = join(home, '.orca', 'managed-hook-install.lock')
|
||||
await createOwnedLock(home, 'another-process-incarnation')
|
||||
const originalLock = await readFile(lockPath, 'utf8')
|
||||
const selfIdentity = await ownerIdentity.readManagedHookProcessIdentity(process.pid)
|
||||
expect(selfIdentity).toBeTypeOf('string')
|
||||
vi.spyOn(ownerIdentity, 'readManagedHookProcessIdentity')
|
||||
.mockResolvedValueOnce(selfIdentity)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
|
||||
await expect(withManagedHookInstallLock(home, undefined, vi.fn())).rejects.toThrow(
|
||||
'Could not verify the managed-hook lock owner process'
|
||||
)
|
||||
expect(await readFile(lockPath, 'utf8')).toBe(originalLock)
|
||||
})
|
||||
|
||||
it('serializes two contenders that concurrently recover the same dead owner', async () => {
|
||||
const home = await createTempHome()
|
||||
await createOwnedLock(home, 'stale-process-incarnation')
|
||||
|
||||
let releaseFirst!: () => void
|
||||
let activeRuns = 0
|
||||
let maxActiveRuns = 0
|
||||
const run = vi.fn(async () => {
|
||||
activeRuns += 1
|
||||
maxActiveRuns = Math.max(maxActiveRuns, activeRuns)
|
||||
if (run.mock.calls.length === 1) {
|
||||
await new Promise<void>((resolve) => (releaseFirst = resolve))
|
||||
}
|
||||
activeRuns -= 1
|
||||
})
|
||||
const first = withManagedHookInstallLock(home, undefined, run)
|
||||
const second = withManagedHookInstallLock(home, undefined, run)
|
||||
|
||||
await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1))
|
||||
expect(maxActiveRuns).toBe(1)
|
||||
releaseFirst()
|
||||
await Promise.all([first, second])
|
||||
expect(run).toHaveBeenCalledTimes(2)
|
||||
expect(maxActiveRuns).toBe(1)
|
||||
})
|
||||
|
||||
it('recovers a canonical lock after its previous recovery claimant crashes', async () => {
|
||||
const home = await createTempHome()
|
||||
const lockParent = join(home, '.orca')
|
||||
const ownerPath = join(lockParent, `managed-hook-install.owner-${STALE_TOKEN}.json`)
|
||||
const claimedOwnerPath = join(
|
||||
lockParent,
|
||||
`managed-hook-install.claimed-${STALE_TOKEN}-${STALE_CLAIM_TOKEN}.json`
|
||||
)
|
||||
const claimRecordPath = join(
|
||||
lockParent,
|
||||
`managed-hook-install.claim-${STALE_TOKEN}-${STALE_CLAIM_TOKEN}.json`
|
||||
)
|
||||
const hostIdentity = await requireHostIdentity()
|
||||
await createOwnedLock(home, 'stale-owner-incarnation', hostIdentity)
|
||||
await writeFile(
|
||||
claimRecordPath,
|
||||
JSON.stringify({
|
||||
ownerToken: STALE_TOKEN,
|
||||
claimToken: STALE_CLAIM_TOKEN,
|
||||
pid: process.pid,
|
||||
hostIdentity,
|
||||
processIdentity: 'stale-claimant-incarnation'
|
||||
})
|
||||
)
|
||||
await rename(ownerPath, claimedOwnerPath)
|
||||
|
||||
await expect(
|
||||
withManagedHookInstallLock(home, undefined, async () => 'installed')
|
||||
).resolves.toBe('installed')
|
||||
expect(await readdir(lockParent)).toEqual([])
|
||||
})
|
||||
|
||||
it('fails fast when a recovery claimant disappears before removing the canonical lock', async () => {
|
||||
const home = await createTempHome()
|
||||
const lockParent = join(home, '.orca')
|
||||
const lockPath = join(lockParent, 'managed-hook-install.lock')
|
||||
const ownerPath = join(lockParent, `managed-hook-install.owner-${STALE_TOKEN}.json`)
|
||||
await createOwnedLock(home, 'stale-process-incarnation')
|
||||
await unlink(ownerPath)
|
||||
|
||||
const startedAt = Date.now()
|
||||
await expect(withManagedHookInstallLock(home, undefined, vi.fn())).rejects.toThrow(
|
||||
'unverifiable recovery claim'
|
||||
)
|
||||
expect(Date.now() - startedAt).toBeLessThan(500)
|
||||
expect(await readFile(lockPath, 'utf8')).toContain(STALE_TOKEN)
|
||||
})
|
||||
|
||||
it('cleans an unlinked owner draft left by a crashed acquisition', async () => {
|
||||
const home = await createTempHome()
|
||||
const lockParent = join(home, '.orca')
|
||||
const staleOwnerEntry = `managed-hook-install.owner-${STALE_TOKEN}.json`
|
||||
const ownerPath = join(lockParent, staleOwnerEntry)
|
||||
await mkdir(lockParent, { recursive: true })
|
||||
const hostIdentity = await requireHostIdentity()
|
||||
await writeFile(
|
||||
ownerPath,
|
||||
JSON.stringify({
|
||||
token: STALE_TOKEN,
|
||||
pid: process.pid,
|
||||
hostIdentity,
|
||||
processIdentity: 'stale-process-incarnation'
|
||||
})
|
||||
)
|
||||
|
||||
await withManagedHookInstallLock(home, undefined, async () => {
|
||||
expect(await readdir(lockParent)).not.toContain(staleOwnerEntry)
|
||||
})
|
||||
expect(await readdir(lockParent)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not delete a malformed final owner record that an older relay may still publish', async () => {
|
||||
const home = await createTempHome()
|
||||
const lockParent = join(home, '.orca')
|
||||
const staleOwnerEntry = `managed-hook-install.owner-${STALE_TOKEN}.json`
|
||||
const ownerPath = join(lockParent, staleOwnerEntry)
|
||||
await mkdir(lockParent, { recursive: true })
|
||||
await writeFile(ownerPath, '{"token":')
|
||||
const staleTime = new Date(Date.now() - 2_000)
|
||||
await utimes(ownerPath, staleTime, staleTime)
|
||||
|
||||
await withManagedHookInstallLock(home, undefined, async () => {
|
||||
expect(await readdir(lockParent)).toContain(staleOwnerEntry)
|
||||
})
|
||||
expect(await readdir(lockParent)).toEqual([staleOwnerEntry])
|
||||
})
|
||||
|
||||
it('does not let a late release delete a replacement owner lock', async () => {
|
||||
const home = await createTempHome()
|
||||
const lockPath = join(home, '.orca', 'managed-hook-install.lock')
|
||||
let releaseFirst!: () => void
|
||||
let markFirstStarted!: () => void
|
||||
const firstStarted = new Promise<void>((resolve) => (markFirstStarted = resolve))
|
||||
const first = withManagedHookInstallLock(home, undefined, () => {
|
||||
markFirstStarted()
|
||||
return new Promise<void>((resolve) => (releaseFirst = resolve))
|
||||
})
|
||||
await firstStarted
|
||||
await rename(lockPath, `${lockPath}.displaced`)
|
||||
|
||||
let releaseSecond!: () => void
|
||||
let markSecondStarted!: () => void
|
||||
const secondStarted = new Promise<void>((resolve) => (markSecondStarted = resolve))
|
||||
const second = withManagedHookInstallLock(home, undefined, () => {
|
||||
markSecondStarted()
|
||||
return new Promise<void>((resolve) => (releaseSecond = resolve))
|
||||
})
|
||||
await secondStarted
|
||||
|
||||
releaseFirst()
|
||||
await first
|
||||
const thirdRun = vi.fn()
|
||||
const controller = new AbortController()
|
||||
const third = withManagedHookInstallLock(home, controller.signal, thirdRun)
|
||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||
expect(thirdRun).not.toHaveBeenCalled()
|
||||
controller.abort()
|
||||
await expect(third).rejects.toMatchObject({ name: 'AbortError' })
|
||||
|
||||
releaseSecond()
|
||||
await second
|
||||
})
|
||||
|
||||
it('finishes an abandoned same-process release before the next install', async () => {
|
||||
const home = await createTempHome()
|
||||
const lockParent = join(home, '.orca')
|
||||
const lockPath = join(lockParent, 'managed-hook-install.lock')
|
||||
fsFailure.canonicalUnlinkPath = lockPath
|
||||
|
||||
await expect(withManagedHookInstallLock(home, undefined, async () => 'first')).resolves.toBe(
|
||||
'first'
|
||||
)
|
||||
expect(await readdir(lockParent)).toContain('managed-hook-install.lock')
|
||||
|
||||
const startedAt = Date.now()
|
||||
await expect(withManagedHookInstallLock(home, undefined, async () => 'second')).resolves.toBe(
|
||||
'second'
|
||||
)
|
||||
expect(Date.now() - startedAt).toBeLessThan(500)
|
||||
expect(await readdir(lockParent)).toEqual([])
|
||||
})
|
||||
|
||||
it('cancels a request waiting behind another relay runtime', async () => {
|
||||
const home = await createTempHome()
|
||||
let releaseFirst!: () => void
|
||||
let markFirstStarted!: () => void
|
||||
const firstStarted = new Promise<void>((resolve) => (markFirstStarted = resolve))
|
||||
const first = withManagedHookInstallLock(home, undefined, () => {
|
||||
markFirstStarted()
|
||||
return new Promise<void>((resolve) => (releaseFirst = resolve))
|
||||
})
|
||||
await firstStarted
|
||||
const controller = new AbortController()
|
||||
const secondRun = vi.fn()
|
||||
const second = withManagedHookInstallLock(home, controller.signal, secondRun)
|
||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||
|
||||
controller.abort()
|
||||
|
||||
await expect(second).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(secondRun).not.toHaveBeenCalled()
|
||||
releaseFirst()
|
||||
await first
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import { mkdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
import { removeManagedHookLock } from './managed-hook-lock-claims'
|
||||
import {
|
||||
cleanupManagedHookLockFiles,
|
||||
deactivateManagedHookLockOwner,
|
||||
inspectManagedHookLock,
|
||||
isManagedHookLockOwnerActive,
|
||||
tryCreateManagedHookLock,
|
||||
type ManagedHookLockOwner
|
||||
} from './managed-hook-lock-records'
|
||||
import {
|
||||
readManagedHookHostIdentity,
|
||||
readManagedHookProcessIdentity
|
||||
} from './managed-hook-owner-identity'
|
||||
|
||||
const LOCK_WAIT_TIMEOUT_MS = 10_000
|
||||
const LOCK_RETRY_MS = 20
|
||||
|
||||
async function releaseInstallLock(
|
||||
lockPath: string,
|
||||
lockParent: string,
|
||||
owner: ManagedHookLockOwner,
|
||||
hostIdentity: string,
|
||||
processIdentity: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const removal = await removeManagedHookLock(
|
||||
lockPath,
|
||||
lockParent,
|
||||
owner,
|
||||
hostIdentity,
|
||||
processIdentity
|
||||
)
|
||||
if (removal !== 'removed') {
|
||||
console.warn(`[agent-hooks] Failed to release managed-hook install lock: ${removal}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[agent-hooks] Failed to release managed-hook install lock', error)
|
||||
} finally {
|
||||
deactivateManagedHookLockOwner(owner.token)
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireInstallLock(
|
||||
home: string,
|
||||
signal?: AbortSignal,
|
||||
suppliedHostIdentity?: string
|
||||
): Promise<() => Promise<void>> {
|
||||
const lockParent = join(home, '.orca')
|
||||
const lockPath = join(lockParent, 'managed-hook-install.lock')
|
||||
await mkdir(lockParent, { recursive: true })
|
||||
const hostIdentity = suppliedHostIdentity ?? (await readManagedHookHostIdentity())
|
||||
await cleanupManagedHookLockFiles(lockParent, hostIdentity)
|
||||
const processIdentity = await readManagedHookProcessIdentity(process.pid)
|
||||
if (typeof processIdentity !== 'string') {
|
||||
throw new Error('Could not identify the managed-hook installer process')
|
||||
}
|
||||
const deadline = Date.now() + LOCK_WAIT_TIMEOUT_MS
|
||||
|
||||
while (true) {
|
||||
signal?.throwIfAborted()
|
||||
const owner = await tryCreateManagedHookLock(
|
||||
lockParent,
|
||||
lockPath,
|
||||
hostIdentity,
|
||||
processIdentity
|
||||
)
|
||||
if (owner) {
|
||||
return async () =>
|
||||
await releaseInstallLock(lockPath, lockParent, owner, hostIdentity, processIdentity)
|
||||
}
|
||||
|
||||
const state = await inspectManagedHookLock(lockPath)
|
||||
if (state.kind === 'unknown') {
|
||||
// Why: unverifiable legacy locks cannot be stolen safely; hooks are best-effort,
|
||||
// so fail fast instead of adding a recurring connection timeout.
|
||||
throw new Error('Managed-hook install lock has an unverifiable owner')
|
||||
}
|
||||
if (state.kind === 'owned' && state.owner.hostIdentity !== hostIdentity) {
|
||||
// Why: homes can be shared across SSH hosts, whose PID namespaces are unrelated.
|
||||
throw new Error('Managed-hook install lock belongs to another host')
|
||||
}
|
||||
|
||||
if (state.kind === 'owned') {
|
||||
const currentIdentity = await readManagedHookProcessIdentity(state.owner.pid)
|
||||
if (currentIdentity === undefined) {
|
||||
throw new Error('Could not verify the managed-hook lock owner process')
|
||||
}
|
||||
const abandonedOwnLock =
|
||||
state.owner.pid === process.pid &&
|
||||
currentIdentity === processIdentity &&
|
||||
!isManagedHookLockOwnerActive(state.owner.token)
|
||||
if (
|
||||
currentIdentity === null ||
|
||||
currentIdentity !== state.owner.processIdentity ||
|
||||
abandonedOwnLock
|
||||
) {
|
||||
const removal = await removeManagedHookLock(
|
||||
lockPath,
|
||||
lockParent,
|
||||
state.owner,
|
||||
hostIdentity,
|
||||
processIdentity
|
||||
)
|
||||
if (removal === 'removed') {
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error('Timed out waiting for another managed-hook install to finish')
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (removal === 'foreign') {
|
||||
throw new Error('Managed-hook install lock recovery belongs to another host')
|
||||
}
|
||||
if (removal === 'unverifiable') {
|
||||
throw new Error('Managed-hook install lock has an unverifiable recovery claim')
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error('Timed out waiting for another managed-hook install to finish')
|
||||
}
|
||||
await delay(LOCK_RETRY_MS, undefined, { signal })
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialize config merges across relay daemons that target the same account. */
|
||||
export async function withManagedHookInstallLock<T>(
|
||||
home: string,
|
||||
signal: AbortSignal | undefined,
|
||||
run: () => Promise<T>,
|
||||
hostIdentity?: string
|
||||
): Promise<T> {
|
||||
const release = await acquireInstallLock(home, signal, hostIdentity)
|
||||
try {
|
||||
signal?.throwIfAborted()
|
||||
return await run()
|
||||
} finally {
|
||||
await release()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
|
||||
import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem'
|
||||
|
||||
const tempHomes: string[] = []
|
||||
|
||||
async function createTempHome(): Promise<string> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'orca-managed-hooks-'))
|
||||
tempHomes.push(home)
|
||||
return home
|
||||
}
|
||||
|
||||
async function listFiles(root: string): Promise<string[]> {
|
||||
const entries = await readdir(root, { withFileTypes: true })
|
||||
const nested = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const path = join(root, entry.name)
|
||||
return entry.isDirectory() ? await listFiles(path) : [path]
|
||||
})
|
||||
)
|
||||
return nested.flat()
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempHomes.splice(0).map((home) => rm(home, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('managed-hook local filesystem', () => {
|
||||
it('supports cold and warm aggregate installs without SFTP or temp-file residue', async () => {
|
||||
const home = await createTempHome()
|
||||
const filesystem = createManagedHookLocalFilesystem()
|
||||
const options = { grokHomeDir: join(home, '.grok') }
|
||||
|
||||
const cold = await installRemoteManagedAgentHooks(filesystem, home, options)
|
||||
const warm = await installRemoteManagedAgentHooks(filesystem, home, options)
|
||||
|
||||
expect(cold).toHaveLength(14)
|
||||
expect(cold.filter((result) => result.state === 'error')).toEqual([])
|
||||
expect(warm).toHaveLength(14)
|
||||
expect(warm.filter((result) => result.state === 'error')).toEqual([])
|
||||
const files = await listFiles(home)
|
||||
expect(files.filter((path) => path.endsWith('.tmp'))).toEqual([])
|
||||
const scripts = files.filter((path) => path.includes(join('.orca', 'agent-hooks')))
|
||||
expect(scripts.length).toBeGreaterThanOrEqual(10)
|
||||
if (process.platform !== 'win32') {
|
||||
for (const script of scripts) {
|
||||
expect((await stat(script)).mode & 0o777).toBe(0o755)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('isolates a malformed config while installing the remaining agents', async () => {
|
||||
const home = await createTempHome()
|
||||
const claudeConfig = join(home, '.claude', 'settings.json')
|
||||
await mkdir(join(home, '.claude'), { recursive: true })
|
||||
await writeFile(claudeConfig, '{"hooks": }', 'utf8')
|
||||
|
||||
const results = await installRemoteManagedAgentHooks(createManagedHookLocalFilesystem(), home, {
|
||||
grokHomeDir: join(home, '.grok')
|
||||
})
|
||||
|
||||
expect(results).toHaveLength(14)
|
||||
expect(results.find((result) => result.agent === 'claude')?.state).toBe('error')
|
||||
expect(results.find((result) => result.agent === 'openclaude')?.state).toBe('installed')
|
||||
expect(results.find((result) => result.agent === 'kimi')?.state).toBe('installed')
|
||||
expect(await readFile(claudeConfig, 'utf8')).toBe('{"hooks": }')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { chmod, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
|
||||
type Callback<T = void> = (error: Error | null, value?: T) => void
|
||||
|
||||
function asSftpError(error: NodeJS.ErrnoException): Error & { code: number } {
|
||||
const translated = new Error(error.message, { cause: error }) as Error & { code: number }
|
||||
translated.code =
|
||||
error.code === 'ENOENT' ? 2 : error.code === 'ENOSYS' || error.code === 'ENOTSUP' ? 8 : 4
|
||||
return translated
|
||||
}
|
||||
|
||||
function finish<T>(callback: Callback<T>, error: NodeJS.ErrnoException | null, value?: T): void {
|
||||
if (error) {
|
||||
callback(asSftpError(error))
|
||||
return
|
||||
}
|
||||
callback(null, value)
|
||||
}
|
||||
|
||||
/** The managed installers only need this small callback-style SFTP surface.
|
||||
* On the remote host it turns hundreds of WAN round trips into local fs calls. */
|
||||
export function createManagedHookLocalFilesystem(): SFTPWrapper {
|
||||
const adapter = {
|
||||
readFile(path: string, _encoding: string, callback: Callback<string | Buffer>): void {
|
||||
readFile(path, 'utf8', (error, contents) => finish(callback, error, contents))
|
||||
},
|
||||
writeFile(
|
||||
path: string,
|
||||
contents: string,
|
||||
options: { encoding?: BufferEncoding; mode?: number },
|
||||
callback: Callback
|
||||
): void {
|
||||
writeFile(path, contents, options, (error) => finish(callback, error))
|
||||
},
|
||||
stat(path: string, callback: Callback<{ mode: number }>): void {
|
||||
stat(path, (error, stats) => finish(callback, error, stats))
|
||||
},
|
||||
readdir(path: string, callback: Callback<[]>): void {
|
||||
// Why: installers use readdir only as an existence check; names and
|
||||
// attrs would allocate work that no caller consumes.
|
||||
readdir(path, (error) => finish(callback, error, []))
|
||||
},
|
||||
mkdir(path: string, callback: Callback): void {
|
||||
mkdir(path, (error) => finish(callback, error))
|
||||
},
|
||||
rename(source: string, destination: string, callback: Callback): void {
|
||||
rename(source, destination, (error) => finish(callback, error))
|
||||
},
|
||||
ext_openssh_rename(source: string, destination: string, callback: Callback): void {
|
||||
rename(source, destination, (error) => finish(callback, error))
|
||||
},
|
||||
unlink(path: string, callback: Callback): void {
|
||||
unlink(path, (error) => finish(callback, error))
|
||||
},
|
||||
chmod(path: string, mode: number, callback: Callback): void {
|
||||
chmod(path, mode, (error) => finish(callback, error))
|
||||
}
|
||||
}
|
||||
return adapter as unknown as SFTPWrapper
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
import { randomUUID } from 'node:crypto'
|
||||
import { readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
CLAIMED_OWNER_PATTERN,
|
||||
claimedOwnerFileName,
|
||||
claimRecordFileName,
|
||||
hasCode,
|
||||
inspectManagedHookLock,
|
||||
ownerFileName,
|
||||
parseClaim,
|
||||
parseOwner,
|
||||
removeFileIfPresent,
|
||||
type ManagedHookLockClaim,
|
||||
type ManagedHookLockOwner
|
||||
} from './managed-hook-lock-records'
|
||||
import { readManagedHookProcessIdentity } from './managed-hook-owner-identity'
|
||||
|
||||
export type ManagedHookLockRemoval = 'removed' | 'active' | 'foreign' | 'unverifiable'
|
||||
|
||||
type AcquiredClaim = {
|
||||
kind: 'claimed'
|
||||
claim: ManagedHookLockClaim
|
||||
claimRecordPath: string
|
||||
claimedOwnerPath: string
|
||||
}
|
||||
|
||||
type ClaimResult = AcquiredClaim | { kind: 'active' | 'foreign' | 'unverifiable' | 'contended' }
|
||||
|
||||
const activeClaimTokens = new Set<string>()
|
||||
|
||||
async function findClaimedOwner(
|
||||
lockParent: string,
|
||||
ownerToken: string
|
||||
): Promise<{ claimToken: string; path: string } | null | undefined> {
|
||||
const matches = (await readdir(lockParent)).flatMap((entry) => {
|
||||
const match = CLAIMED_OWNER_PATTERN.exec(entry)
|
||||
return match?.[1] === ownerToken && match[2]
|
||||
? [{ claimToken: match[2], path: join(lockParent, entry) }]
|
||||
: []
|
||||
})
|
||||
return matches.length === 1 ? matches[0] : matches.length === 0 ? null : undefined
|
||||
}
|
||||
|
||||
async function resolveClaimSource(
|
||||
lockParent: string,
|
||||
owner: ManagedHookLockOwner,
|
||||
hostIdentity: string,
|
||||
processIdentity: string
|
||||
): Promise<
|
||||
| { kind: 'ready'; sourcePath: string; priorClaimRecordPath?: string }
|
||||
| { kind: 'active' | 'foreign' | 'unverifiable' }
|
||||
> {
|
||||
const ownerPath = join(lockParent, ownerFileName(owner.token))
|
||||
try {
|
||||
return parseOwner(await readFile(ownerPath, 'utf8'), owner.token)
|
||||
? { kind: 'ready', sourcePath: ownerPath }
|
||||
: { kind: 'unverifiable' }
|
||||
} catch (error) {
|
||||
if (!hasCode(error, 'ENOENT')) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const claimedOwner = await findClaimedOwner(lockParent, owner.token)
|
||||
if (claimedOwner === undefined || claimedOwner === null) {
|
||||
return { kind: 'unverifiable' }
|
||||
}
|
||||
const priorClaimRecordPath = join(
|
||||
lockParent,
|
||||
claimRecordFileName(owner.token, claimedOwner.claimToken)
|
||||
)
|
||||
let priorClaim: ManagedHookLockClaim | null
|
||||
try {
|
||||
priorClaim = parseClaim(
|
||||
await readFile(priorClaimRecordPath, 'utf8'),
|
||||
owner.token,
|
||||
claimedOwner.claimToken
|
||||
)
|
||||
} catch (error) {
|
||||
if (hasCode(error, 'ENOENT')) {
|
||||
return { kind: 'unverifiable' }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (!priorClaim) {
|
||||
return { kind: 'unverifiable' }
|
||||
}
|
||||
if (priorClaim.hostIdentity !== hostIdentity) {
|
||||
return { kind: 'foreign' }
|
||||
}
|
||||
const currentIdentity = await readManagedHookProcessIdentity(priorClaim.pid)
|
||||
const recoverOwnInactiveClaim =
|
||||
priorClaim.pid === process.pid &&
|
||||
currentIdentity === processIdentity &&
|
||||
!activeClaimTokens.has(priorClaim.claimToken)
|
||||
if (currentIdentity === undefined) {
|
||||
return { kind: 'unverifiable' }
|
||||
}
|
||||
if (currentIdentity === priorClaim.processIdentity && !recoverOwnInactiveClaim) {
|
||||
return { kind: 'active' }
|
||||
}
|
||||
return { kind: 'ready', sourcePath: claimedOwner.path, priorClaimRecordPath }
|
||||
}
|
||||
|
||||
async function claimManagedHookLock(
|
||||
lockParent: string,
|
||||
owner: ManagedHookLockOwner,
|
||||
hostIdentity: string,
|
||||
processIdentity: string
|
||||
): Promise<ClaimResult> {
|
||||
const source = await resolveClaimSource(lockParent, owner, hostIdentity, processIdentity)
|
||||
if (source.kind !== 'ready') {
|
||||
return source
|
||||
}
|
||||
const claimToken = randomUUID()
|
||||
const claim = {
|
||||
ownerToken: owner.token,
|
||||
claimToken,
|
||||
pid: process.pid,
|
||||
hostIdentity,
|
||||
processIdentity
|
||||
}
|
||||
const claimRecordPath = join(lockParent, claimRecordFileName(owner.token, claimToken))
|
||||
const claimedOwnerPath = join(lockParent, claimedOwnerFileName(owner.token, claimToken))
|
||||
await writeFile(claimRecordPath, JSON.stringify(claim), {
|
||||
encoding: 'utf8',
|
||||
flag: 'wx',
|
||||
mode: 0o600
|
||||
})
|
||||
try {
|
||||
try {
|
||||
// Why: renaming the witness elects one claimant without ever dropping the hard link.
|
||||
await rename(source.sourcePath, claimedOwnerPath)
|
||||
} catch (error) {
|
||||
if (hasCode(error, 'ENOENT')) {
|
||||
return { kind: 'contended' }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
activeClaimTokens.add(claimToken)
|
||||
if (source.priorClaimRecordPath) {
|
||||
try {
|
||||
await removeFileIfPresent(source.priorClaimRecordPath)
|
||||
} catch (error) {
|
||||
console.warn('[agent-hooks] Failed to clean prior managed-hook claim record', error)
|
||||
}
|
||||
}
|
||||
return { kind: 'claimed', claim, claimRecordPath, claimedOwnerPath }
|
||||
} finally {
|
||||
if (!activeClaimTokens.has(claimToken)) {
|
||||
await removeFileIfPresent(claimRecordPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanAcquiredClaim(claimed: AcquiredClaim): Promise<void> {
|
||||
await Promise.all([
|
||||
removeFileIfPresent(claimed.claimedOwnerPath),
|
||||
removeFileIfPresent(claimed.claimRecordPath)
|
||||
])
|
||||
}
|
||||
|
||||
export async function removeManagedHookLock(
|
||||
lockPath: string,
|
||||
lockParent: string,
|
||||
owner: ManagedHookLockOwner,
|
||||
hostIdentity: string,
|
||||
processIdentity: string
|
||||
): Promise<ManagedHookLockRemoval> {
|
||||
const claimed = await claimManagedHookLock(lockParent, owner, hostIdentity, processIdentity)
|
||||
if (claimed.kind === 'contended') {
|
||||
return 'active'
|
||||
}
|
||||
if (claimed.kind === 'unverifiable') {
|
||||
const state = await inspectManagedHookLock(lockPath)
|
||||
if (state.kind === 'missing' || (state.kind === 'owned' && state.owner.token !== owner.token)) {
|
||||
return 'removed'
|
||||
}
|
||||
}
|
||||
if (claimed.kind !== 'claimed') {
|
||||
return claimed.kind
|
||||
}
|
||||
|
||||
let removed = false
|
||||
try {
|
||||
const state = await inspectManagedHookLock(lockPath)
|
||||
if (state.kind === 'unknown') {
|
||||
return 'unverifiable'
|
||||
}
|
||||
if (state.kind === 'missing' || state.owner.token !== owner.token) {
|
||||
removed = true
|
||||
return 'removed'
|
||||
}
|
||||
try {
|
||||
await unlink(lockPath)
|
||||
} catch (error) {
|
||||
if (!hasCode(error, 'ENOENT')) {
|
||||
return 'unverifiable'
|
||||
}
|
||||
}
|
||||
removed = true
|
||||
return 'removed'
|
||||
} finally {
|
||||
activeClaimTokens.delete(claimed.claim.claimToken)
|
||||
if (removed) {
|
||||
try {
|
||||
await cleanAcquiredClaim(claimed)
|
||||
} catch (error) {
|
||||
// Why: the canonical lock is already gone; residue cleanup must not
|
||||
// turn a successful release into a failed installation.
|
||||
console.warn('[agent-hooks] Failed to clean managed-hook recovery claim', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
import { randomUUID } from 'node:crypto'
|
||||
import { link, lstat, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { readManagedHookProcessIdentity } from './managed-hook-owner-identity'
|
||||
|
||||
const UUID_PATTERN = '[\\da-f]{8}-[\\da-f]{4}-[1-5][\\da-f]{3}-[89ab][\\da-f]{3}-[\\da-f]{12}'
|
||||
const OWNER_FILE_PATTERN = new RegExp(
|
||||
`^managed-hook-install\\.owner-(${UUID_PATTERN})\\.json$`,
|
||||
'i'
|
||||
)
|
||||
const OWNER_DRAFT_PATTERN = new RegExp(
|
||||
`^managed-hook-install\\.owner-draft-(${UUID_PATTERN})\\.json$`,
|
||||
'i'
|
||||
)
|
||||
export const CLAIMED_OWNER_PATTERN = new RegExp(
|
||||
`^managed-hook-install\\.claimed-(${UUID_PATTERN})-(${UUID_PATTERN})\\.json$`,
|
||||
'i'
|
||||
)
|
||||
const CLAIM_RECORD_PATTERN = new RegExp(
|
||||
`^managed-hook-install\\.claim-(${UUID_PATTERN})-(${UUID_PATTERN})\\.json$`,
|
||||
'i'
|
||||
)
|
||||
const activeOwnerTokens = new Set<string>()
|
||||
|
||||
export type ManagedHookLockOwner = {
|
||||
token: string
|
||||
pid: number
|
||||
hostIdentity: string
|
||||
processIdentity: string
|
||||
}
|
||||
|
||||
export type ManagedHookLockClaim = {
|
||||
ownerToken: string
|
||||
claimToken: string
|
||||
pid: number
|
||||
hostIdentity: string
|
||||
processIdentity: string
|
||||
}
|
||||
|
||||
export type ManagedHookLockState =
|
||||
| { kind: 'missing' }
|
||||
| { kind: 'owned'; owner: ManagedHookLockOwner }
|
||||
| { kind: 'unknown' }
|
||||
|
||||
export function hasCode(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === code
|
||||
}
|
||||
|
||||
export function ownerFileName(token: string): string {
|
||||
return `managed-hook-install.owner-${token}.json`
|
||||
}
|
||||
|
||||
function ownerDraftFileName(token: string): string {
|
||||
return `managed-hook-install.owner-draft-${token}.json`
|
||||
}
|
||||
|
||||
export function claimedOwnerFileName(ownerToken: string, claimToken: string): string {
|
||||
return `managed-hook-install.claimed-${ownerToken}-${claimToken}.json`
|
||||
}
|
||||
|
||||
export function claimRecordFileName(ownerToken: string, claimToken: string): string {
|
||||
return `managed-hook-install.claim-${ownerToken}-${claimToken}.json`
|
||||
}
|
||||
|
||||
export function deactivateManagedHookLockOwner(ownerToken: string): void {
|
||||
activeOwnerTokens.delete(ownerToken)
|
||||
}
|
||||
|
||||
export function isManagedHookLockOwnerActive(ownerToken: string): boolean {
|
||||
return activeOwnerTokens.has(ownerToken)
|
||||
}
|
||||
|
||||
export function parseOwner(value: string, token: string): ManagedHookLockOwner | null {
|
||||
try {
|
||||
const owner = JSON.parse(value) as Partial<ManagedHookLockOwner>
|
||||
if (
|
||||
owner.token !== token ||
|
||||
!Number.isSafeInteger(owner.pid) ||
|
||||
(owner.pid ?? 0) <= 0 ||
|
||||
typeof owner.hostIdentity !== 'string' ||
|
||||
owner.hostIdentity.length === 0 ||
|
||||
typeof owner.processIdentity !== 'string' ||
|
||||
owner.processIdentity.length === 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return owner as ManagedHookLockOwner
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function parseClaim(
|
||||
value: string,
|
||||
ownerToken: string,
|
||||
claimToken: string
|
||||
): ManagedHookLockClaim | null {
|
||||
try {
|
||||
const claim = JSON.parse(value) as Partial<ManagedHookLockClaim>
|
||||
if (
|
||||
claim.ownerToken !== ownerToken ||
|
||||
claim.claimToken !== claimToken ||
|
||||
!Number.isSafeInteger(claim.pid) ||
|
||||
(claim.pid ?? 0) <= 0 ||
|
||||
typeof claim.hostIdentity !== 'string' ||
|
||||
claim.hostIdentity.length === 0 ||
|
||||
typeof claim.processIdentity !== 'string' ||
|
||||
claim.processIdentity.length === 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return claim as ManagedHookLockClaim
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeFileIfPresent(path: string): Promise<boolean> {
|
||||
try {
|
||||
await unlink(path)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (hasCode(error, 'ENOENT')) {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function inspectManagedHookLock(lockPath: string): Promise<ManagedHookLockState> {
|
||||
let rawOwner: string
|
||||
try {
|
||||
rawOwner = await readFile(lockPath, 'utf8')
|
||||
} catch (error) {
|
||||
if (hasCode(error, 'ENOENT')) {
|
||||
return { kind: 'missing' }
|
||||
}
|
||||
// Why: an older relay may own the canonical path as a directory.
|
||||
return { kind: 'unknown' }
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(rawOwner) as Partial<ManagedHookLockOwner>
|
||||
const owner = typeof parsed.token === 'string' ? parseOwner(rawOwner, parsed.token) : null
|
||||
return owner ? { kind: 'owned', owner } : { kind: 'unknown' }
|
||||
} catch {
|
||||
return { kind: 'unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function tryCreateManagedHookLock(
|
||||
lockParent: string,
|
||||
lockPath: string,
|
||||
hostIdentity: string,
|
||||
processIdentity: string
|
||||
): Promise<ManagedHookLockOwner | null> {
|
||||
const token = randomUUID()
|
||||
const owner = { token, pid: process.pid, hostIdentity, processIdentity }
|
||||
const ownerPath = join(lockParent, ownerFileName(token))
|
||||
const draftPath = join(lockParent, ownerDraftFileName(token))
|
||||
await writeFile(draftPath, JSON.stringify(owner), { encoding: 'utf8', flag: 'wx', mode: 0o600 })
|
||||
try {
|
||||
// Why: the final owner name appears only after its complete record is durable.
|
||||
await rename(draftPath, ownerPath)
|
||||
try {
|
||||
// Why: hard-link creation publishes metadata atomically and never replaces a lock.
|
||||
await link(ownerPath, lockPath)
|
||||
// Why: publish process-local activity before another same-process contender
|
||||
// can mistake the newly linked owner for an abandoned release.
|
||||
activeOwnerTokens.add(token)
|
||||
return owner
|
||||
} catch (error) {
|
||||
if (hasCode(error, 'EEXIST')) {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
await removeFileIfPresent(draftPath)
|
||||
try {
|
||||
if ((await lstat(ownerPath)).nlink === 1) {
|
||||
await unlink(ownerPath)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!hasCode(error, 'ENOENT')) {
|
||||
console.warn('[agent-hooks] Failed to clean managed-hook owner file', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanOwnerEntry(path: string, token: string, hostIdentity: string): Promise<void> {
|
||||
const stats = await lstat(path)
|
||||
if (stats.nlink !== 1) {
|
||||
return
|
||||
}
|
||||
const owner = parseOwner(await readFile(path, 'utf8'), token)
|
||||
if (!owner || owner.hostIdentity !== hostIdentity) {
|
||||
return
|
||||
}
|
||||
const currentIdentity = await readManagedHookProcessIdentity(owner.pid)
|
||||
if (
|
||||
currentIdentity === null ||
|
||||
(typeof currentIdentity === 'string' && currentIdentity !== owner.processIdentity)
|
||||
) {
|
||||
await unlink(path)
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanLockEntry(
|
||||
entry: string,
|
||||
lockParent: string,
|
||||
hostIdentity: string
|
||||
): Promise<void> {
|
||||
const path = join(lockParent, entry)
|
||||
const ownerToken = OWNER_FILE_PATTERN.exec(entry)?.[1] ?? OWNER_DRAFT_PATTERN.exec(entry)?.[1]
|
||||
if (ownerToken) {
|
||||
await cleanOwnerEntry(path, ownerToken, hostIdentity)
|
||||
return
|
||||
}
|
||||
const claimedMatch = CLAIMED_OWNER_PATTERN.exec(entry)
|
||||
if (claimedMatch?.[1] && claimedMatch[2] && (await lstat(path)).nlink === 1) {
|
||||
await unlink(path)
|
||||
await removeFileIfPresent(
|
||||
join(lockParent, claimRecordFileName(claimedMatch[1], claimedMatch[2]))
|
||||
)
|
||||
return
|
||||
}
|
||||
const claimMatch = CLAIM_RECORD_PATTERN.exec(entry)
|
||||
if (!claimMatch?.[1] || !claimMatch[2]) {
|
||||
return
|
||||
}
|
||||
const claim = parseClaim(await readFile(path, 'utf8'), claimMatch[1], claimMatch[2])
|
||||
if (!claim || claim.hostIdentity !== hostIdentity) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await lstat(join(lockParent, claimedOwnerFileName(claim.ownerToken, claim.claimToken)))
|
||||
return
|
||||
} catch (error) {
|
||||
if (!hasCode(error, 'ENOENT')) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
const currentIdentity = await readManagedHookProcessIdentity(claim.pid)
|
||||
if (
|
||||
currentIdentity === null ||
|
||||
(typeof currentIdentity === 'string' && currentIdentity !== claim.processIdentity)
|
||||
) {
|
||||
await unlink(path)
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupManagedHookLockFiles(
|
||||
lockParent: string,
|
||||
hostIdentity: string
|
||||
): Promise<void> {
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = await readdir(lockParent)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
try {
|
||||
await cleanLockEntry(entry, lockParent, hostIdentity)
|
||||
} catch (error) {
|
||||
if (!hasCode(error, 'ENOENT')) {
|
||||
console.warn('[agent-hooks] Failed to clean managed-hook lock file', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const originalPlatform = process.platform
|
||||
const originalGetuidDescriptor = Object.getOwnPropertyDescriptor(process, 'getuid')
|
||||
|
||||
type LinuxIdentityFixture = {
|
||||
hostToken?: string
|
||||
bootId?: string
|
||||
pidNamespace?: string
|
||||
statErrorCode?: string
|
||||
}
|
||||
|
||||
async function loadLinuxIdentity(fixture: LinuxIdentityFixture) {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
|
||||
Object.defineProperty(process, 'getuid', { configurable: true, value: () => 1000 })
|
||||
const statFields = ['S', ...Array.from({ length: 18 }, () => '0'), '4242']
|
||||
const readFile = vi.fn(async (path: string | URL) => {
|
||||
if (String(path).endsWith('host-id') && fixture.hostToken) {
|
||||
return fixture.hostToken
|
||||
}
|
||||
switch (String(path)) {
|
||||
case '/proc/sys/kernel/random/boot_id':
|
||||
if (fixture.bootId) {
|
||||
return `${fixture.bootId}\n`
|
||||
}
|
||||
break
|
||||
case `/proc/${process.pid}/stat`:
|
||||
case '/proc/123/stat':
|
||||
if (!fixture.statErrorCode) {
|
||||
return `123 (node relay) ${statFields.join(' ')}`
|
||||
}
|
||||
break
|
||||
default:
|
||||
throw new Error(`unexpected path: ${String(path)}`)
|
||||
}
|
||||
throw Object.assign(new Error(`unavailable path: ${String(path)}`), {
|
||||
code: fixture.statErrorCode ?? 'ENOENT'
|
||||
})
|
||||
})
|
||||
const readlink = vi.fn(async (path: string | URL) => {
|
||||
if (fixture.pidNamespace) {
|
||||
return fixture.pidNamespace
|
||||
}
|
||||
throw Object.assign(new Error(`unavailable path: ${String(path)}`), { code: 'EACCES' })
|
||||
})
|
||||
const mkdir = vi.fn(async () => {
|
||||
if (!fixture.hostToken) {
|
||||
throw Object.assign(new Error('host-local storage unavailable'), { code: 'EACCES' })
|
||||
}
|
||||
})
|
||||
const lstat = vi.fn(async (path: string | URL) => {
|
||||
const isToken = String(path).endsWith('host-id')
|
||||
return {
|
||||
isDirectory: () => !isToken,
|
||||
isFile: () => isToken,
|
||||
mode: isToken ? 0o100600 : 0o040700,
|
||||
uid: process.getuid?.() ?? 0
|
||||
}
|
||||
})
|
||||
vi.doMock('node:fs/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
lstat,
|
||||
mkdir,
|
||||
readFile,
|
||||
readlink
|
||||
}))
|
||||
return { identity: await import('./managed-hook-owner-identity'), readFile }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
if (originalGetuidDescriptor) {
|
||||
Object.defineProperty(process, 'getuid', originalGetuidDescriptor)
|
||||
} else {
|
||||
Reflect.deleteProperty(process, 'getuid')
|
||||
}
|
||||
vi.unstubAllEnvs()
|
||||
vi.doUnmock('node:fs/promises')
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('managed hook owner identity', () => {
|
||||
it('uses durable host-local identity without requiring Linux machine identity files', async () => {
|
||||
vi.stubEnv('SSH_CONNECTION', '198.51.100.8 53100 10.0.0.7 2222')
|
||||
const { identity, readFile } = await loadLinuxIdentity({
|
||||
hostToken: '00000000-0000-4000-8000-000000000001',
|
||||
bootId: 'current-boot-id',
|
||||
pidNamespace: 'pid:[4026533001]'
|
||||
})
|
||||
|
||||
await expect(identity.readManagedHookHostIdentity()).resolves.toBe(
|
||||
'host-token:00000000-0000-4000-8000-000000000001'
|
||||
)
|
||||
expect(readFile).not.toHaveBeenCalledWith('/etc/machine-id', 'utf8')
|
||||
})
|
||||
|
||||
it('separates SSH backends that share a key, endpoint, and boot metadata', async () => {
|
||||
vi.stubEnv('SSH_CONNECTION', '198.51.100.8 53100 10.0.0.7 2222')
|
||||
const first = await loadLinuxIdentity({
|
||||
hostToken: '00000000-0000-4000-8000-000000000001',
|
||||
bootId: 'shared-kernel-boot-id',
|
||||
pidNamespace: 'pid:[4026533001]'
|
||||
})
|
||||
const firstHost = await first.identity.readManagedHookHostIdentity()
|
||||
const firstProcess = await first.identity.readManagedHookProcessIdentity(123)
|
||||
const fingerprint = 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
|
||||
const firstScopedHost = first.identity.scopeManagedHookHostIdentity(firstHost, fingerprint)
|
||||
|
||||
vi.doUnmock('node:fs/promises')
|
||||
vi.resetModules()
|
||||
const second = await loadLinuxIdentity({
|
||||
hostToken: '00000000-0000-4000-8000-000000000002',
|
||||
bootId: 'shared-kernel-boot-id',
|
||||
pidNamespace: 'pid:[4026533002]'
|
||||
})
|
||||
|
||||
const secondHost = await second.identity.readManagedHookHostIdentity()
|
||||
expect(secondHost).not.toBe(firstHost)
|
||||
expect(second.identity.scopeManagedHookHostIdentity(secondHost, fingerprint)).not.toBe(
|
||||
firstScopedHost
|
||||
)
|
||||
await expect(second.identity.readManagedHookProcessIdentity(123)).resolves.not.toBe(
|
||||
firstProcess
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps one host scope stable across reboot while changing its process incarnation', async () => {
|
||||
const fixture = {
|
||||
hostToken: '00000000-0000-4000-8000-000000000001',
|
||||
bootId: 'first-boot-id',
|
||||
pidNamespace: 'pid:[4026533001]'
|
||||
}
|
||||
const first = await loadLinuxIdentity(fixture)
|
||||
const firstHost = await first.identity.readManagedHookHostIdentity()
|
||||
const firstProcess = await first.identity.readManagedHookProcessIdentity(123)
|
||||
|
||||
vi.doUnmock('node:fs/promises')
|
||||
vi.resetModules()
|
||||
const second = await loadLinuxIdentity({
|
||||
...fixture,
|
||||
bootId: 'second-boot-id',
|
||||
pidNamespace: 'pid:[4026534001]'
|
||||
})
|
||||
|
||||
await expect(second.identity.readManagedHookHostIdentity()).resolves.toBe(firstHost)
|
||||
await expect(second.identity.readManagedHookProcessIdentity(123)).resolves.not.toBe(
|
||||
firstProcess
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back safely when machine, boot, and namespace probes are unavailable', async () => {
|
||||
vi.stubEnv('SSH_CONNECTION', '')
|
||||
const { identity } = await loadLinuxIdentity({ statErrorCode: 'EACCES' })
|
||||
|
||||
const hostIdentity = await identity.readManagedHookHostIdentity()
|
||||
const processIdentity = await identity.readManagedHookProcessIdentity(process.pid)
|
||||
expect(hostIdentity).toMatch(/^runtime:/)
|
||||
expect(processIdentity).toMatch(/^runtime:/)
|
||||
await expect(identity.readManagedHookHostIdentity()).resolves.toBe(hostIdentity)
|
||||
await expect(identity.readManagedHookProcessIdentity(process.pid)).resolves.toBe(
|
||||
processIdentity
|
||||
)
|
||||
await expect(identity.readManagedHookProcessIdentity(123)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes namespace, boot, and start ticks when Linux exposes them', async () => {
|
||||
vi.stubEnv('SSH_CONNECTION', '')
|
||||
const { identity } = await loadLinuxIdentity({
|
||||
hostToken: '00000000-0000-4000-8000-000000000001',
|
||||
bootId: 'current-boot-id',
|
||||
pidNamespace: 'pid:[4026533001]'
|
||||
})
|
||||
|
||||
await expect(identity.readManagedHookHostIdentity()).resolves.toBe(
|
||||
'host-token:00000000-0000-4000-8000-000000000001'
|
||||
)
|
||||
await expect(identity.readManagedHookProcessIdentity(123)).resolves.toBe(
|
||||
'linux:pid:[4026533001]:current-boot-id:4242'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
import { execFile } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { link, lstat, mkdir, readFile, readlink, unlink, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const runtimeHostIdentity = `runtime:${randomUUID()}`
|
||||
const runtimeProcessIdentity = `runtime:${randomUUID()}`
|
||||
let hostIdentityPromise: Promise<string> | undefined
|
||||
let bootIdentityPromise: Promise<string | undefined> | undefined
|
||||
const HOST_TOKEN_PATTERN = /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i
|
||||
|
||||
function hasCode(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === code
|
||||
}
|
||||
|
||||
function parseLinuxStartTicks(statLine: string): string | null {
|
||||
const commandEnd = statLine.lastIndexOf(') ')
|
||||
if (commandEnd < 0) {
|
||||
return null
|
||||
}
|
||||
// Field 22 is index 19 after removing pid and the parenthesized command.
|
||||
const startTicks = statLine
|
||||
.slice(commandEnd + 2)
|
||||
.trim()
|
||||
.split(/\s+/)[19]
|
||||
return startTicks ?? null
|
||||
}
|
||||
|
||||
async function readLinuxPidNamespace(pid: number): Promise<string | undefined> {
|
||||
try {
|
||||
const namespace = await readlink(`/proc/${pid}/ns/pid`)
|
||||
return namespace.trim() || undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function readPublishedHostToken(path: string, uid: number): Promise<string | undefined> {
|
||||
try {
|
||||
const stats = await lstat(path)
|
||||
if (!stats.isFile() || stats.uid !== uid || (stats.mode & 0o077) !== 0) {
|
||||
return undefined
|
||||
}
|
||||
const token = (await readFile(path, 'utf8')).trim()
|
||||
return HOST_TOKEN_PATTERN.test(token) ? token : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function readDurableHostToken(): Promise<string | undefined> {
|
||||
const uid = process.getuid?.()
|
||||
if ((process.platform !== 'linux' && process.platform !== 'darwin') || uid === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const directory = join('/var', 'tmp', `orca-managed-hooks-${uid}`)
|
||||
const tokenPath = join(directory, 'host-id')
|
||||
try {
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 })
|
||||
const directoryStats = await lstat(directory)
|
||||
if (
|
||||
!directoryStats.isDirectory() ||
|
||||
directoryStats.uid !== uid ||
|
||||
(directoryStats.mode & 0o077) !== 0
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
const existing = await readPublishedHostToken(tokenPath, uid)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const token = randomUUID()
|
||||
const draftPath = join(directory, `host-id-draft-${token}`)
|
||||
await writeFile(draftPath, token, { encoding: 'utf8', flag: 'wx', mode: 0o600 })
|
||||
try {
|
||||
// Why: the hard link publishes a complete token atomically across relay processes.
|
||||
await link(draftPath, tokenPath).catch((error) => {
|
||||
if (!hasCode(error, 'EEXIST')) {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
await unlink(draftPath).catch(() => {})
|
||||
}
|
||||
return await readPublishedHostToken(tokenPath, uid)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function readHostIdentity(): Promise<string> {
|
||||
const durableToken = await readDurableHostToken()
|
||||
if (durableToken) {
|
||||
return `host-token:${durableToken}`
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
// Why: an unverifiable host scope may acquire and release its own clean lock,
|
||||
// but a later process gets a different identity and cannot steal its residue.
|
||||
return runtimeHostIdentity
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 1_000
|
||||
})
|
||||
const platformId = /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(stdout)?.[1]
|
||||
return platformId ? `darwin:${platformId}` : runtimeHostIdentity
|
||||
} catch {
|
||||
return runtimeHostIdentity
|
||||
}
|
||||
}
|
||||
return runtimeHostIdentity
|
||||
}
|
||||
|
||||
async function readBootIdentity(): Promise<string | undefined> {
|
||||
if (process.platform === 'linux') {
|
||||
try {
|
||||
const bootId = (await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim()
|
||||
return bootId || undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('sysctl', ['-n', 'kern.bootsessionuuid'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 1_000
|
||||
})
|
||||
const bootSessionId = stdout.trim()
|
||||
return bootSessionId || undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function readManagedHookHostIdentity(): Promise<string> {
|
||||
hostIdentityPromise ??= readHostIdentity()
|
||||
return await hostIdentityPromise
|
||||
}
|
||||
|
||||
/** Bind a stable SSH identity to one execution runtime before comparing its PIDs. */
|
||||
export function scopeManagedHookHostIdentity(
|
||||
executionIdentity: string,
|
||||
hostKeyFingerprint?: string
|
||||
): string {
|
||||
return hostKeyFingerprint
|
||||
? `ssh-host-key:${hostKeyFingerprint}:execution:${executionIdentity}`
|
||||
: executionIdentity
|
||||
}
|
||||
|
||||
/** null means confirmed missing; undefined means the platform probe was unavailable. */
|
||||
export async function readManagedHookProcessIdentity(
|
||||
pid: number
|
||||
): Promise<string | null | undefined> {
|
||||
if (process.platform === 'linux') {
|
||||
try {
|
||||
const [statLine, pidNamespace, bootIdentity] = await Promise.all([
|
||||
readFile(`/proc/${pid}/stat`, 'utf8'),
|
||||
readLinuxPidNamespace(pid),
|
||||
(bootIdentityPromise ??= readBootIdentity())
|
||||
])
|
||||
const startTicks = parseLinuxStartTicks(statLine)
|
||||
return startTicks
|
||||
? `linux:${pidNamespace ?? 'unknown-namespace'}:${bootIdentity ?? 'unknown-boot'}:${startTicks}`
|
||||
: undefined
|
||||
} catch (error) {
|
||||
if (hasCode(error, 'ENOENT')) {
|
||||
return null
|
||||
}
|
||||
return pid === process.pid ? runtimeProcessIdentity : undefined
|
||||
}
|
||||
}
|
||||
|
||||
bootIdentityPromise ??= readBootIdentity()
|
||||
const bootIdentity = await bootIdentityPromise
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'ps',
|
||||
['-o', 'lstart=', '-o', 'command=', '-p', String(pid)],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 1_000,
|
||||
// Why: lstart is locale-sensitive; a fixed locale keeps the identity
|
||||
// stable across relay processes for the same remote account.
|
||||
env: { ...process.env, LC_ALL: 'C', LANG: 'C' }
|
||||
}
|
||||
)
|
||||
const startedAt = stdout.trim()
|
||||
return startedAt ? `${process.platform}:${bootIdentity ?? 'unknown-boot'}:${startedAt}` : null
|
||||
} catch {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return undefined
|
||||
} catch (error) {
|
||||
if (hasCode(error, 'ESRCH')) {
|
||||
return null
|
||||
}
|
||||
return pid === process.pid ? runtimeProcessIdentity : undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { resolveRelayGrokHome } from './managed-hook-runtime'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe.runIf(process.platform !== 'win32')('resolveRelayGrokHome', () => {
|
||||
it('uses the login-shell GROK_HOME and normalizes trailing separators', async () => {
|
||||
vi.stubEnv('SHELL', '/bin/sh')
|
||||
vi.stubEnv('GROK_HOME', '/srv/grok///')
|
||||
|
||||
await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/srv/grok')
|
||||
})
|
||||
|
||||
it('falls back when the login-shell GROK_HOME is not an absolute POSIX path', async () => {
|
||||
vi.stubEnv('SHELL', '/bin/sh')
|
||||
vi.stubEnv('GROK_HOME', '../relative')
|
||||
|
||||
await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import { execFile } from 'node:child_process'
|
||||
import { basename } from 'node:path'
|
||||
import { homedir, userInfo } from 'node:os'
|
||||
import { promisify } from 'node:util'
|
||||
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
|
||||
import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem'
|
||||
import { withManagedHookInstallLock } from './managed-hook-install-lock'
|
||||
import {
|
||||
readManagedHookHostIdentity,
|
||||
scopeManagedHookHostIdentity
|
||||
} from './managed-hook-owner-identity'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const GROK_HOME_MAX_LENGTH = 4096
|
||||
const GROK_HOME_PROBE_TIMEOUT_MS = 8_000
|
||||
|
||||
export type ManagedHookInstallSummary = {
|
||||
installers: number
|
||||
errors: number
|
||||
}
|
||||
|
||||
function defaultGrokHome(home: string): string {
|
||||
return `${home.replace(/\/+$/, '') || home}/.grok`
|
||||
}
|
||||
|
||||
function hasControlCharacter(value: string): boolean {
|
||||
return Array.from(value).some((character) => {
|
||||
const code = character.charCodeAt(0)
|
||||
return code <= 0x1f || code === 0x7f
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeGrokHome(candidate: string): string | null {
|
||||
if (
|
||||
candidate.length === 0 ||
|
||||
candidate.length > GROK_HOME_MAX_LENGTH ||
|
||||
candidate !== candidate.trim() ||
|
||||
!candidate.startsWith('/') ||
|
||||
candidate.includes('\\') ||
|
||||
hasControlCharacter(candidate)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return candidate.replace(/\/+$/, '') || '/'
|
||||
}
|
||||
|
||||
function resolveLoginShell(): string {
|
||||
const candidate = process.env.SHELL || userInfo().shell || '/bin/sh'
|
||||
if (!candidate.startsWith('/') || candidate.includes('\\') || hasControlCharacter(candidate)) {
|
||||
return '/bin/sh'
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
export async function resolveRelayGrokHome(home: string, signal?: AbortSignal): Promise<string> {
|
||||
const fallback = defaultGrokHome(home)
|
||||
try {
|
||||
const shell = resolveLoginShell()
|
||||
const shellName = basename(shell)
|
||||
const mode = shellName === 'sh' || shellName === 'dash' ? '-c' : '-lc'
|
||||
// Why: agent PTYs start login shells, so read the same profile-derived
|
||||
// GROK_HOME without opening two additional SSH exec channels.
|
||||
const { stdout } = await execFileAsync(
|
||||
shell,
|
||||
[mode, `printenv GROK_HOME | head -c ${GROK_HOME_MAX_LENGTH + 1}`],
|
||||
{ encoding: 'utf8', timeout: GROK_HOME_PROBE_TIMEOUT_MS, signal }
|
||||
)
|
||||
return normalizeGrokHome(stdout.split(/\r?\n/, 1)[0] ?? '') ?? fallback
|
||||
} catch {
|
||||
signal?.throwIfAborted()
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export async function installManagedHooks(options?: {
|
||||
signal?: AbortSignal
|
||||
hostKeyFingerprint?: string
|
||||
}): Promise<ManagedHookInstallSummary> {
|
||||
options?.signal?.throwIfAborted()
|
||||
const home = homedir()
|
||||
const grokHomeDir = await resolveRelayGrokHome(home, options?.signal)
|
||||
options?.signal?.throwIfAborted()
|
||||
const hostIdentity = scopeManagedHookHostIdentity(
|
||||
await readManagedHookHostIdentity(),
|
||||
options?.hostKeyFingerprint
|
||||
)
|
||||
return await withManagedHookInstallLock(
|
||||
home,
|
||||
options?.signal,
|
||||
async () => {
|
||||
const results = await installRemoteManagedAgentHooks(
|
||||
createManagedHookLocalFilesystem(),
|
||||
home,
|
||||
{
|
||||
grokHomeDir,
|
||||
signal: options?.signal
|
||||
}
|
||||
)
|
||||
return {
|
||||
installers: results.length,
|
||||
errors: results.filter((result) => result.state === 'error').length
|
||||
}
|
||||
},
|
||||
hostIdentity
|
||||
)
|
||||
}
|
||||
|
|
@ -2,12 +2,31 @@
|
|||
// matrix catches an unread early exit without duplicating template assertions.
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
import type * as osModule from 'node:os'
|
||||
|
||||
function findGitBash(): string {
|
||||
if (process.env.KIMI_SHELL_PATH) {
|
||||
return process.env.KIMI_SHELL_PATH
|
||||
}
|
||||
const candidates = [
|
||||
process.env.ProgramFiles && join(process.env.ProgramFiles, 'Git', 'bin', 'bash.exe'),
|
||||
process.env['ProgramFiles(x86)'] &&
|
||||
join(process.env['ProgramFiles(x86)'], 'Git', 'bin', 'bash.exe'),
|
||||
process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe')
|
||||
]
|
||||
const bash = candidates.find((candidate): candidate is string =>
|
||||
Boolean(candidate && existsSync(candidate))
|
||||
)
|
||||
if (!bash) {
|
||||
throw new Error('Git Bash is required for the Windows Kimi hook lifecycle test')
|
||||
}
|
||||
return bash
|
||||
}
|
||||
|
||||
const { homedirMock } = vi.hoisted(() => ({
|
||||
homedirMock: vi.fn<() => string>()
|
||||
}))
|
||||
|
|
@ -255,6 +274,7 @@ describe('Windows managed hook stdin structure', () => {
|
|||
const home = mkdtempSync(join(tmpdir(), 'orca-hook-stdin-windows-live-'))
|
||||
homedirMock.mockReturnValue(home)
|
||||
try {
|
||||
const gitBash = findGitBash()
|
||||
for (const entry of LOCAL_INSTALLERS) {
|
||||
expect(entry.install().state, `${entry.agent} install status`).toBe('installed')
|
||||
}
|
||||
|
|
@ -279,7 +299,7 @@ describe('Windows managed hook stdin structure', () => {
|
|||
'v1.0',
|
||||
'powershell.exe'
|
||||
)
|
||||
: process.env.KIMI_SHELL_PATH || 'bash.exe'
|
||||
: gitBash
|
||||
const args = fileName.endsWith('.cmd')
|
||||
? ['/d', '/c', scriptPath]
|
||||
: fileName.endsWith('.ps1')
|
||||
|
|
@ -306,7 +326,7 @@ describe('Windows managed hook stdin structure', () => {
|
|||
},
|
||||
{
|
||||
name: 'Git Bash fast path',
|
||||
executable: process.env.KIMI_SHELL_PATH || 'bash.exe',
|
||||
executable: gitBash,
|
||||
args: ['-lc', wrapWindowsGitBashHookCommand(missingScript)]
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -118,10 +118,16 @@ const MANAGED_HOOKS_DIR_NEEDLE = '/.orca/agent-hooks/'
|
|||
// dir) has a positive config-level timeout sibling (`timeout` or the
|
||||
// provider-specific `timeoutSec`). Returns the count of managed carriers found
|
||||
// so callers can assert the scan was not vacuous.
|
||||
function countManagedCarriersWithTimeout(node: unknown, expectedTimeout: number): number {
|
||||
function countManagedCarriersWithTimeout(
|
||||
node: unknown,
|
||||
expectedTimeout: number,
|
||||
isManagedCarrier = (value: string): boolean =>
|
||||
value.replaceAll('\\', '/').includes(MANAGED_HOOKS_DIR_NEEDLE)
|
||||
): number {
|
||||
if (Array.isArray(node)) {
|
||||
return node.reduce<number>(
|
||||
(sum, child) => sum + countManagedCarriersWithTimeout(child, expectedTimeout),
|
||||
(sum, child) =>
|
||||
sum + countManagedCarriersWithTimeout(child, expectedTimeout, isManagedCarrier),
|
||||
0
|
||||
)
|
||||
}
|
||||
|
|
@ -131,8 +137,7 @@ function countManagedCarriersWithTimeout(node: unknown, expectedTimeout: number)
|
|||
const record = node as Record<string, unknown>
|
||||
let found = 0
|
||||
const carrier = [record.command, record.bash, record.powershell].find(
|
||||
(value): value is string =>
|
||||
typeof value === 'string' && value.includes(MANAGED_HOOKS_DIR_NEEDLE)
|
||||
(value): value is string => typeof value === 'string' && isManagedCarrier(value)
|
||||
)
|
||||
if (carrier !== undefined) {
|
||||
const timeout = typeof record.timeout === 'number' ? record.timeout : record.timeoutSec
|
||||
|
|
@ -143,7 +148,7 @@ function countManagedCarriersWithTimeout(node: unknown, expectedTimeout: number)
|
|||
found += 1
|
||||
}
|
||||
for (const value of Object.values(record)) {
|
||||
found += countManagedCarriersWithTimeout(value, expectedTimeout)
|
||||
found += countManagedCarriersWithTimeout(value, expectedTimeout, isManagedCarrier)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
|
@ -182,7 +187,13 @@ describe('managed agent hook timeouts', () => {
|
|||
const status = new DroidHookService().install()
|
||||
expect(status.state).toBe('installed')
|
||||
const config = JSON.parse(readFileSync(join(homeDir, '.factory', 'settings.json'), 'utf8'))
|
||||
const carriers = countManagedCarriersWithTimeout(config, MANAGED_HOOK_TIMEOUT_SECONDS)
|
||||
const carriers = countManagedCarriersWithTimeout(
|
||||
config,
|
||||
MANAGED_HOOK_TIMEOUT_SECONDS,
|
||||
(command) =>
|
||||
command.replaceAll('\\', '/').includes(MANAGED_HOOKS_DIR_NEEDLE) ||
|
||||
(process.platform === 'win32' && command.includes('-EncodedCommand'))
|
||||
)
|
||||
expect(carriers).toBeGreaterThan(0)
|
||||
} finally {
|
||||
homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir())
|
||||
|
|
|
|||
|
|
@ -726,6 +726,35 @@ describe('remote hook service installers', () => {
|
|||
expect(byAgent.get('copilot')).toBe('installed')
|
||||
})
|
||||
|
||||
it('stops before the next installer when its relay request is cancelled', async () => {
|
||||
const controller = new AbortController()
|
||||
const claudeInstall = vi
|
||||
.spyOn(claudeHookService, 'installRemote')
|
||||
.mockImplementation(async () => {
|
||||
controller.abort()
|
||||
return {
|
||||
agent: 'claude',
|
||||
state: 'installed',
|
||||
configPath: '/home/dev/.claude/settings.json',
|
||||
managedHooksPresent: true,
|
||||
detail: null
|
||||
}
|
||||
})
|
||||
const openClaudeInstall = vi.spyOn(openClaudeHookService, 'installRemote')
|
||||
try {
|
||||
const { sftp } = createFakeSftp()
|
||||
|
||||
await expect(
|
||||
installRemoteManagedAgentHooks(sftp, '/home/dev', { signal: controller.signal })
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(claudeInstall).toHaveBeenCalledTimes(1)
|
||||
expect(openClaudeInstall).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
claudeInstall.mockRestore()
|
||||
openClaudeInstall.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('installs remote Droid hooks into Factory settings.json (issue #7253)', async () => {
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ export type RemoteManagedHookInstallOptions = {
|
|||
codexHomeDir?: string
|
||||
/** Explicit GROK_HOME for remote runtimes that redirect Grok's config. */
|
||||
grokHomeDir?: string
|
||||
/** Stops before starting the next installer when the owning relay request
|
||||
* is cancelled. Individual filesystem mutations remain atomic. */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
type RemoteManagedHookInstaller = readonly [
|
||||
|
|
@ -78,6 +81,9 @@ export async function installRemoteManagedAgentHooks(
|
|||
): Promise<AgentHookInstallStatus[]> {
|
||||
const results: AgentHookInstallStatus[] = []
|
||||
for (const [agent, install] of REMOTE_MANAGED_HOOK_INSTALLERS) {
|
||||
// Why: relay requests can disappear during reconnect; do not start more
|
||||
// user-config mutations after their client has gone away.
|
||||
options?.signal?.throwIfAborted()
|
||||
try {
|
||||
const result = await install(sftp, remoteHome, options)
|
||||
results.push(result)
|
||||
|
|
|
|||
|
|
@ -1225,6 +1225,49 @@ describe('SSH IPC handlers', () => {
|
|||
expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')
|
||||
})
|
||||
|
||||
it('invalidates a pending connect when disconnect wins and allows a fresh connect', async () => {
|
||||
const target: SshTarget = {
|
||||
id: 'ssh-1',
|
||||
label: 'Server',
|
||||
host: 'example.com',
|
||||
port: 22,
|
||||
username: 'deploy'
|
||||
}
|
||||
const staleConn = {}
|
||||
const freshConn = {}
|
||||
let resolveStaleConnect!: (connection: unknown) => void
|
||||
mockSshStore.getTarget.mockReturnValue(target)
|
||||
mockConnectionManager.connect.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveStaleConnect = resolve
|
||||
})
|
||||
)
|
||||
mockConnectionManager.getState.mockReturnValue({
|
||||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
|
||||
const staleConnect = handlers.get('ssh:connect')!(null, {
|
||||
targetId: 'ssh-1'
|
||||
}) as Promise<SshConnectionState>
|
||||
await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1))
|
||||
|
||||
await handlers.get('ssh:disconnect')!(null, { targetId: 'ssh-1' })
|
||||
mockConnectionManager.connect.mockResolvedValueOnce(freshConn)
|
||||
const freshConnect = handlers.get('ssh:connect')!(null, {
|
||||
targetId: 'ssh-1'
|
||||
}) as Promise<SshConnectionState>
|
||||
await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(2))
|
||||
|
||||
resolveStaleConnect(staleConn)
|
||||
|
||||
await expect(staleConnect).rejects.toThrow('SSH connection attempt was cancelled')
|
||||
await expect(freshConnect).resolves.toMatchObject({ targetId: 'ssh-1', status: 'connected' })
|
||||
expect(mockDeployAndLaunchRelay).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ssh:terminateSessions preserves tracking when relay shutdown fails', async () => {
|
||||
const target: SshTarget = {
|
||||
id: 'ssh-1',
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ export function listRegisteredRemovedSshTargetLabels(): Record<string, string> {
|
|||
}
|
||||
|
||||
export async function disconnectRegisteredSshTarget(targetId: string): Promise<void> {
|
||||
invalidateConnectAttempt(targetId)
|
||||
if (!connectionManager) {
|
||||
return
|
||||
}
|
||||
|
|
@ -110,7 +111,8 @@ export async function removeRegisteredSshTarget(targetId: string): Promise<void>
|
|||
if (!sshStore) {
|
||||
return
|
||||
}
|
||||
// Why: removal is destructive — dispose() (not detach()) so the relay shuts down and remote PTY leases are terminated, not preserved for a reattach.
|
||||
invalidateConnectAttempt(targetId)
|
||||
// Why: removal is destructive; dispose so remote PTYs cannot reattach to a deleted target.
|
||||
await disposeActiveSshSession(targetId)
|
||||
try {
|
||||
await connectionManager?.disconnect(targetId)
|
||||
|
|
@ -172,8 +174,33 @@ function relayGracePeriodForTarget(target: SshTarget | null | undefined): number
|
|||
return target?.relayGracePeriodSeconds
|
||||
}
|
||||
|
||||
// Why: concurrent ssh:connect from multiple tabs would each create a session (leaking the first); hold the in-flight promise so the second awaits it.
|
||||
const connectInFlight = new Map<string, Promise<SshConnectionState>>()
|
||||
// Why: tabs must share one connect, while a disconnect must invalidate that
|
||||
// attempt so its late continuation cannot clobber a replacement.
|
||||
type ConnectAttempt = {
|
||||
generation: number
|
||||
promise: Promise<SshConnectionState>
|
||||
}
|
||||
|
||||
const connectInFlight = new Map<string, ConnectAttempt>()
|
||||
const connectGenerationByTarget = new Map<string, number>()
|
||||
|
||||
function currentConnectGeneration(targetId: string): number {
|
||||
return connectGenerationByTarget.get(targetId) ?? 0
|
||||
}
|
||||
|
||||
function invalidateConnectAttempt(targetId: string): void {
|
||||
connectGenerationByTarget.set(targetId, currentConnectGeneration(targetId) + 1)
|
||||
connectInFlight.delete(targetId)
|
||||
credentialRequestedForTarget.delete(targetId)
|
||||
}
|
||||
|
||||
function isCurrentConnectAttempt(targetId: string, generation: number): boolean {
|
||||
return currentConnectGeneration(targetId) === generation
|
||||
}
|
||||
|
||||
function connectCancelledError(): Error {
|
||||
return new Error('SSH connection attempt was cancelled')
|
||||
}
|
||||
|
||||
// Why: publish reset's teardown/force-stop/disconnect lifecycle so new connects and duplicate resets can't race it.
|
||||
const resetRelayInFlight = new Map<string, Promise<void>>()
|
||||
|
|
@ -732,6 +759,7 @@ export function registerSshHandlers(
|
|||
// ── Connection lifecycle ───────────────────────────────────────────
|
||||
|
||||
async function connectTarget(targetId: string): Promise<SshConnectionState> {
|
||||
const observedGeneration = currentConnectGeneration(targetId)
|
||||
const reset = resetRelayInFlight.get(targetId)
|
||||
if (reset) {
|
||||
await reset
|
||||
|
|
@ -740,15 +768,23 @@ export function registerSshHandlers(
|
|||
// Why: serialize concurrent ssh:connect for the same target; interleaved connects otherwise leak the first session.
|
||||
const existing = connectInFlight.get(targetId)
|
||||
if (existing) {
|
||||
return existing
|
||||
return existing.promise
|
||||
}
|
||||
if (currentConnectGeneration(targetId) !== observedGeneration) {
|
||||
throw connectCancelledError()
|
||||
}
|
||||
|
||||
const promise = doConnect(targetId)
|
||||
connectInFlight.set(targetId, promise)
|
||||
const generation = observedGeneration + 1
|
||||
connectGenerationByTarget.set(targetId, generation)
|
||||
const promise = doConnect(targetId, generation)
|
||||
const attempt = { generation, promise }
|
||||
connectInFlight.set(targetId, attempt)
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
connectInFlight.delete(targetId)
|
||||
if (connectInFlight.get(targetId) === attempt) {
|
||||
connectInFlight.delete(targetId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -759,7 +795,7 @@ export function registerSshHandlers(
|
|||
return connectTarget(args.targetId)
|
||||
})
|
||||
|
||||
async function doConnect(targetId: string): Promise<SshConnectionState> {
|
||||
async function doConnect(targetId: string, generation: number): Promise<SshConnectionState> {
|
||||
const target = sshStore!.getTarget(targetId)
|
||||
if (!target) {
|
||||
throw new Error(`SSH target "${targetId}" not found`)
|
||||
|
|
@ -788,6 +824,9 @@ export function registerSshHandlers(
|
|||
if (existingSession) {
|
||||
// Why: await port teardown before disposing, else the new session's restorePortForwards can hit EADDRINUSE on not-yet-released ports.
|
||||
await portForwardManager!.removeAllForwards(targetId)
|
||||
if (!isCurrentConnectAttempt(targetId, generation)) {
|
||||
throw connectCancelledError()
|
||||
}
|
||||
existingSession.detach()
|
||||
activeSessions.delete(targetId)
|
||||
clearRelayLostBackoff(targetId)
|
||||
|
|
@ -805,14 +844,22 @@ export function registerSshHandlers(
|
|||
)
|
||||
configureRelaySessionCallbacks(session)
|
||||
activeSessions.set(targetId, session)
|
||||
const ownsSession = (): boolean =>
|
||||
isCurrentConnectAttempt(targetId, generation) && activeSessions.get(targetId) === session
|
||||
|
||||
try {
|
||||
conn = await connectionManager!.connect(target)
|
||||
if (!ownsSession()) {
|
||||
throw connectCancelledError()
|
||||
}
|
||||
} catch (err) {
|
||||
// Why: connect()'s internal state may not have reached the renderer; broadcast explicitly so the UI leaves 'connecting'.
|
||||
const errObj = err instanceof Error ? err : new Error(String(err))
|
||||
const status: SshConnectionStatus = isAuthError(errObj) ? 'auth-failed' : 'error'
|
||||
// Why: clear this failed connect's credential flag so a later non-prompting connect can't persist lastRequiredPassphrase=true.
|
||||
if (!ownsSession()) {
|
||||
throw connectCancelledError()
|
||||
}
|
||||
// Why: clear this failed connect's flag so a later non-prompting connect isn't deferred.
|
||||
credentialRequestedForTarget.delete(targetId)
|
||||
activeSessions.delete(targetId)
|
||||
clearRelayLostBackoff(targetId)
|
||||
|
|
@ -835,6 +882,9 @@ export function registerSshHandlers(
|
|||
})
|
||||
|
||||
await session.establish(conn, relayGracePeriodForTarget(target))
|
||||
if (!ownsSession()) {
|
||||
throw connectCancelledError()
|
||||
}
|
||||
|
||||
// Why: we manually pushed `deploying-relay`, so send `connected` straight to the renderer — routing through onStateChange would trigger reconnect logic.
|
||||
clearRelayStateOverride(targetId)
|
||||
|
|
@ -846,6 +896,9 @@ export function registerSshHandlers(
|
|||
supportsFolderDownload: conn.usesSystemSshTransport?.() !== true
|
||||
})
|
||||
} catch (err) {
|
||||
if (!ownsSession()) {
|
||||
throw connectCancelledError()
|
||||
}
|
||||
activeSessions.delete(targetId)
|
||||
clearRelayLostBackoff(targetId)
|
||||
await connectionManager!.disconnect(targetId)
|
||||
|
|
@ -865,6 +918,7 @@ export function registerSshHandlers(
|
|||
})
|
||||
|
||||
ipcMain.handle('ssh:terminateSessions', async (_event, args: { targetId: string }) => {
|
||||
invalidateConnectAttempt(args.targetId)
|
||||
const session = activeSessions.get(args.targetId)
|
||||
const provider = getSshPtyProvider(args.targetId)
|
||||
const leasedIds = persistedStore!
|
||||
|
|
@ -931,8 +985,8 @@ export function registerSshHandlers(
|
|||
const inFlightConnect = connectInFlight.get(targetId)
|
||||
if (inFlightConnect) {
|
||||
try {
|
||||
// Why: await the in-flight connect first; tearing down activeSessions mid-deploy would dispose the session doConnect is about to use.
|
||||
await inFlightConnect
|
||||
// Why: resetting activeSessions mid-deploy would dispose the session doConnect will use.
|
||||
await inFlightConnect.promise
|
||||
} catch {
|
||||
// The reset can still recover a stale remote relay after a failed connect.
|
||||
}
|
||||
|
|
@ -1029,7 +1083,7 @@ export function registerSshHandlers(
|
|||
const inFlight = connectInFlight.get(args.targetId)
|
||||
if (inFlight) {
|
||||
try {
|
||||
const state = await inFlight
|
||||
const state = await inFlight.promise
|
||||
return { success: true, state }
|
||||
} catch (err) {
|
||||
return {
|
||||
|
|
@ -1177,6 +1231,7 @@ export async function resetSshHandlerStateForTests(): Promise<void> {
|
|||
}
|
||||
relayStateOverrides.clear()
|
||||
connectInFlight.clear()
|
||||
connectGenerationByTarget.clear()
|
||||
resetRelayInFlight.clear()
|
||||
testingTargets.clear()
|
||||
credentialRequestedForTarget.clear()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SshTarget } from '../../shared/ssh-types'
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
connectResults: [] as Promise<void>[],
|
||||
instances: [] as {
|
||||
connect: ReturnType<typeof vi.fn>
|
||||
disconnect: ReturnType<typeof vi.fn>
|
||||
status: 'connecting' | 'connected' | 'disconnected'
|
||||
}[]
|
||||
}))
|
||||
|
||||
vi.mock('./ssh-connection', () => ({
|
||||
SshConnection: class MockSshConnection {
|
||||
status: 'connecting' | 'connected' | 'disconnected' = 'connecting'
|
||||
connect = vi.fn(async () => {
|
||||
await (mockState.connectResults.shift() ?? Promise.resolve())
|
||||
this.status = 'connected'
|
||||
})
|
||||
disconnect = vi.fn(async () => {
|
||||
this.status = 'disconnected'
|
||||
})
|
||||
|
||||
constructor() {
|
||||
mockState.instances.push(this)
|
||||
}
|
||||
|
||||
getState(): { status: 'connecting' | 'connected' | 'disconnected' } {
|
||||
return { status: this.status }
|
||||
}
|
||||
|
||||
setCallbacks(): void {}
|
||||
}
|
||||
}))
|
||||
|
||||
import { SshConnectionManager } from './ssh-connection-manager'
|
||||
|
||||
const target = {
|
||||
id: 'target-1',
|
||||
label: 'Target 1',
|
||||
host: 'example.test',
|
||||
port: 22,
|
||||
username: 'demo',
|
||||
source: 'manual'
|
||||
} as SshTarget
|
||||
|
||||
describe('SshConnectionManager', () => {
|
||||
beforeEach(() => {
|
||||
mockState.connectResults.length = 0
|
||||
mockState.instances.length = 0
|
||||
})
|
||||
|
||||
it('lets disconnect start a new connect before the cancelled attempt settles', async () => {
|
||||
let rejectFirst!: (error: Error) => void
|
||||
mockState.connectResults.push(
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
rejectFirst = reject
|
||||
}),
|
||||
Promise.resolve()
|
||||
)
|
||||
const manager = new SshConnectionManager({
|
||||
onStateChange: vi.fn()
|
||||
})
|
||||
|
||||
const firstConnect = manager.connect(target)
|
||||
await manager.disconnect(target.id)
|
||||
const secondConnection = await manager.connect(target)
|
||||
rejectFirst(new Error('cancelled'))
|
||||
|
||||
await expect(firstConnect).rejects.toThrow('cancelled')
|
||||
expect(mockState.instances).toHaveLength(2)
|
||||
expect(manager.getConnection(target.id)).toBe(secondConnection)
|
||||
})
|
||||
})
|
||||
|
|
@ -9,10 +9,9 @@ import { SshConnection, type SshConnectionCallbacks } from './ssh-connection'
|
|||
export class SshConnectionManager {
|
||||
private connections = new Map<string, SshConnection>()
|
||||
private callbacks: SshConnectionCallbacks
|
||||
// Why: two concurrent connect() calls for the same target would both pass
|
||||
// the "existing" check, create two SshConnections, and orphan the first.
|
||||
// This set prevents a second call from racing with an in-progress one.
|
||||
private connectingTargets = new Set<string>()
|
||||
// Why: attempt identity lets disconnect unblock a replacement without the
|
||||
// cancelled attempt later clearing the replacement's state.
|
||||
private connectingTargets = new Map<string, symbol>()
|
||||
|
||||
constructor(callbacks: SshConnectionCallbacks) {
|
||||
this.callbacks = callbacks
|
||||
|
|
@ -35,7 +34,8 @@ export class SshConnectionManager {
|
|||
throw new Error(`Connection to ${target.label} is already in progress`)
|
||||
}
|
||||
|
||||
this.connectingTargets.add(target.id)
|
||||
const attempt = Symbol(target.id)
|
||||
this.connectingTargets.set(target.id, attempt)
|
||||
|
||||
try {
|
||||
if (existing) {
|
||||
|
|
@ -48,23 +48,32 @@ export class SshConnectionManager {
|
|||
try {
|
||||
await conn.connect()
|
||||
} catch (err) {
|
||||
this.connections.delete(target.id)
|
||||
if (this.connections.get(target.id) === conn) {
|
||||
this.connections.delete(target.id)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
return conn
|
||||
} finally {
|
||||
this.connectingTargets.delete(target.id)
|
||||
if (this.connectingTargets.get(target.id) === attempt) {
|
||||
this.connectingTargets.delete(target.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(targetId: string): Promise<void> {
|
||||
// Why: disconnect invalidates the old attempt immediately so a reconnect
|
||||
// need not wait for the cancelled socket's late completion.
|
||||
this.connectingTargets.delete(targetId)
|
||||
const conn = this.connections.get(targetId)
|
||||
if (!conn) {
|
||||
return
|
||||
}
|
||||
await conn.disconnect()
|
||||
this.connections.delete(targetId)
|
||||
if (this.connections.get(targetId) === conn) {
|
||||
this.connections.delete(targetId)
|
||||
}
|
||||
}
|
||||
|
||||
async reconnect(targetId: string): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -60,6 +60,9 @@ vi.mock('ssh2', () => {
|
|||
}
|
||||
connect(config?: unknown) {
|
||||
this.lastConnectConfig = config
|
||||
const hostVerifier = (config as { hostVerifier?: (key: Buffer) => boolean } | undefined)
|
||||
?.hostVerifier
|
||||
hostVerifier?.(Buffer.from('mock-ssh-host-key'))
|
||||
setTimeout(() => {
|
||||
const next = connectSequence.shift()
|
||||
if (next instanceof Error) {
|
||||
|
|
@ -320,6 +323,35 @@ describe('SshConnection', () => {
|
|||
expect(clientInstances[0].setNoDelay).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('captures the negotiated SSH server key fingerprint', async () => {
|
||||
const conn = new SshConnection(createTarget(), createCallbacks())
|
||||
await conn.connect()
|
||||
|
||||
expect(conn.getHostKeyFingerprint()).toMatch(/^SHA256:[A-Za-z\d+/]{43}$/)
|
||||
})
|
||||
|
||||
it('ignores a late host fingerprint from an obsolete connect generation', async () => {
|
||||
const conn = new SshConnection(createTarget(), createCallbacks())
|
||||
await conn.connect()
|
||||
const firstVerifier = (
|
||||
clientInstances[0].lastConnectConfig as { hostVerifier?: (key: Buffer) => boolean }
|
||||
).hostVerifier
|
||||
|
||||
const privateConn = conn as unknown as { attemptConnect: () => Promise<void> }
|
||||
await privateConn.attemptConnect()
|
||||
const secondVerifier = (
|
||||
clientInstances[1].lastConnectConfig as { hostVerifier?: (key: Buffer) => boolean }
|
||||
).hostVerifier
|
||||
expect(firstVerifier).toBeTypeOf('function')
|
||||
expect(secondVerifier).toBeTypeOf('function')
|
||||
|
||||
secondVerifier?.(Buffer.from('newer-ssh-host-key'))
|
||||
const currentFingerprint = conn.getHostKeyFingerprint()
|
||||
firstVerifier?.(Buffer.from('obsolete-ssh-host-key'))
|
||||
|
||||
expect(conn.getHostKeyFingerprint()).toBe(currentFingerprint)
|
||||
})
|
||||
|
||||
it('allows concurrent exec commands for ssh2 transport', async () => {
|
||||
const conn = new SshConnection(createTarget(), createCallbacks())
|
||||
await conn.connect()
|
||||
|
|
@ -1248,6 +1280,7 @@ describe('SshConnection', () => {
|
|||
|
||||
expect(conn.getState().status).toBe('connected')
|
||||
expect(conn.usesSystemSshTransport()).toBe(true)
|
||||
expect(conn.getHostKeyFingerprint()).toBeUndefined()
|
||||
expect(onCredentialRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
/* eslint-disable max-lines -- Why: SSH connection lifecycle, credential retries, reconnect policy, and transport fallback are intentionally co-located so state transitions stay auditable in one file. */
|
||||
import * as net from 'node:net'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { Client as SshClient } from 'ssh2'
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import type { ClientChannel, ConnectConfig, SFTPWrapper } from 'ssh2'
|
||||
|
|
@ -82,6 +83,7 @@ export class SshConnection {
|
|||
private disposed = false
|
||||
private cachedPassphrase: string | null = null
|
||||
private cachedPassword: string | null = null
|
||||
private hostKeyFingerprint: string | undefined
|
||||
private connectGeneration = 0
|
||||
|
||||
constructor(target: SshTarget, callbacks: SshConnectionCallbacks) {
|
||||
|
|
@ -121,6 +123,11 @@ export class SshConnection {
|
|||
getSystemSshResolvedConfig(): SshResolvedConfig | null {
|
||||
return cloneResolvedConfig(this.systemSshResolvedConfig)
|
||||
}
|
||||
getHostKeyFingerprint(): string | undefined {
|
||||
// Why: system SSH does not expose its negotiated key; a fingerprint from a
|
||||
// failed ssh2 attempt may identify a different load-balanced execution host.
|
||||
return this.useSystemSshTransport ? undefined : this.hostKeyFingerprint
|
||||
}
|
||||
|
||||
setCallbacks(callbacks: SshConnectionCallbacks): void {
|
||||
this.callbacks = callbacks
|
||||
|
|
@ -1075,6 +1082,16 @@ export class SshConnection {
|
|||
const client = new SshClient()
|
||||
let settled = false
|
||||
|
||||
// Why: the relay uses the negotiated server key to isolate shared-home
|
||||
// install locks without comparing PIDs from an unrelated SSH host.
|
||||
config.hostVerifier = (key: Buffer): boolean => {
|
||||
if (!this.disposed && connectGeneration === this.connectGeneration) {
|
||||
const digest = createHash('sha256').update(key).digest('base64').replace(/=+$/, '')
|
||||
this.hostKeyFingerprint = `SHA256:${digest}`
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const cleanupStartupListeners = (): void => {
|
||||
client.off('ready', onReady)
|
||||
client.off('error', onStartupError)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { SshRelaySession } from './ssh-relay-session'
|
||||
import type { SshConnection } from './ssh-connection'
|
||||
import { AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../../shared/agent-hook-relay'
|
||||
import {
|
||||
AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD,
|
||||
AGENT_HOOK_INSTALL_PLUGINS_METHOD
|
||||
} from '../../shared/agent-hook-relay'
|
||||
import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../../shared/ssh-types'
|
||||
import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures'
|
||||
|
||||
const { muxRequestMock, installRemoteManagedAgentHooksMock } = vi.hoisted(() => ({
|
||||
muxRequestMock: vi.fn(),
|
||||
installRemoteManagedAgentHooksMock: vi.fn()
|
||||
}))
|
||||
const { muxRequestMock } = vi.hoisted(() => ({ muxRequestMock: vi.fn() }))
|
||||
|
||||
vi.mock('./ssh-relay-deploy', () => ({
|
||||
deployAndLaunchRelay: vi.fn()
|
||||
|
|
@ -32,10 +32,6 @@ vi.mock('./ssh-channel-multiplexer', () => {
|
|||
}
|
||||
})
|
||||
|
||||
vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({
|
||||
installRemoteManagedAgentHooks: installRemoteManagedAgentHooksMock
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-pty-provider', () => ({
|
||||
isSshPtyNotFoundError: (err: unknown) =>
|
||||
(err instanceof Error ? err.message : String(err)).includes('not found'),
|
||||
|
|
@ -115,8 +111,6 @@ describe('SshRelaySession', () => {
|
|||
delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
|
||||
muxRequestMock.mockReset()
|
||||
muxRequestMock.mockResolvedValue([])
|
||||
installRemoteManagedAgentHooksMock.mockReset()
|
||||
installRemoteManagedAgentHooksMock.mockResolvedValue([])
|
||||
mockDeploySuccess()
|
||||
vi.mocked(getPtyIdsForConnection).mockReturnValue([])
|
||||
_resetHiddenRendererPtyDeliveryGateForTest()
|
||||
|
|
@ -233,99 +227,86 @@ describe('SshRelaySession', () => {
|
|||
expect(registerSshGitProvider).toHaveBeenCalledWith('target-1', expect.anything())
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/bin/bash', "'/bin/bash' -lc 'printenv GROK_HOME | head -c 4097'"],
|
||||
['/usr/bin/fish', "'/usr/bin/fish' -lc 'printenv GROK_HOME | head -c 4097'"],
|
||||
['/bin/tcsh', "'/bin/tcsh' -lc 'printenv GROK_HOME | head -c 4097'"],
|
||||
['/bin/sh', "'/bin/sh' -c 'printenv GROK_HOME | head -c 4097'"]
|
||||
])('uses a shell-independent GROK_HOME probe with login shell %s', async (shell, command) => {
|
||||
it('installs all managed hooks in one relay RPC before plugins and PTY registration', async () => {
|
||||
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1'
|
||||
muxRequestMock.mockImplementation(async (method: string) =>
|
||||
method === 'session.resolveHome' ? { resolvedPath: '/home/orca' } : { ok: true }
|
||||
)
|
||||
vi.mocked(execCommand).mockResolvedValueOnce(`${shell}\n`).mockResolvedValueOnce('/srv/grok\n')
|
||||
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)
|
||||
|
||||
expect(execCommand).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mockConn,
|
||||
"printenv SHELL || printf '/bin/sh\\n'",
|
||||
{ timeoutMs: 8_000 }
|
||||
)
|
||||
expect(execCommand).toHaveBeenNthCalledWith(2, mockConn, command, {
|
||||
wrapCommand: false,
|
||||
timeoutMs: 8_000
|
||||
})
|
||||
expect(installRemoteManagedAgentHooksMock).toHaveBeenCalledWith(sftp, '/home/orca', {
|
||||
grokHomeDir: '/srv/grok'
|
||||
})
|
||||
})
|
||||
|
||||
it('installs remote managed hooks and relay-owned plugin assets before registering the SSH PTY provider', async () => {
|
||||
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1'
|
||||
muxRequestMock.mockImplementation(async (method: string) => {
|
||||
if (method === 'session.resolveHome') {
|
||||
return { resolvedPath: '/home/orca' }
|
||||
}
|
||||
return { ok: true }
|
||||
})
|
||||
const sftp = { end: vi.fn() }
|
||||
vi.mocked(execCommand)
|
||||
.mockResolvedValueOnce('/bin/bash\n')
|
||||
.mockResolvedValueOnce('/srv/grok profile\n')
|
||||
muxRequestMock.mockResolvedValue({ installers: 14, errors: 0 })
|
||||
const { mockStore, mockPortForward, getMainWindow } = createMockDeps()
|
||||
const sftp = vi.fn()
|
||||
const mockConn = {
|
||||
sftp: vi.fn().mockResolvedValue(sftp)
|
||||
sftp,
|
||||
getHostKeyFingerprint: vi.fn(() => 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')
|
||||
} as unknown as SshConnection
|
||||
const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)
|
||||
|
||||
await session.establish(mockConn)
|
||||
|
||||
const managedHookCalls = muxRequestMock.mock.calls.filter(
|
||||
([method]) => method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD
|
||||
)
|
||||
const managedHookCallIndex = muxRequestMock.mock.calls.findIndex(
|
||||
([method]) => method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD
|
||||
)
|
||||
const installPluginsCallIndex = muxRequestMock.mock.calls.findIndex(
|
||||
([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD
|
||||
)
|
||||
expect(managedHookCalls).toEqual([
|
||||
[
|
||||
AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD,
|
||||
{ hostKeyFingerprint: 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' }
|
||||
]
|
||||
])
|
||||
expect(installPluginsCallIndex).toBeGreaterThanOrEqual(0)
|
||||
const installPluginsParams = muxRequestMock.mock.calls[installPluginsCallIndex]?.[1]
|
||||
expect(installPluginsParams).toMatchObject({
|
||||
piExtensionSource: expect.stringContaining('/hook/pi'),
|
||||
ompExtensionSource: expect.stringContaining('/hook/omp')
|
||||
})
|
||||
expect(mockConn.sftp).toHaveBeenCalledTimes(1)
|
||||
expect(installRemoteManagedAgentHooksMock).toHaveBeenCalledWith(sftp, '/home/orca', {
|
||||
grokHomeDir: '/srv/grok profile'
|
||||
})
|
||||
expect(sftp.end).toHaveBeenCalledTimes(1)
|
||||
expect(installRemoteManagedAgentHooksMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
muxRequestMock.mock.invocationCallOrder[installPluginsCallIndex]
|
||||
)
|
||||
expect(sftp).not.toHaveBeenCalled()
|
||||
expect(managedHookCallIndex).toBeLessThan(installPluginsCallIndex)
|
||||
expect(muxRequestMock.mock.invocationCallOrder[installPluginsCallIndex]).toBeLessThan(
|
||||
vi.mocked(registerSshPtyProvider).mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to login-home Grok config when the remote env probe is invalid', async () => {
|
||||
it('continues provider registration when the relay managed-hook request fails', async () => {
|
||||
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1'
|
||||
muxRequestMock.mockImplementation(async (method: string) =>
|
||||
method === 'session.resolveHome' ? { resolvedPath: '/home/orca' } : { ok: true }
|
||||
)
|
||||
vi.mocked(execCommand)
|
||||
.mockResolvedValueOnce('/bin/bash\n')
|
||||
.mockResolvedValueOnce('relative/grok-home\n')
|
||||
const sftp = { end: vi.fn() }
|
||||
muxRequestMock.mockImplementation(async (method: string) => {
|
||||
if (method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD) {
|
||||
throw new Error('runtime unavailable')
|
||||
}
|
||||
return { ok: true }
|
||||
})
|
||||
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)
|
||||
await session.establish({} as SshConnection)
|
||||
|
||||
expect(installRemoteManagedAgentHooksMock).toHaveBeenCalledWith(sftp, '/home/orca', {
|
||||
grokHomeDir: '/home/orca/.grok'
|
||||
expect(registerSshPtyProvider).toHaveBeenCalledWith('target-1', expect.anything())
|
||||
expect(
|
||||
muxRequestMock.mock.calls.some(([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('suppresses expected managed-hook teardown errors during disconnect', async () => {
|
||||
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1'
|
||||
muxRequestMock.mockImplementation(async (method: string) => {
|
||||
if (method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD) {
|
||||
throw Object.assign(new Error('request disposed'), { code: 'DISPOSED' })
|
||||
}
|
||||
return { ok: true }
|
||||
})
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { mockStore, mockPortForward, getMainWindow } = createMockDeps()
|
||||
const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)
|
||||
|
||||
try {
|
||||
await session.establish({} as SshConnection)
|
||||
|
||||
expect(registerSshPtyProvider).toHaveBeenCalledWith('target-1', expect.anything())
|
||||
expect(warn.mock.calls.flat().join(' ')).not.toContain('relay managed hook install failed')
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not run POSIX managed hook installers on Windows remotes', async () => {
|
||||
|
|
@ -351,7 +332,11 @@ describe('SshRelaySession', () => {
|
|||
|
||||
await session.establish(mockConn)
|
||||
|
||||
expect(installRemoteManagedAgentHooksMock).not.toHaveBeenCalled()
|
||||
expect(
|
||||
muxRequestMock.mock.calls.some(
|
||||
([method]) => method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
muxRequestMock.mock.calls.some(([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD)
|
||||
).toBe(true)
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id'
|
|||
import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider'
|
||||
import { SshGitProvider } from '../providers/ssh-git-provider'
|
||||
import { agentHookServer } from '../agent-hooks/server'
|
||||
import { installRemoteManagedAgentHooks } from '../agent-hooks/remote-managed-hook-installers'
|
||||
import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
|
||||
import {
|
||||
AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD,
|
||||
AGENT_HOOK_INSTALL_PLUGINS_METHOD,
|
||||
AGENT_HOOK_NOTIFICATION_METHOD,
|
||||
AGENT_HOOK_REQUEST_REPLAY_METHOD,
|
||||
|
|
@ -68,7 +68,6 @@ import { runRemoteOrcaCli } from './ssh-remote-orca-cli'
|
|||
import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { isValidTerminalTabId } from '../../shared/terminal-tab-id'
|
||||
import { shellEscape } from './ssh-connection-utils'
|
||||
|
||||
export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed'
|
||||
|
||||
|
|
@ -84,67 +83,6 @@ type RemoteCliBridgeEnv = {
|
|||
|
||||
type ExpectedPtyIdentity = { paneKey?: string; tabId?: string }
|
||||
|
||||
const REMOTE_GROK_HOME_MAX_LENGTH = 4096
|
||||
const REMOTE_GROK_HOME_PROBE_TIMEOUT_MS = 8_000
|
||||
|
||||
function defaultRemoteGrokHome(remoteHome: string): string {
|
||||
const home = remoteHome.replace(/\/+$/, '') || remoteHome
|
||||
return `${home}/.grok`
|
||||
}
|
||||
|
||||
function normalizeRemoteGrokHome(candidate: string): string | null {
|
||||
if (
|
||||
candidate.length === 0 ||
|
||||
candidate.length > REMOTE_GROK_HOME_MAX_LENGTH ||
|
||||
candidate !== candidate.trim() ||
|
||||
!candidate.startsWith('/') ||
|
||||
candidate.includes('\\') ||
|
||||
hasControlCharacter(candidate)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return candidate.replace(/\/+$/, '') || '/'
|
||||
}
|
||||
|
||||
function hasControlCharacter(value: string): boolean {
|
||||
return Array.from(value).some((character) => {
|
||||
const code = character.charCodeAt(0)
|
||||
return code <= 0x1f || code === 0x7f
|
||||
})
|
||||
}
|
||||
|
||||
function loginShellCommand(shell: string, command: string): string {
|
||||
const shellName = shell.split('/').at(-1)
|
||||
const mode = shellName === 'sh' || shellName === 'dash' ? '-c' : '-lc'
|
||||
return `${shellEscape(shell)} ${mode} ${shellEscape(command)}`
|
||||
}
|
||||
|
||||
async function resolveRemoteGrokHome(
|
||||
connection: SshConnection,
|
||||
remoteHome: string
|
||||
): Promise<string> {
|
||||
const fallback = defaultRemoteGrokHome(remoteHome)
|
||||
try {
|
||||
// Why: remote PTYs start login shells, so probe that profile-derived env, not the relay service or local Electron env.
|
||||
const shellOutput = await execCommand(connection, "printenv SHELL || printf '/bin/sh\\n'", {
|
||||
timeoutMs: REMOTE_GROK_HOME_PROBE_TIMEOUT_MS
|
||||
})
|
||||
const shell = shellOutput.trim().split(/\r?\n/, 1)[0]
|
||||
if (!shell?.startsWith('/') || hasControlCharacter(shell)) {
|
||||
return fallback
|
||||
}
|
||||
// Why: runs in the user's login shell (maybe fish/tcsh), so use external commands to avoid shell-specific syntax.
|
||||
const probe = `printenv GROK_HOME | head -c ${REMOTE_GROK_HOME_MAX_LENGTH + 1}`
|
||||
const output = await execCommand(connection, loginShellCommand(shell, probe), {
|
||||
wrapCommand: false,
|
||||
timeoutMs: REMOTE_GROK_HOME_PROBE_TIMEOUT_MS
|
||||
})
|
||||
return normalizeRemoteGrokHome(output.split(/\r?\n/, 1)[0] ?? '') ?? fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function expectedIdentityForLease(lease: {
|
||||
tabId?: string
|
||||
leafId?: string
|
||||
|
|
@ -646,7 +584,7 @@ export class SshRelaySession {
|
|||
})
|
||||
}
|
||||
|
||||
// Why: hook-script agents need remote config files (relay env alone isn't enough); install before the PTY provider so first prompts report status.
|
||||
// Why: hooks must exist before PTY spawn; relay-local work keeps all managed installs to one SSH round trip.
|
||||
private async installManagedHooksOnRemote(mux: SshChannelMultiplexer): Promise<void> {
|
||||
if (!isRemoteAgentHooksEnabled() || !this.areAgentStatusHooksEnabled()) {
|
||||
return
|
||||
|
|
@ -659,41 +597,34 @@ export class SshRelaySession {
|
|||
return
|
||||
}
|
||||
|
||||
let remoteHome: string
|
||||
try {
|
||||
const result = (await mux.request('session.resolveHome', { path: '~' })) as {
|
||||
resolvedPath?: unknown
|
||||
const hostKeyFingerprint = this.requireReadyConnection().getHostKeyFingerprint?.()
|
||||
const params = hostKeyFingerprint ? { hostKeyFingerprint } : {}
|
||||
const result = (await mux.request(AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, params)) as {
|
||||
errors?: unknown
|
||||
}
|
||||
if (typeof result.resolvedPath !== 'string' || result.resolvedPath.length === 0) {
|
||||
if (typeof result.errors === 'number' && result.errors > 0) {
|
||||
console.warn(
|
||||
`[ssh-relay-session] skipped remote managed hook install for ${this.targetId}: could not resolve remote home`
|
||||
`[ssh-relay-session] ${result.errors} remote managed hook installers failed for ${this.targetId}`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
// Why: teardown routinely cancels this best-effort request; only warn for
|
||||
// installer failures that survive the connection lifecycle.
|
||||
const code = (error as { code?: unknown })?.code
|
||||
if (
|
||||
code === -32601 ||
|
||||
code === 'CONNECTION_LOST' ||
|
||||
code === 'DISPOSED' ||
|
||||
mux.isDisposed()
|
||||
) {
|
||||
return
|
||||
}
|
||||
remoteHome = result.resolvedPath
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[ssh-relay-session] skipped remote managed hook install for ${this.targetId}: ${
|
||||
`[ssh-relay-session] relay managed hook install failed for ${this.targetId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let sftp: Awaited<ReturnType<SshConnection['sftp']>> | null = null
|
||||
try {
|
||||
const connection = this.requireReadyConnection()
|
||||
const remoteGrokHome = await resolveRemoteGrokHome(connection, remoteHome)
|
||||
sftp = await connection.sftp()
|
||||
await installRemoteManagedAgentHooks(sftp, remoteHome, { grokHomeDir: remoteGrokHome })
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[ssh-relay-session] remote managed hook install failed for ${this.targetId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
} finally {
|
||||
;(sftp as { end?: () => void } | null)?.end?.()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -128,12 +128,13 @@ describe('isRelayAlreadyInstalled', () => {
|
|||
).rejects.toBe(sessionLimitError)
|
||||
})
|
||||
|
||||
it('checks for both relay process artifacts and .install-complete', async () => {
|
||||
it('checks every relay runtime artifact and .install-complete', async () => {
|
||||
mockExec.mockResolvedValueOnce('OK')
|
||||
await isRelayAlreadyInstalled(conn, '/r')
|
||||
const cmd = mockExec.mock.calls.at(-1)?.[1] ?? ''
|
||||
expect(cmd).toContain('relay.js')
|
||||
expect(cmd).toContain('relay-watcher.js')
|
||||
expect(cmd).toContain('managed-hook-runtime.js')
|
||||
expect(cmd).toContain('.install-complete')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -104,9 +104,8 @@ export function computeRemoteRelayDir(
|
|||
}
|
||||
|
||||
/**
|
||||
* Probe whether a fully-installed relay exists at remoteRelayDir — meaning
|
||||
* relay.js, its relay-watcher.js child, and the .install-complete sentinel are
|
||||
* all present. Missing artifacts force a complete re-deploy.
|
||||
* Probe for relay.js, its watcher, the managed-hook runtime, and the
|
||||
* completion sentinel. Any missing artifact forces a complete re-deploy.
|
||||
*/
|
||||
export async function isRelayAlreadyInstalled(
|
||||
conn: SshConnection,
|
||||
|
|
|
|||
|
|
@ -108,7 +108,9 @@ describe('ssh remote command builders', () => {
|
|||
it('keeps POSIX deploy commands POSIX-native', () => {
|
||||
expect(readRemoteHomeCommand(posix)).toBe('echo $HOME')
|
||||
expect(makeRemoteDirectoryCommand(posix, '/home/me/.orca-remote')).toContain('mkdir -p')
|
||||
expect(probeRelayInstalledCommand(posix, '/home/me/relay')).toContain('test -d')
|
||||
const probe = probeRelayInstalledCommand(posix, '/home/me/relay')
|
||||
expect(probe).toContain('test -d')
|
||||
expect(probe).toContain('managed-hook-runtime.js')
|
||||
})
|
||||
|
||||
it('uses encoded PowerShell for Windows deploy commands', () => {
|
||||
|
|
@ -116,7 +118,9 @@ describe('ssh remote command builders', () => {
|
|||
expect(makeRemoteDirectoryCommand(windows, 'C:/Users/me/.orca-remote')).toContain(
|
||||
'-EncodedCommand'
|
||||
)
|
||||
expect(probeRelayInstalledCommand(windows, 'C:/Users/me/relay')).toContain('-EncodedCommand')
|
||||
const probe = probeRelayInstalledCommand(windows, 'C:/Users/me/relay')
|
||||
expect(probe).toContain('-EncodedCommand')
|
||||
expect(decodePowerShellCommand(probe)).toContain('managed-hook-runtime.js')
|
||||
})
|
||||
|
||||
it('uses a legacy-visible Windows lock directory with an exclusive owner file', () => {
|
||||
|
|
|
|||
|
|
@ -78,12 +78,14 @@ export function probeRelayInstalledCommand(
|
|||
): string {
|
||||
const relayJs = joinRemotePath(host, remoteRelayDir, 'relay.js')
|
||||
const relayWatcherJs = joinRemotePath(host, remoteRelayDir, 'relay-watcher.js')
|
||||
const managedHookRuntimeJs = joinRemotePath(host, remoteRelayDir, 'managed-hook-runtime.js')
|
||||
const installComplete = joinRemotePath(host, remoteRelayDir, '.install-complete')
|
||||
if (!isWindowsRemoteHost(host)) {
|
||||
return (
|
||||
`test -d ${shellEscape(remoteRelayDir)} ` +
|
||||
`&& test -f ${shellEscape(relayJs)} ` +
|
||||
`&& test -f ${shellEscape(relayWatcherJs)} ` +
|
||||
`&& test -f ${shellEscape(managedHookRuntimeJs)} ` +
|
||||
`&& test -f ${shellEscape(installComplete)} ` +
|
||||
`&& echo OK || echo MISSING`
|
||||
)
|
||||
|
|
@ -93,8 +95,9 @@ export function probeRelayInstalledCommand(
|
|||
`$dir = ${powerShellLiteral(remoteRelayDir)}`,
|
||||
`$relay = ${powerShellLiteral(relayJs)}`,
|
||||
`$watcher = ${powerShellLiteral(relayWatcherJs)}`,
|
||||
`$managedHooks = ${powerShellLiteral(managedHookRuntimeJs)}`,
|
||||
`$complete = ${powerShellLiteral(installComplete)}`,
|
||||
"if ((Test-Path -LiteralPath $dir -PathType Container) -and (Test-Path -LiteralPath $relay -PathType Leaf) -and (Test-Path -LiteralPath $watcher -PathType Leaf) -and (Test-Path -LiteralPath $complete -PathType Leaf)) { 'OK' } else { 'MISSING' }"
|
||||
"if ((Test-Path -LiteralPath $dir -PathType Container) -and (Test-Path -LiteralPath $relay -PathType Leaf) -and (Test-Path -LiteralPath $watcher -PathType Leaf) -and (Test-Path -LiteralPath $managedHooks -PathType Leaf) -and (Test-Path -LiteralPath $complete -PathType Leaf)) { 'OK' } else { 'MISSING' }"
|
||||
].join('; ')
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ function createRelayTree(root: string, remoteHome: string): void {
|
|||
writeFileSync(join(remoteDir, 'node_modules', 'node-pty', 'index.js'), '')
|
||||
writeFileSync(join(remoteDir, 'node_modules', '@parcel', 'watcher', 'index.js'), '')
|
||||
writeFileSync(join(remoteDir, '.install-complete'), '')
|
||||
writeFileSync(join(remoteDir, 'managed-hook-runtime.js'), '')
|
||||
writeFakeRelay(remoteDir)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD } from '../shared/agent-hook-relay'
|
||||
import type { MethodHandler, RequestContext } from './dispatcher'
|
||||
import { registerManagedHookInstaller, type ManagedHookRuntime } from './managed-hook-installer'
|
||||
|
||||
function captureHandler(loadRuntime: () => ManagedHookRuntime): MethodHandler {
|
||||
let handler: MethodHandler | undefined
|
||||
registerManagedHookInstaller(
|
||||
{
|
||||
onRequest: (method, nextHandler) => {
|
||||
expect(method).toBe(AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD)
|
||||
handler = nextHandler
|
||||
}
|
||||
},
|
||||
loadRuntime
|
||||
)
|
||||
return handler!
|
||||
}
|
||||
|
||||
function context(signal?: AbortSignal): RequestContext {
|
||||
return { clientId: 1, isStale: () => signal?.aborted ?? false, signal }
|
||||
}
|
||||
|
||||
describe('registerManagedHookInstaller', () => {
|
||||
it('forwards request cancellation to the remote runtime', async () => {
|
||||
const controller = new AbortController()
|
||||
const installManagedHooks = vi.fn().mockResolvedValue({ installers: 14, errors: 0 })
|
||||
const handler = captureHandler(() => ({ installManagedHooks }))
|
||||
|
||||
await expect(handler({}, context(controller.signal))).resolves.toEqual({
|
||||
installers: 14,
|
||||
errors: 0
|
||||
})
|
||||
expect(installManagedHooks).toHaveBeenCalledWith({ signal: controller.signal })
|
||||
})
|
||||
|
||||
it('does not load or start the runtime for an already-cancelled request', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const loadRuntime = vi.fn()
|
||||
const handler = captureHandler(loadRuntime)
|
||||
|
||||
await expect(handler({}, context(controller.signal))).rejects.toMatchObject({
|
||||
name: 'AbortError'
|
||||
})
|
||||
expect(loadRuntime).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards only a valid negotiated server-key fingerprint', async () => {
|
||||
const installManagedHooks = vi.fn().mockResolvedValue({ installers: 14, errors: 0 })
|
||||
const handler = captureHandler(() => ({ installManagedHooks }))
|
||||
const fingerprint = 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
|
||||
|
||||
await handler({ hostKeyFingerprint: fingerprint }, context())
|
||||
await handler({ hostKeyFingerprint: 'ssh://untrusted-host' }, context())
|
||||
|
||||
expect(installManagedHooks).toHaveBeenNthCalledWith(1, {
|
||||
signal: undefined,
|
||||
hostKeyFingerprint: fingerprint
|
||||
})
|
||||
expect(installManagedHooks).toHaveBeenNthCalledWith(2, { signal: undefined })
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { join } from 'node:path'
|
||||
import {
|
||||
AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD,
|
||||
type AgentHookInstallManagedHooksParams
|
||||
} from '../shared/agent-hook-relay'
|
||||
import type { RelayDispatcher, RequestContext } from './dispatcher'
|
||||
|
||||
export type ManagedHookInstallSummary = {
|
||||
installers: number
|
||||
errors: number
|
||||
}
|
||||
|
||||
export type ManagedHookRuntime = {
|
||||
installManagedHooks: (options?: {
|
||||
signal?: AbortSignal
|
||||
hostKeyFingerprint?: string
|
||||
}) => Promise<ManagedHookInstallSummary>
|
||||
}
|
||||
|
||||
const SHA256_HOST_KEY_PATTERN = /^SHA256:[A-Za-z\d+/]{43}$/
|
||||
|
||||
function readHostKeyFingerprint(params: unknown): string | undefined {
|
||||
const fingerprint = (params as Partial<AgentHookInstallManagedHooksParams> | null)
|
||||
?.hostKeyFingerprint
|
||||
return typeof fingerprint === 'string' && SHA256_HOST_KEY_PATTERN.test(fingerprint)
|
||||
? fingerprint
|
||||
: undefined
|
||||
}
|
||||
|
||||
let managedHookRuntime: ManagedHookRuntime | null = null
|
||||
|
||||
function loadManagedHookRuntime(): ManagedHookRuntime {
|
||||
if (!managedHookRuntime) {
|
||||
// Why: keep the sizeable installer implementation out of relay startup and
|
||||
// its narrow TS project while still executing it in-process on the remote.
|
||||
managedHookRuntime = require(join(__dirname, 'managed-hook-runtime.js')) as ManagedHookRuntime
|
||||
}
|
||||
return managedHookRuntime
|
||||
}
|
||||
|
||||
export function registerManagedHookInstaller(
|
||||
dispatcher: Pick<RelayDispatcher, 'onRequest'>,
|
||||
loadRuntime: () => ManagedHookRuntime = loadManagedHookRuntime
|
||||
): void {
|
||||
dispatcher.onRequest(
|
||||
AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD,
|
||||
async (params, context: RequestContext): Promise<ManagedHookInstallSummary> => {
|
||||
context.signal?.throwIfAborted()
|
||||
const hostKeyFingerprint = readHostKeyFingerprint(params)
|
||||
return await loadRuntime().installManagedHooks({
|
||||
signal: context.signal,
|
||||
...(hostKeyFingerprint ? { hostKeyFingerprint } : {})
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -52,6 +52,7 @@ import { pickRemoteCliEnv } from './remote-cli-env'
|
|||
import { relayLogLine } from './relay-diagnostic-log'
|
||||
import { remoteCliRequestTimeoutMs } from './remote-cli-timeout'
|
||||
import { shouldReadRemoteCliStdin } from './remote-cli-stdin'
|
||||
import { registerManagedHookInstaller } from './managed-hook-installer'
|
||||
|
||||
const DEFAULT_GRACE_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000
|
||||
const SOCK_NAME = 'relay.sock'
|
||||
|
|
@ -540,6 +541,9 @@ async function main(): Promise<void> {
|
|||
return { replayed }
|
||||
})
|
||||
|
||||
// Why: relay-local installers collapse hundreds of SFTP request/response RTTs to one RPC.
|
||||
registerManagedHookInstaller(dispatcher)
|
||||
|
||||
// Why: plugin sources ship over the wire so an Orca update doesn't force a relay redeploy; cache them per spawn. See docs/design/agent-status-over-ssh.md §4.
|
||||
// Why: bound per-source size so a buggy/hostile Orca can't OOM the relay by pushing a giant string.
|
||||
dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => {
|
||||
|
|
|
|||
|
|
@ -108,6 +108,15 @@ export const AGENT_HOOK_REQUEST_REPLAY_METHOD = 'agent_hook.requestReplay' as co
|
|||
* overlay dirs on the remote. */
|
||||
export const AGENT_HOOK_INSTALL_PLUGINS_METHOD = 'agent_hook.installPlugins' as const
|
||||
|
||||
/** JSON-RPC request method that asks the remote relay to install every
|
||||
* managed hook using its local filesystem instead of WAN-bound SFTP. */
|
||||
export const AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD = 'agent_hook.installManagedHooks' as const
|
||||
|
||||
export type AgentHookInstallManagedHooksParams = {
|
||||
/** SHA-256 fingerprint of the server key negotiated by Orca's SSH transport. */
|
||||
hostKeyFingerprint?: string
|
||||
}
|
||||
|
||||
/** Feature-flag env var. Read once at process start by Orca and the relay.
|
||||
* Remote agent hooks ship as the default SSH behavior; set "0" to opt out. */
|
||||
export const ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV = 'ORCA_FEATURE_REMOTE_AGENT_HOOKS' as const
|
||||
|
|
|
|||
Loading…
Reference in New Issue