mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 08:03:58 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/sta-3919-mobile-picker-lag
This commit is contained in:
@@ -92,6 +92,7 @@ describe('agent hooks CLI handler', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
vi.restoreAllMocks()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
@@ -139,6 +140,33 @@ describe('agent hooks CLI handler', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards WSL pane routing to the runtime exactly once without using the host installer', async () => {
|
||||
const home = '/home/jin/.local/share/orca/codex-runtime-home/home'
|
||||
vi.stubEnv('CODEX_HOME', home)
|
||||
vi.stubEnv('ORCA_CODEX_HOME', home)
|
||||
vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu-24.04')
|
||||
callMock.mockResolvedValue({ result: { state: 'installed' } })
|
||||
|
||||
await main(['agent', 'hooks', 'prepare-codex'], userDataPath)
|
||||
|
||||
expect(callMock).toHaveBeenCalledExactlyOnceWith(
|
||||
'agentHooks.prepareCodexForWslPane',
|
||||
{ codexHome: home, orcaCodexHome: home, wslDistro: 'Ubuntu-24.04' },
|
||||
{ timeoutMs: 50_000 }
|
||||
)
|
||||
expect(prepareManagedCodexHomeBeforeShellLaunchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails open when WSL runtime preparation is unavailable', async () => {
|
||||
vi.stubEnv('CODEX_HOME', '/home/jin/.local/share/orca/codex-runtime-home/home')
|
||||
vi.stubEnv('ORCA_CODEX_HOME', '/home/jin/.local/share/orca/codex-runtime-home/home')
|
||||
vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu')
|
||||
callMock.mockRejectedValue(new Error('method_not_found'))
|
||||
|
||||
await expect(main(['agent', 'hooks', 'prepare-codex'], userDataPath)).resolves.toBeUndefined()
|
||||
expect(prepareManagedCodexHomeBeforeShellLaunchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors Codex-specific disablement when the runtime is unavailable', async () => {
|
||||
const state = getDefaultPersistedState(userDataPath)
|
||||
state.settings.disabledTuiAgents = ['codex']
|
||||
|
||||
@@ -28,6 +28,9 @@ type AgentHookCommandResult = {
|
||||
statuses: AgentHookInstallStatus[]
|
||||
}
|
||||
|
||||
// Covers managed-home verification, WSL identity, trust grant, and bounded app-server reap.
|
||||
const WSL_CODEX_PREPARE_TIMEOUT_MS = 50_000
|
||||
|
||||
function getDataPath(): string {
|
||||
const userDataPath = getDefaultUserDataPath()
|
||||
const indexPath = join(userDataPath, 'orca-profile-index.json')
|
||||
@@ -207,6 +210,22 @@ async function setAgentHooksEnabled(
|
||||
|
||||
export const AGENT_HOOK_HANDLERS: Record<string, CommandHandler> = {
|
||||
'agent hooks prepare-codex': async ({ client }) => {
|
||||
if (process.env.WSL_DISTRO_NAME?.trim()) {
|
||||
try {
|
||||
await client.call(
|
||||
'agentHooks.prepareCodexForWslPane',
|
||||
{
|
||||
codexHome: process.env.CODEX_HOME ?? '',
|
||||
orcaCodexHome: process.env.ORCA_CODEX_HOME ?? '',
|
||||
wslDistro: process.env.WSL_DISTRO_NAME
|
||||
},
|
||||
{ timeoutMs: WSL_CODEX_PREPARE_TIMEOUT_MS }
|
||||
)
|
||||
} catch {
|
||||
// Best effort: old or unavailable runtimes must not block Codex launch.
|
||||
}
|
||||
return
|
||||
}
|
||||
const settings = await readHookSettings(client)
|
||||
await prepareManagedCodexHomeBeforeShellLaunch({
|
||||
userDataPath: getDefaultUserDataPath(),
|
||||
|
||||
@@ -72,7 +72,7 @@ fi
|
||||
# Why || : twice — zsh alone aborts inside the substitution, but every shell's
|
||||
# assignment adopts its exit status, so an absent codex trips set -e in bash too.
|
||||
__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)"
|
||||
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
|
||||
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
|
||||
# Why the function reserved word: it suppresses alias expansion of the name,
|
||||
# which otherwise rewrites this header at parse time and aborts the whole file.
|
||||
function codex {
|
||||
|
||||
@@ -2,7 +2,7 @@ function __orca_shell_ready_marker --on-event fish_prompt
|
||||
builtin printf "\033]777;orca-shell-ready\007"
|
||||
functions -e __orca_shell_ready_marker
|
||||
end
|
||||
if test -n "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
|
||||
if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
|
||||
function codex
|
||||
command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true
|
||||
command codex $argv
|
||||
|
||||
@@ -110,7 +110,7 @@ __orca_deferred_init() {
|
||||
# Why || : twice — zsh alone aborts inside the substitution, but every shell's
|
||||
# assignment adopts its exit status, so an absent codex trips set -e in bash too.
|
||||
__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)"
|
||||
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
|
||||
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
|
||||
# Why the function reserved word: it suppresses alias expansion of the name,
|
||||
# which otherwise rewrites this header at parse time and aborts the whole file.
|
||||
function codex {
|
||||
|
||||
@@ -75,7 +75,7 @@ fi
|
||||
# Why || : twice — zsh alone aborts inside the substitution, but every shell's
|
||||
# assignment adopts its exit status, so an absent codex trips set -e in bash too.
|
||||
__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)"
|
||||
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
|
||||
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
|
||||
# Why the function reserved word: it suppresses alias expansion of the name,
|
||||
# which otherwise rewrites this header at parse time and aborts the whole file.
|
||||
function codex {
|
||||
|
||||
@@ -2,7 +2,7 @@ function __orca_shell_ready_marker --on-event fish_prompt
|
||||
builtin printf "\033]777;orca-shell-ready\007"
|
||||
functions -e __orca_shell_ready_marker
|
||||
end
|
||||
if test -n "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
|
||||
if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
|
||||
function codex
|
||||
command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true
|
||||
command codex $argv
|
||||
|
||||
@@ -110,7 +110,7 @@ __orca_deferred_init() {
|
||||
# Why || : twice — zsh alone aborts inside the substitution, but every shell's
|
||||
# assignment adopts its exit status, so an absent codex trips set -e in bash too.
|
||||
__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)"
|
||||
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
|
||||
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
|
||||
# Why the function reserved word: it suppresses alias expansion of the name,
|
||||
# which otherwise rewrites this header at parse time and aborts the whole file.
|
||||
function codex {
|
||||
|
||||
@@ -284,7 +284,7 @@ describe('remote hook service installers', () => {
|
||||
hooks: Record<string, { hooks: { command: string }[] }[]>
|
||||
}
|
||||
expect(hooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toContain(
|
||||
'/home/dev/.orca/agent-hooks/codex-hook.sh'
|
||||
'/home/dev/.local/share/orca/codex-runtime-home/home/.orca/agent-hooks/codex-hook.sh'
|
||||
)
|
||||
expect(fs.files.get(`${runtimeHome}/config.toml`)).toContain(
|
||||
`${runtimeHome}/hooks.json:stop:0:0`
|
||||
|
||||
@@ -12,19 +12,23 @@ import {
|
||||
type ManagedHookDetectionSettings
|
||||
} from './managed-hook-detection-commands'
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env'
|
||||
import { WSL_HOOK_FS_METHODS, type WslFsResult } from '../../shared/wsl-hook-relay-contract'
|
||||
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
|
||||
/** Run the shared remote hook installers against a WSL guest over the relay's fs bridge. */
|
||||
export async function installWslGuestHooks(options: {
|
||||
mux: SshChannelMultiplexer
|
||||
guestHome: string
|
||||
codexHomePath: string | null
|
||||
distro: string
|
||||
installHooks: typeof installRemoteManagedAgentHooks
|
||||
settings: ManagedHookDetectionSettings
|
||||
warn: (message: string) => void
|
||||
/** Canonical runtime-host installer for redirected Codex homes. */
|
||||
installCodex: (runtimeHomePath: string, distro: string) => Promise<AgentHookInstallStatus | null>
|
||||
}): Promise<void> {
|
||||
const { mux, guestHome, distro, installHooks, settings, warn } = options
|
||||
const { mux, guestHome, codexHomePath, distro, installHooks, settings, warn, installCodex } =
|
||||
options
|
||||
let agents
|
||||
try {
|
||||
const detected = (await mux.request('preflight.detectAgents', {
|
||||
@@ -42,12 +46,25 @@ export async function installWslGuestHooks(options: {
|
||||
if (agents.length === 0) {
|
||||
return
|
||||
}
|
||||
if (agents.includes('codex') && codexHomePath) {
|
||||
try {
|
||||
const status = await installCodex(codexHomePath, distro)
|
||||
if (status?.state === 'error') {
|
||||
warn(
|
||||
`[agent-hooks] WSL Codex hook install for '${distro}' failed: ${status.detail ?? 'unknown error'}`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
warn(
|
||||
`[agent-hooks] WSL Codex hook install for '${distro}' failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
// Codex is redirected into the runtime home and must use the canonical
|
||||
// runtime-host writer above; the relay adapter owns all other agents.
|
||||
const remoteAgents = agents.filter((agent) => agent !== 'codex')
|
||||
const results = await installHooks(createWslHookSftpAdapter(mux), guestHome, {
|
||||
agents,
|
||||
// WSL Codex launches use Orca's managed runtime CODEX_HOME, not ~/.codex.
|
||||
// Keep relay hooks in that active home so status callbacks are received.
|
||||
codexHomeDir: wslCodexRuntimeHomeForGuestHome(guestHome),
|
||||
deferTrustUntilConfigToml: true
|
||||
agents: remoteAgents
|
||||
})
|
||||
const failed = results.filter((r) => r.state === 'error').length
|
||||
if (failed > 0) {
|
||||
|
||||
@@ -9,6 +9,8 @@ import { agentHookServer } from './server'
|
||||
import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands'
|
||||
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
|
||||
import { getOpenCodePluginSource } from '../opencode/hook-service'
|
||||
import { codexHookService } from '../codex/hook-service'
|
||||
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
import type { PluginSources } from '../../relay/plugin-overlay'
|
||||
import {
|
||||
isWslDistroRunning,
|
||||
@@ -61,6 +63,7 @@ export type WslHookRelayManagerDeps = {
|
||||
waitForSentinel: typeof waitForWslRelaySentinel
|
||||
ingest: (envelope: Record<string, unknown>, connectionId: string) => void
|
||||
installHooks: typeof installRemoteManagedAgentHooks
|
||||
installCodex: (runtimeHomePath: string, distro: string) => Promise<AgentHookInstallStatus | null>
|
||||
managedHookSettings: () => ManagedHookDetectionSettings
|
||||
/** Plugin source strings shipped to the guest relay so an Orca update needn't redeploy the relay bundle. */
|
||||
pluginSources: () => PluginSources
|
||||
@@ -102,6 +105,11 @@ export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = {
|
||||
connectionId
|
||||
),
|
||||
installHooks: installRemoteManagedAgentHooks,
|
||||
installCodex: (runtimeHomePath, distro) =>
|
||||
codexHookService.installForRuntimeHomeSerialized(runtimeHomePath, {
|
||||
runtime: 'wsl',
|
||||
wslDistro: distro
|
||||
}),
|
||||
managedHookSettings: () => null,
|
||||
// Why: only OpenCode is in scope for WSL now; the payload shape stays identical to SSH so Pi/OMP are additive later.
|
||||
pluginSources: () => ({ opencodePluginSource: getOpenCodePluginSource() }),
|
||||
|
||||
@@ -6,13 +6,14 @@ import type { ManagedHookDetectionSettings } from './managed-hook-detection-comm
|
||||
import type { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
|
||||
import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install'
|
||||
import { installWslGuestHooks } from './wsl-hook-fs-adapter'
|
||||
import { REINSTALL_MIN_INTERVAL_MS } from './wsl-hook-relay-deps'
|
||||
import { REINSTALL_MIN_INTERVAL_MS, type WslHookRelayManagerDeps } from './wsl-hook-relay-deps'
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import type { PluginSources } from '../../relay/plugin-overlay'
|
||||
|
||||
/** Structural slice of WslHookRelayManagerDeps — only what an install pass uses. */
|
||||
type GuestInstallDeps = {
|
||||
installHooks: typeof installRemoteManagedAgentHooks
|
||||
installCodex: WslHookRelayManagerDeps['installCodex']
|
||||
managedHookSettings: () => ManagedHookDetectionSettings
|
||||
pluginSources: () => PluginSources
|
||||
warn: (message: string) => void
|
||||
@@ -23,6 +24,7 @@ type GuestInstallState = {
|
||||
distro: string
|
||||
mux?: SshChannelMultiplexer
|
||||
guestHome?: string
|
||||
codexHomePath?: string
|
||||
opencodeOverlayDir?: string
|
||||
lastInstallAt?: number
|
||||
}
|
||||
@@ -37,8 +39,10 @@ export async function runWslRelayGuestInstall(
|
||||
await installWslGuestHooks({
|
||||
mux,
|
||||
guestHome,
|
||||
codexHomePath: state.codexHomePath ?? null,
|
||||
distro: state.distro,
|
||||
installHooks: deps.installHooks,
|
||||
installCodex: deps.installCodex,
|
||||
settings: deps.managedHookSettings(),
|
||||
warn: deps.warn
|
||||
})
|
||||
|
||||
@@ -13,7 +13,8 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AgentHookServer } from './server'
|
||||
import { WslHookRelayManager } from './wsl-hook-relay-manager'
|
||||
import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env'
|
||||
import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem'
|
||||
import { codexHookService } from '../codex/hook-service'
|
||||
|
||||
const BUNDLE_DIR = join(process.cwd(), 'out', 'relay', 'wsl')
|
||||
const BUNDLE_JS = join(BUNDLE_DIR, 'wsl-agent-hook-relay.js')
|
||||
@@ -75,6 +76,7 @@ describe.skipIf(process.platform === 'win32')(
|
||||
const server = orcaServer
|
||||
|
||||
const warns: string[] = []
|
||||
const codexHome = join(fakeHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home')
|
||||
manager = new WslHookRelayManager({
|
||||
platform: () => 'win32',
|
||||
remoteHooksEnabled: () => true,
|
||||
@@ -102,6 +104,11 @@ describe.skipIf(process.platform === 'win32')(
|
||||
envelope as Parameters<AgentHookServer['ingestRemote']>[0],
|
||||
connectionId
|
||||
),
|
||||
installCodex: (runtimeHomePath) =>
|
||||
codexHookService.installRemote(createManagedHookLocalFilesystem(), fakeHome, {
|
||||
codexHomeDir: runtimeHomePath,
|
||||
deferTrustUntilConfigToml: true
|
||||
}),
|
||||
managedHookSettings: () => ({
|
||||
agentCmdOverrides: {
|
||||
claude: process.execPath,
|
||||
@@ -112,15 +119,17 @@ describe.skipIf(process.platform === 'win32')(
|
||||
transientRetryDelayMs: 1
|
||||
})
|
||||
|
||||
manager.ensureForDistro('LiveDistro')
|
||||
manager.ensureForDistro('LiveDistro', codexHome)
|
||||
|
||||
// Waiting on Codex's artifact (not Claude's, which is written first) keeps the
|
||||
// assertions behind the still-running 14-agent installer loop.
|
||||
const codexHome = wslCodexRuntimeHomeForGuestHome(fakeHome)
|
||||
await vi.waitFor(() => expect(existsSync(join(codexHome, 'hooks.json'))).toBe(true), {
|
||||
timeout: 15_000
|
||||
})
|
||||
expect(existsSync(join(fakeHome, '.claude', 'settings.json'))).toBe(true)
|
||||
await vi.waitFor(
|
||||
() => expect(existsSync(join(fakeHome, '.claude', 'settings.json'))).toBe(true),
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
const claudeScript = readFileSync(
|
||||
join(fakeHome, '.orca', 'agent-hooks', 'claude-hook.sh'),
|
||||
'utf8'
|
||||
|
||||
@@ -147,6 +147,8 @@ describe('WslHookRelayManager', () => {
|
||||
// hosts — installHooks is mocked here, so the fs bridge only ever serves
|
||||
// the wslfs.home request and never touches the real filesystem.
|
||||
const home = '/home/wsl-test-user'
|
||||
const codexHome =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\wsl-test-user\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
const opencodeOverlayDir = `${home}/.orca-relay/opencode-overlays/deadbeefcafe`
|
||||
let harnesses: GuestHarness[]
|
||||
|
||||
@@ -229,6 +231,13 @@ describe('WslHookRelayManager', () => {
|
||||
waitForSentinel: vi.fn(async () => guestTransport()),
|
||||
ingest: vi.fn(),
|
||||
installHooks: vi.fn(async () => []),
|
||||
installCodex: vi.fn(async () => ({
|
||||
agent: 'codex' as const,
|
||||
state: 'installed' as const,
|
||||
configPath: `${home}/.local/share/orca/codex-runtime-home/home/hooks.json`,
|
||||
managedHooksPresent: true,
|
||||
detail: null
|
||||
})),
|
||||
managedHookSettings: () => null,
|
||||
pluginSources: () => ({ opencodePluginSource: '// opencode plugin source' }),
|
||||
warn: vi.fn(),
|
||||
@@ -240,14 +249,14 @@ describe('WslHookRelayManager', () => {
|
||||
|
||||
it('starts one relay per distro, installs hooks, exposes the guest endpoint path, and forwards envelopes', async () => {
|
||||
const { manager, deps } = createManager({})
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
manager.ensureForDistro('Ubuntu', codexHome)
|
||||
manager.ensureForDistro('Ubuntu', codexHome)
|
||||
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
|
||||
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
|
||||
expect(deps.installCodex).toHaveBeenCalledWith(codexHome, 'Ubuntu')
|
||||
// Codex is owned by the canonical runtime-host writer, not the relay adapter.
|
||||
expect(deps.installHooks).toHaveBeenCalledWith(expect.anything(), home, {
|
||||
agents: ['codex'],
|
||||
codexHomeDir: `${home}/.local/share/orca/codex-runtime-home/home`,
|
||||
deferTrustUntilConfigToml: true
|
||||
agents: []
|
||||
})
|
||||
|
||||
expect(manager.getGuestEndpointFilePath('Ubuntu')).toBe(
|
||||
@@ -272,9 +281,23 @@ describe('WslHookRelayManager', () => {
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
it('reinstalls into a newly resolved runtime home without restarting the relay', async () => {
|
||||
const { manager, deps } = createManager({})
|
||||
manager.ensureForDistro('Ubuntu', codexHome)
|
||||
await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(1))
|
||||
const nextHome = codexHome.replace('codex-runtime-home', 'codex-accounts\\account-2')
|
||||
|
||||
manager.ensureForDistro('ubuntu', nextHome)
|
||||
await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(2))
|
||||
|
||||
expect(deps.installCodex).toHaveBeenLastCalledWith(nextHome, 'Ubuntu')
|
||||
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
it('ships the OpenCode plugin to the guest and exposes the overlay dir', async () => {
|
||||
const { manager } = createManager({})
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
manager.ensureForDistro('Ubuntu', codexHome)
|
||||
await vi.waitFor(() => expect(manager.getOpenCodeOverlayDir('Ubuntu')).toBe(opencodeOverlayDir))
|
||||
manager.disposeAll()
|
||||
})
|
||||
@@ -397,7 +420,7 @@ describe('WslHookRelayManager', () => {
|
||||
it('stops live relays and refuses to revive them once agent status hooks are switched off', async () => {
|
||||
const settings = { agentStatusHooksEnabled: true }
|
||||
const { manager, deps } = createManager({ managedHookSettings: () => settings })
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
manager.ensureForDistro('Ubuntu', codexHome)
|
||||
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
|
||||
|
||||
settings.agentStatusHooksEnabled = false
|
||||
@@ -414,6 +437,8 @@ describe('WslHookRelayManager', () => {
|
||||
settings.agentStatusHooksEnabled = true
|
||||
manager.resumeStoppedRelays()
|
||||
await vi.waitFor(() => expect(deps.spawnRelay).toHaveBeenCalledTimes(2))
|
||||
await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(2))
|
||||
expect(deps.installCodex).toHaveBeenLastCalledWith(codexHome, 'Ubuntu')
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ import {
|
||||
WSL_HOOK_FS_METHODS,
|
||||
wslHookRelayEndpointFilePath
|
||||
} from '../../shared/wsl-hook-relay-contract'
|
||||
import {
|
||||
recordManagedWslCodexHome,
|
||||
wslRuntimeHomePathsEqual
|
||||
} from '../codex/managed-wsl-codex-home-registry'
|
||||
|
||||
type DistroState = {
|
||||
/** Original casing for wsl.exe argv and breadcrumbs; map keys are lowercased. */
|
||||
@@ -37,6 +41,7 @@ type DistroState = {
|
||||
child?: ChildProcessWithoutNullStreams
|
||||
mux?: SshChannelMultiplexer
|
||||
guestHome?: string
|
||||
codexHomePath?: string
|
||||
guestEndpointFilePath?: string
|
||||
opencodeOverlayDir?: string
|
||||
failures: number
|
||||
@@ -52,7 +57,7 @@ export class WslHookRelayManager {
|
||||
private recovery: WslRelayRecovery
|
||||
private states = new Map<string, DistroState>()
|
||||
/** Distros a hooks-off teardown stopped, so re-enabling can put them back. */
|
||||
private stoppedByHooksOff = new Set<string>()
|
||||
private stoppedByHooksOff = new Map<string, string | undefined>()
|
||||
private defaultDistro: string | null = null
|
||||
private disposed = false
|
||||
private warnedBundleMissing = false
|
||||
@@ -64,7 +69,7 @@ export class WslHookRelayManager {
|
||||
warn: (message) => this.deps.warn(message),
|
||||
isDisposed: () => this.disposed,
|
||||
isCurrent: (state) => this.states.get(wslHookRelayStateKey(state.distro)) === state,
|
||||
restart: (distro) => this.ensureForDistro(distro),
|
||||
restart: (distro) => this.ensureForDistro(distro, this.stateFor(distro)?.codexHomePath),
|
||||
dropState: (state) => {
|
||||
// Why: identity-guarded — a fresh ensure() may own this key by now;
|
||||
// deleting by key alone would orphan its live relay child.
|
||||
@@ -81,11 +86,11 @@ export class WslHookRelayManager {
|
||||
}
|
||||
|
||||
/** Fire-and-forget from every WSL PTY spawn-env build; errors breadcrumb. */
|
||||
ensureForDistro(distro: string | null): void {
|
||||
ensureForDistro(distro: string | null, codexHomePath?: string | null): void {
|
||||
if (this.disposed || !isWslHookRelayAllowed(this.deps)) {
|
||||
return
|
||||
}
|
||||
void this.ensureInternal(distro).catch((err) => {
|
||||
void this.ensureInternal(distro, codexHomePath ?? undefined).catch((err) => {
|
||||
const detail = err instanceof Error ? err.message : String(err)
|
||||
this.deps.warn(`[agent-hooks] WSL hook relay ensure failed: ${detail}`)
|
||||
})
|
||||
@@ -118,7 +123,7 @@ export class WslHookRelayManager {
|
||||
state.mux?.dispose()
|
||||
state.child?.kill()
|
||||
if (!permanent) {
|
||||
this.stoppedByHooksOff.add(state.distro)
|
||||
this.stoppedByHooksOff.set(state.distro, state.codexHomePath)
|
||||
}
|
||||
}
|
||||
this.states.clear()
|
||||
@@ -129,26 +134,39 @@ export class WslHookRelayManager {
|
||||
resumeStoppedRelays(): void {
|
||||
const distros = [...this.stoppedByHooksOff]
|
||||
this.stoppedByHooksOff.clear()
|
||||
for (const distro of distros) {
|
||||
for (const [distro, codexHomePath] of distros) {
|
||||
void this.deps
|
||||
.isDistroRunning(distro)
|
||||
.then((running) => {
|
||||
if (running) {
|
||||
this.ensureForDistro(distro)
|
||||
this.ensureForDistro(distro, codexHomePath)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureInternal(requestedDistro: string | null): Promise<void> {
|
||||
private async ensureInternal(
|
||||
requestedDistro: string | null,
|
||||
requestedCodexHomePath?: string
|
||||
): Promise<void> {
|
||||
const distro = requestedDistro ?? (await this.resolveDefaultDistro())
|
||||
if (!distro || this.disposed) {
|
||||
return
|
||||
}
|
||||
const key = wslHookRelayStateKey(distro)
|
||||
const existing = this.states.get(key)
|
||||
if (requestedCodexHomePath) {
|
||||
recordManagedWslCodexHome(distro, requestedCodexHomePath)
|
||||
}
|
||||
if (existing) {
|
||||
if (
|
||||
requestedCodexHomePath &&
|
||||
!wslRuntimeHomePathsEqual(existing.codexHomePath, requestedCodexHomePath)
|
||||
) {
|
||||
existing.codexHomePath = requestedCodexHomePath
|
||||
existing.lastInstallAt = 0
|
||||
}
|
||||
if (existing.phase === 'running') {
|
||||
void maybeRerunWslRelayGuestInstall(this.deps, existing)
|
||||
return
|
||||
@@ -184,6 +202,7 @@ export class WslHookRelayManager {
|
||||
// Why: instance-keyed and on the distro's persistent fs, so it outlives a relay
|
||||
// crash — dropping it would blank status on panes spawned mid-relaunch.
|
||||
opencodeOverlayDir: existing?.opencodeOverlayDir,
|
||||
codexHomePath: requestedCodexHomePath ?? existing?.codexHomePath,
|
||||
cooldownUntil: 0
|
||||
}
|
||||
this.states.set(key, state)
|
||||
|
||||
@@ -108,4 +108,36 @@ describe('runExclusivelyForCodexTrustConfig', () => {
|
||||
await queued
|
||||
expect(secondStarted).toBe(true)
|
||||
})
|
||||
|
||||
it('coalesces WSL UNC aliases without folding the case-sensitive Linux path', async () => {
|
||||
const aliasGate = deferred()
|
||||
let aliasStarted = false
|
||||
const blockedAlias = runExclusivelyForCodexTrustConfig(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\Alice\.codex\config.toml`,
|
||||
() => aliasGate.promise
|
||||
)
|
||||
const queuedAlias = runExclusivelyForCodexTrustConfig(
|
||||
String.raw`\\wsl$\ubuntu\home\Alice\.codex\config.toml`,
|
||||
() => {
|
||||
aliasStarted = true
|
||||
return Promise.resolve()
|
||||
}
|
||||
)
|
||||
await Promise.resolve()
|
||||
expect(aliasStarted).toBe(false)
|
||||
|
||||
let distinctStarted = false
|
||||
await runExclusivelyForCodexTrustConfig(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\alice\.codex\config.toml`,
|
||||
async () => {
|
||||
distinctStarted = true
|
||||
}
|
||||
)
|
||||
expect(distinctStarted).toBe(true)
|
||||
|
||||
aliasGate.resolve()
|
||||
await blockedAlias
|
||||
await queuedAlias
|
||||
expect(aliasStarted).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type CodexTrustEntry
|
||||
} from './config-toml-trust'
|
||||
import {
|
||||
CodexHookService,
|
||||
_internals,
|
||||
createCodexWslRuntimeHookInstallPlan,
|
||||
type CodexWslRuntimeHookInstallPlan
|
||||
@@ -82,7 +83,67 @@ function expectedManagedCommand(scriptPath: string): string {
|
||||
}
|
||||
|
||||
describe('Codex WSL runtime hook install', () => {
|
||||
it('plans WSL hook files with Linux command and trust paths', async () => {
|
||||
it('coalesces aliases of one runtime home without blocking independent homes', async () => {
|
||||
const service = new CodexHookService()
|
||||
const releases: (() => void)[] = []
|
||||
const started: string[] = []
|
||||
vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (runtimeHomePath) => {
|
||||
started.push(runtimeHomePath!)
|
||||
await new Promise<void>((resolve) => releases.push(resolve))
|
||||
return null
|
||||
})
|
||||
const firstHome = '\\\\wsl$\\Ubuntu\\home\\Alice\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
const alias = firstHome.replace('\\\\wsl$', '\\\\wsl.localhost')
|
||||
const independent = firstHome.replace('\\Alice\\', '\\Bob\\')
|
||||
|
||||
const first = service.installForRuntimeHomeSerialized(firstHome)
|
||||
const second = service.installForRuntimeHomeSerialized(alias)
|
||||
const third = service.installForRuntimeHomeSerialized(independent)
|
||||
await vi.waitFor(() => expect(started).toEqual([firstHome, independent]))
|
||||
expect(second).toBe(first)
|
||||
|
||||
releases.splice(0).forEach((release) => release())
|
||||
await Promise.all([first, second, third])
|
||||
expect(started).toEqual([firstHome, independent])
|
||||
})
|
||||
|
||||
it('does not coalesce one drive-backed home across WSL distros', async () => {
|
||||
const service = new CodexHookService()
|
||||
const started: string[] = []
|
||||
vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (_runtimeHome, target) => {
|
||||
started.push(target?.wslDistro ?? '')
|
||||
return null
|
||||
})
|
||||
const home = 'D:\\wsl-home\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
|
||||
await Promise.all([
|
||||
service.installForRuntimeHomeSerialized(home, { runtime: 'wsl', wslDistro: 'Ubuntu' }),
|
||||
service.installForRuntimeHomeSerialized(home, { runtime: 'wsl', wslDistro: 'Debian' })
|
||||
])
|
||||
|
||||
expect(started).toEqual(['Ubuntu', 'Debian'])
|
||||
})
|
||||
|
||||
it('keeps case-distinct Linux runtime homes on separate queues', async () => {
|
||||
const service = new CodexHookService()
|
||||
const started: string[] = []
|
||||
vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (runtimeHomePath) => {
|
||||
started.push(runtimeHomePath!)
|
||||
return null
|
||||
})
|
||||
const upper =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\Alice\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
const lower = upper.replace('\\Alice\\', '\\alice\\')
|
||||
|
||||
await Promise.all([
|
||||
service.installForRuntimeHomeSerialized(upper),
|
||||
service.installForRuntimeHomeSerialized(lower)
|
||||
])
|
||||
|
||||
expect(started).toEqual([upper, lower])
|
||||
})
|
||||
|
||||
it('plans WSL hook files with Linux command and trust paths', () => {
|
||||
const runtimeHome =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
|
||||
|
||||
@@ -1075,6 +1075,7 @@ export class CodexHookService {
|
||||
}
|
||||
|
||||
private readonly wslReconciliationGeneration = new Map<string, number>()
|
||||
private readonly wslInstallsInFlight = new Map<string, Promise<AgentHookInstallStatus | null>>()
|
||||
|
||||
private supersedeWslReconciliation(runtimeHomePath: string | null | undefined): number {
|
||||
if (!runtimeHomePath) {
|
||||
@@ -1171,6 +1172,30 @@ export class CodexHookService {
|
||||
}
|
||||
}
|
||||
|
||||
installForRuntimeHomeSerialized(
|
||||
runtimeHomePath: string | null | undefined,
|
||||
target?: CodexWslRuntimeHookTarget
|
||||
): Promise<AgentHookInstallStatus | null> {
|
||||
if (!runtimeHomePath) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
const targetKey = target?.runtime === 'wsl' ? target.wslDistro?.trim().toLowerCase() : ''
|
||||
const key = `${getWslReconciliationKey(runtimeHomePath)}\0${targetKey ?? ''}`
|
||||
const active = this.wslInstallsInFlight.get(key)
|
||||
if (active) {
|
||||
return active
|
||||
}
|
||||
const install = this.installForRuntimeHome(runtimeHomePath, target)
|
||||
this.wslInstallsInFlight.set(key, install)
|
||||
const clear = (): void => {
|
||||
if (this.wslInstallsInFlight.get(key) === install) {
|
||||
this.wslInstallsInFlight.delete(key)
|
||||
}
|
||||
}
|
||||
void install.then(clear, clear)
|
||||
return install
|
||||
}
|
||||
|
||||
refreshRuntimeUserHooksForRuntimeHome(
|
||||
runtimeHomePath: string | null | undefined,
|
||||
target?: CodexWslRuntimeHookTarget
|
||||
@@ -1486,7 +1511,13 @@ export class CodexHookService {
|
||||
options?.codexHomeDir?.replace(/\/$/, '') ?? `${remoteHome.replace(/\/$/, '')}/.codex`
|
||||
const remoteConfigPath = `${codexHomeBase}/hooks.json`
|
||||
const remoteTomlPath = `${codexHomeBase}/config.toml`
|
||||
const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh`
|
||||
// Redirected WSL homes must use the same script location and command shape
|
||||
// as the runtime installer; two representations of one hooks.json race
|
||||
// into stale trust keys. Plain SSH keeps its guest-home script contract.
|
||||
const redirectedCodexHome = options?.codexHomeDir?.replace(/\/$/, '')
|
||||
const remoteScriptPath = redirectedCodexHome
|
||||
? `${redirectedCodexHome}/.orca/agent-hooks/codex-hook.sh`
|
||||
: `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh`
|
||||
try {
|
||||
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
|
||||
if (!config) {
|
||||
@@ -1499,7 +1530,9 @@ export class CodexHookService {
|
||||
}
|
||||
}
|
||||
|
||||
const command = wrapPosixHookCommand(remoteScriptPath)
|
||||
const command = redirectedCodexHome
|
||||
? wrapReadablePosixHookCommand(remoteScriptPath)
|
||||
: wrapPosixHookCommand(remoteScriptPath)
|
||||
const nextHooks = { ...config.hooks }
|
||||
const managedEvents = new Set<string>(CODEX_EVENTS)
|
||||
const isManagedCommand = createManagedCommandMatcher('codex-hook.sh')
|
||||
@@ -1523,11 +1556,13 @@ export class CodexHookService {
|
||||
const definition: HookDefinition = {
|
||||
hooks: [buildManagedCommandHook(command)]
|
||||
}
|
||||
nextHooks[eventName] = [...cleaned, definition]
|
||||
nextHooks[eventName] = redirectedCodexHome
|
||||
? [definition, ...cleaned]
|
||||
: [...cleaned, definition]
|
||||
trustEntries.push({
|
||||
sourcePath: remoteConfigPath,
|
||||
eventLabel: CODEX_EVENT_LABEL[eventName],
|
||||
groupIndex: cleaned.length,
|
||||
groupIndex: redirectedCodexHome ? 0 : cleaned.length,
|
||||
handlerIndex: 0,
|
||||
command,
|
||||
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
|
||||
|
||||
@@ -2,10 +2,25 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { wslRealpathMock } = vi.hoisted(() => ({
|
||||
wslRealpathMock: vi.fn(async (path: string) => path)
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', () => ({ realpath: wslRealpathMock }))
|
||||
|
||||
import {
|
||||
prepareManagedCodexHomeBeforeShellLaunch,
|
||||
resolveManagedCodexShellPreflightHome
|
||||
} from './managed-home-shell-preflight'
|
||||
import {
|
||||
prepareManagedWslCodexHomeBeforeShellLaunch,
|
||||
resolveManagedWslCodexShellPreflightTarget
|
||||
} from './managed-wsl-home-shell-preflight'
|
||||
import {
|
||||
_internals as managedWslHomeRegistryInternals,
|
||||
recordManagedWslCodexHome
|
||||
} from './managed-wsl-codex-home-registry'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
@@ -16,6 +31,8 @@ function makeRoot(): string {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wslRealpathMock.mockClear()
|
||||
managedWslHomeRegistryInternals.clearRecordedManagedWslCodexHomes()
|
||||
for (const root of roots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
@@ -160,3 +177,123 @@ describe('managed Codex shell preflight', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('managed WSL Codex shell preflight', () => {
|
||||
const home = '/home/jin/.local/share/orca/codex-runtime-home/home'
|
||||
const runtimeHome =
|
||||
'\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
const env = {
|
||||
CODEX_HOME: home,
|
||||
ORCA_CODEX_HOME: home,
|
||||
WSL_DISTRO_NAME: 'Ubuntu-24.04'
|
||||
}
|
||||
|
||||
it('targets the pane-selected managed home through its UNC twin', () => {
|
||||
recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome)
|
||||
expect(resolveManagedWslCodexShellPreflightTarget(env)).toEqual({
|
||||
runtimeHomePath: runtimeHome,
|
||||
wslDistro: 'Ubuntu-24.04'
|
||||
})
|
||||
})
|
||||
|
||||
it('reconstructs a validated managed home when a restarted runtime has no record', () => {
|
||||
expect(resolveManagedWslCodexShellPreflightTarget(env)).toEqual({
|
||||
runtimeHomePath: runtimeHome,
|
||||
wslDistro: 'Ubuntu-24.04'
|
||||
})
|
||||
})
|
||||
|
||||
it('installs once through the WSL runtime-home lane while hooks are enabled', async () => {
|
||||
recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome)
|
||||
const status = {
|
||||
agent: 'codex' as const,
|
||||
state: 'installed' as const,
|
||||
configPath: 'config.toml',
|
||||
managedHooksPresent: true,
|
||||
detail: null
|
||||
}
|
||||
const install = vi.fn(() => status)
|
||||
|
||||
await expect(
|
||||
prepareManagedWslCodexHomeBeforeShellLaunch({ env, hooksEnabled: true, install })
|
||||
).resolves.toBe(status)
|
||||
expect(install).toHaveBeenCalledExactlyOnceWith(runtimeHome, {
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'Ubuntu-24.04'
|
||||
})
|
||||
|
||||
await expect(
|
||||
prepareManagedWslCodexHomeBeforeShellLaunch({ env, hooksEnabled: false, install })
|
||||
).resolves.toBeNull()
|
||||
expect(install).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a user home',
|
||||
{ ...env, CODEX_HOME: '/home/jin/.codex', ORCA_CODEX_HOME: '/home/jin/.codex' }
|
||||
],
|
||||
['unequal routing markers', { ...env, ORCA_CODEX_HOME: `${home}-other` }],
|
||||
[
|
||||
'a parent traversal',
|
||||
{
|
||||
...env,
|
||||
CODEX_HOME: `/home/jin/../jin${home.slice('/home/jin'.length)}`,
|
||||
ORCA_CODEX_HOME: `/home/jin/../jin${home.slice('/home/jin'.length)}`
|
||||
}
|
||||
],
|
||||
[
|
||||
'a dot segment',
|
||||
{
|
||||
...env,
|
||||
CODEX_HOME: `/home/./jin${home.slice('/home/jin'.length)}`,
|
||||
ORCA_CODEX_HOME: `/home/./jin${home.slice('/home/jin'.length)}`
|
||||
}
|
||||
],
|
||||
[
|
||||
'a host path',
|
||||
{ ...env, CODEX_HOME: 'C:\\Users\\jin\\.codex', ORCA_CODEX_HOME: 'C:\\Users\\jin\\.codex' }
|
||||
],
|
||||
['a missing distro', { ...env, WSL_DISTRO_NAME: '' }],
|
||||
['a distro path escape', { ...env, WSL_DISTRO_NAME: 'Ubuntu\\..\\host' }],
|
||||
[
|
||||
'a repeated separator',
|
||||
{
|
||||
...env,
|
||||
CODEX_HOME: home.replace('/jin/', '/jin//'),
|
||||
ORCA_CODEX_HOME: home.replace('/jin/', '/jin//')
|
||||
}
|
||||
]
|
||||
])('rejects %s', (_label, candidate) => {
|
||||
recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome)
|
||||
expect(resolveManagedWslCodexShellPreflightTarget(candidate)).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves a recorded runtime spelling for a managed account home', () => {
|
||||
const directHome = '/home/jin/.local/share/orca/codex-accounts/account-1/home'
|
||||
const directRuntimeHome =
|
||||
'\\\\wsl$\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-accounts\\account-1\\home'
|
||||
recordManagedWslCodexHome('Ubuntu-24.04', directRuntimeHome)
|
||||
|
||||
expect(
|
||||
resolveManagedWslCodexShellPreflightTarget({
|
||||
CODEX_HOME: directHome,
|
||||
ORCA_CODEX_HOME: directHome,
|
||||
WSL_DISTRO_NAME: 'ubuntu-24.04'
|
||||
})
|
||||
).toEqual({ runtimeHomePath: directRuntimeHome, wslDistro: 'ubuntu-24.04' })
|
||||
})
|
||||
|
||||
it('never authorizes a WSL system home even when it was offered by a PTY lane', () => {
|
||||
const systemHome = '/home/jin/.codex'
|
||||
recordManagedWslCodexHome('Ubuntu-24.04', '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.codex')
|
||||
|
||||
expect(
|
||||
resolveManagedWslCodexShellPreflightTarget({
|
||||
CODEX_HOME: systemHome,
|
||||
ORCA_CODEX_HOME: systemHome,
|
||||
WSL_DISTRO_NAME: 'Ubuntu-24.04'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { win32 as pathWin32 } from 'node:path'
|
||||
import {
|
||||
foldWslUncPathCaseInsensitiveParts,
|
||||
parseWslUncPath,
|
||||
toWindowsWslPath
|
||||
} from '../../shared/wsl-paths'
|
||||
|
||||
type ManagedWslCodexHome = {
|
||||
runtimeHomePath: string
|
||||
linuxHomePath: string
|
||||
}
|
||||
|
||||
const managedHomesByDistro = new Map<string, Map<string, ManagedWslCodexHome>>()
|
||||
|
||||
function distroKey(distro: string): string {
|
||||
return distro.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function isAbsolutePosixPathWithoutDotSegments(value: string): boolean {
|
||||
const segments = value.split('/').slice(1)
|
||||
return (
|
||||
value.startsWith('/') &&
|
||||
!value.startsWith('//') &&
|
||||
!value.includes('\\') &&
|
||||
segments.every((segment) => segment && segment !== '.' && segment !== '..')
|
||||
)
|
||||
}
|
||||
|
||||
function isOrcaManagedWslCodexHome(linuxHomePath: string): boolean {
|
||||
const segments = linuxHomePath.split('/').filter(Boolean)
|
||||
const orcaIndex = segments.findIndex(
|
||||
(segment, index) =>
|
||||
segment === 'orca' && segments[index - 1] === 'share' && segments[index - 2] === '.local'
|
||||
)
|
||||
if (orcaIndex === -1) {
|
||||
return false
|
||||
}
|
||||
const tail = segments.slice(orcaIndex + 1)
|
||||
return (
|
||||
(tail.length === 2 && tail[0] === 'codex-runtime-home' && tail[1] === 'home') ||
|
||||
(tail.length === 3 && tail[0] === 'codex-accounts' && Boolean(tail[1]) && tail[2] === 'home')
|
||||
)
|
||||
}
|
||||
|
||||
function linuxHomeForRuntimePath(runtimeHomePath: string, distro: string): string | null {
|
||||
const wsl = parseWslUncPath(runtimeHomePath)
|
||||
if (wsl) {
|
||||
return wsl.distro.toLowerCase() === distroKey(distro) ? wsl.linuxPath : null
|
||||
}
|
||||
const drive = runtimeHomePath.match(/^([A-Za-z]):[/\\](.*)$/)
|
||||
return drive ? `/mnt/${drive[1].toLowerCase()}/${drive[2].replace(/\\/g, '/')}` : null
|
||||
}
|
||||
|
||||
export function recordManagedWslCodexHome(distro: string, runtimeHomePath: string): void {
|
||||
const normalizedDistro = distro.trim()
|
||||
const linuxHomePath = linuxHomeForRuntimePath(runtimeHomePath, normalizedDistro)
|
||||
if (
|
||||
!normalizedDistro ||
|
||||
/[\\/\r\n]/.test(normalizedDistro) ||
|
||||
!linuxHomePath ||
|
||||
!isAbsolutePosixPathWithoutDotSegments(linuxHomePath) ||
|
||||
!isOrcaManagedWslCodexHome(linuxHomePath)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const homes = managedHomesByDistro.get(distroKey(normalizedDistro)) ?? new Map()
|
||||
homes.set(linuxHomePath, { runtimeHomePath, linuxHomePath })
|
||||
managedHomesByDistro.set(distroKey(normalizedDistro), homes)
|
||||
}
|
||||
|
||||
export function resolveRecordedManagedWslCodexHome(
|
||||
distro: string,
|
||||
linuxHomePath: string
|
||||
): string | null {
|
||||
if (
|
||||
!distro.trim() ||
|
||||
/[\\/\r\n]/.test(distro) ||
|
||||
!isAbsolutePosixPathWithoutDotSegments(linuxHomePath)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return managedHomesByDistro.get(distroKey(distro))?.get(linuxHomePath)?.runtimeHomePath ?? null
|
||||
}
|
||||
|
||||
export function resolveManagedWslCodexHome(distro: string, linuxHomePath: string): string | null {
|
||||
if (
|
||||
!distro.trim() ||
|
||||
/[\\/\r\n]/.test(distro) ||
|
||||
!isAbsolutePosixPathWithoutDotSegments(linuxHomePath) ||
|
||||
!isOrcaManagedWslCodexHome(linuxHomePath)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
resolveRecordedManagedWslCodexHome(distro, linuxHomePath) ??
|
||||
toWindowsWslPath(linuxHomePath, distro)
|
||||
)
|
||||
}
|
||||
|
||||
export function wslRuntimeHomePathsEqual(left: string | undefined, right: string): boolean {
|
||||
if (!left) {
|
||||
return false
|
||||
}
|
||||
const leftWsl = foldWslUncPathCaseInsensitiveParts(left)
|
||||
const rightWsl = foldWslUncPathCaseInsensitiveParts(right)
|
||||
if (leftWsl || rightWsl) {
|
||||
return leftWsl !== null && leftWsl === rightWsl
|
||||
}
|
||||
return pathWin32.normalize(left).toLowerCase() === pathWin32.normalize(right).toLowerCase()
|
||||
}
|
||||
|
||||
export const _internals = {
|
||||
clearRecordedManagedWslCodexHomes: () => managedHomesByDistro.clear(),
|
||||
isOrcaManagedWslCodexHome
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
import { withTimeout } from '../../shared/promise-timeout-fallback'
|
||||
import type { CodexWslRuntimeHookTarget } from './codex-wsl-hook-install-plan'
|
||||
import { codexHookService } from './hook-service'
|
||||
import {
|
||||
isAbsolutePosixPathWithoutDotSegments,
|
||||
resolveManagedWslCodexHome,
|
||||
wslRuntimeHomePathsEqual
|
||||
} from './managed-wsl-codex-home-registry'
|
||||
|
||||
type WslShellPreflightEnvironment = {
|
||||
CODEX_HOME?: string
|
||||
ORCA_CODEX_HOME?: string
|
||||
WSL_DISTRO_NAME?: string
|
||||
}
|
||||
|
||||
const WSL_MANAGED_HOME_REALPATH_TIMEOUT_MS = 5_000
|
||||
|
||||
export type ManagedWslCodexShellPreflightTarget = {
|
||||
runtimeHomePath: string
|
||||
wslDistro: string
|
||||
}
|
||||
|
||||
export function resolveManagedWslCodexShellPreflightTarget(
|
||||
env: WslShellPreflightEnvironment
|
||||
): ManagedWslCodexShellPreflightTarget | null {
|
||||
const codexHome = env.CODEX_HOME?.trim()
|
||||
const orcaCodexHome = env.ORCA_CODEX_HOME?.trim()
|
||||
const wslDistro = env.WSL_DISTRO_NAME?.trim()
|
||||
if (
|
||||
!codexHome ||
|
||||
codexHome !== orcaCodexHome ||
|
||||
!wslDistro ||
|
||||
/[\\/\r\n]/.test(wslDistro) ||
|
||||
!isAbsolutePosixPathWithoutDotSegments(codexHome)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const runtimeHomePath = resolveManagedWslCodexHome(wslDistro, codexHome)
|
||||
return runtimeHomePath ? { runtimeHomePath, wslDistro } : null
|
||||
}
|
||||
|
||||
export async function prepareManagedWslCodexHomeBeforeShellLaunch(args: {
|
||||
env: WslShellPreflightEnvironment
|
||||
hooksEnabled: boolean
|
||||
install?: (
|
||||
runtimeHomePath: string,
|
||||
target: CodexWslRuntimeHookTarget
|
||||
) => AgentHookInstallStatus | Promise<AgentHookInstallStatus | null> | null
|
||||
}): Promise<AgentHookInstallStatus | null> {
|
||||
if (!args.hooksEnabled) {
|
||||
return null
|
||||
}
|
||||
const target = resolveManagedWslCodexShellPreflightTarget(args.env)
|
||||
if (!target) {
|
||||
return null
|
||||
}
|
||||
const realHome = await withTimeout(
|
||||
realpath(target.runtimeHomePath),
|
||||
WSL_MANAGED_HOME_REALPATH_TIMEOUT_MS,
|
||||
null
|
||||
)
|
||||
if (!realHome || !wslRuntimeHomePathsEqual(realHome, target.runtimeHomePath)) {
|
||||
return null
|
||||
}
|
||||
const install =
|
||||
args.install ??
|
||||
((home: string, hookTarget: CodexWslRuntimeHookTarget) =>
|
||||
codexHookService.installForRuntimeHomeSerialized(home, hookTarget))
|
||||
return await install(target.runtimeHomePath, {
|
||||
runtime: 'wsl',
|
||||
wslDistro: target.wslDistro
|
||||
})
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { LocalPtyProvider } from '../providers/local-pty-provider'
|
||||
import { __resetPersistedWindowsPathCacheForTests } from '../pty/windows-environment-path'
|
||||
import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader'
|
||||
import { hasLiveClaudePtys, markClaudePtySpawned } from '../claude-accounts/live-pty-gate'
|
||||
import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager'
|
||||
import { registerPtyHandlers, buildPtyHostEnv, clearProviderPtyState } from './pty'
|
||||
|
||||
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
|
||||
@@ -57,6 +58,32 @@ describe('registerPtyHandlers', () => {
|
||||
const { handlers, mainWindow, spawnAndGetEnv, withBundledCli } = setupPtyIpcSuite()
|
||||
|
||||
describe('spawn environment', () => {
|
||||
it('passes the PTY-resolved Codex home to the WSL relay lane', () => {
|
||||
const runtimeHome =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
const ensureForDistro = vi
|
||||
.spyOn(wslHookRelayManager, 'ensureForDistro')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
buildPtyHostEnv(
|
||||
'pty-wsl',
|
||||
{},
|
||||
{
|
||||
isPackaged: true,
|
||||
userDataPath: '/tmp/orca-user-data',
|
||||
selectedCodexHomePath: runtimeHome,
|
||||
isWsl: true,
|
||||
wslDistro: 'Ubuntu',
|
||||
agentStatusHooksEnabled: true
|
||||
}
|
||||
)
|
||||
expect(ensureForDistro).toHaveBeenCalledExactlyOnceWith('Ubuntu', runtimeHome)
|
||||
} finally {
|
||||
ensureForDistro.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('refreshes the outer Windows PATH for a WSL spawn without forwarding it', async () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
|
||||
@@ -122,7 +122,7 @@ export function buildPtyHostEnv(
|
||||
if (opts.isWsl === true) {
|
||||
// Why: hook POSTs to 127.0.0.1 die inside WSL's NAT namespace; use the guest-resident relay's endpoint instead of the Windows one.
|
||||
const distro = opts.wslDistro ?? null
|
||||
wslHookRelayManager.ensureForDistro(distro)
|
||||
wslHookRelayManager.ensureForDistro(distro, opts.selectedCodexHomePath)
|
||||
const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro)
|
||||
if (guestEndpoint) {
|
||||
baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint
|
||||
|
||||
@@ -162,13 +162,13 @@ describe.skipIf(!bashAvailable)('Codex preflight under a profile-rewritten PATH'
|
||||
expect(readFileSync(fixture.codexMarker, 'utf-8').trim()).toBe('--alias-flag --version')
|
||||
})
|
||||
|
||||
// Why: pins the defect itself, so a regression back to an unqualified name fails here.
|
||||
it('would run the impostor if the preflight carried an unqualified command name', () => {
|
||||
// Why: an unqualified preflight is rejected so profile PATH entries cannot hijack it.
|
||||
it('skips an unqualified preflight value while still launching codex', () => {
|
||||
const fixture = buildFixture()
|
||||
|
||||
launchCodexThroughRcfile(fixture, 'orca')
|
||||
|
||||
expect(existsSync(fixture.hijackMarker)).toBe(true)
|
||||
expect(existsSync(fixture.hijackMarker)).toBe(false)
|
||||
expect(existsSync(fixture.intendedMarker)).toBe(false)
|
||||
expect(existsSync(fixture.codexMarker)).toBe(true)
|
||||
})
|
||||
|
||||
@@ -170,6 +170,30 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', ()
|
||||
)
|
||||
})
|
||||
|
||||
it('does not trigger a preflight outside an Orca terminal', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-codex-plain-shell-'))
|
||||
roots.push(root)
|
||||
const bin = join(root, 'bin')
|
||||
const marker = join(root, 'preflight-ran')
|
||||
mkdirSync(bin)
|
||||
writeExecutable(join(bin, 'codex'), '#!/bin/sh\nprintf launched\n')
|
||||
writeExecutable(join(bin, 'orca-test'), `#!/bin/sh\nprintf ran > ${JSON.stringify(marker)}\n`)
|
||||
|
||||
const output = execFileSync(
|
||||
'/bin/bash',
|
||||
['--noprofile', '--norc', '-c', `${getPosixCodexShellLaunchPreflight()}\ncodex`],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(output.trim()).toBe('launched')
|
||||
expect(existsSync(marker)).toBe(false)
|
||||
})
|
||||
|
||||
// Why a loop over it.each: an early `return` reported green on hosts without the
|
||||
// shell, so the zsh half silently never ran on Linux. skipIf reports it as a skip.
|
||||
for (const [shell, strict] of [
|
||||
@@ -319,6 +343,24 @@ describe('Codex shell launch preflight command', () => {
|
||||
).toBe(launcherPath)
|
||||
})
|
||||
|
||||
it('carries the packaged Windows launcher for WSLENV path translation', () => {
|
||||
const { userDataPath, resourcesPath } = makeCliRoot()
|
||||
const launcherPath = join(resourcesPath, 'bin', 'orca.exe')
|
||||
writeExecutable(launcherPath, '#!/bin/sh\nexit 0\n')
|
||||
|
||||
expect(
|
||||
resolveCodexShellLaunchPreflightCommand({
|
||||
hooksEnabled: true,
|
||||
isPackaged: true,
|
||||
isWsl: true,
|
||||
managedHomePath: '/home/jin/.local/share/orca/codex-runtime-home/home',
|
||||
userDataPath,
|
||||
resourcesPath,
|
||||
platform: 'win32'
|
||||
})
|
||||
).toBe(launcherPath)
|
||||
})
|
||||
|
||||
it('never returns an unqualified command name that a profile-rewritten PATH could hijack', () => {
|
||||
const { userDataPath, resourcesPath } = makeCliRoot()
|
||||
writeExecutable(join(resourcesPath, 'bin', 'orca'), '#!/bin/sh\nexit 0\n')
|
||||
@@ -340,16 +382,12 @@ describe('Codex shell launch preflight command', () => {
|
||||
|
||||
it.each([
|
||||
{ label: 'the launcher file is missing', create: null },
|
||||
{ label: 'the launcher is not executable', create: 0o644 },
|
||||
{ label: 'the launcher path is a directory', create: 'directory' as const }
|
||||
])('skips the preflight when $label', (config) => {
|
||||
const { userDataPath, resourcesPath } = makeCliRoot()
|
||||
const launcherPath = join(resourcesPath, 'bin', 'orca')
|
||||
if (config.create === 'directory') {
|
||||
mkdirSync(launcherPath)
|
||||
} else if (config.create !== null) {
|
||||
writeFileSync(launcherPath, '#!/bin/sh\nexit 0\n')
|
||||
chmodSync(launcherPath, config.create)
|
||||
}
|
||||
|
||||
expect(
|
||||
@@ -364,6 +402,27 @@ describe('Codex shell launch preflight command', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'skips the preflight when the launcher is not executable',
|
||||
() => {
|
||||
const { userDataPath, resourcesPath } = makeCliRoot()
|
||||
const launcherPath = join(resourcesPath, 'bin', 'orca')
|
||||
writeFileSync(launcherPath, '#!/bin/sh\nexit 0\n')
|
||||
chmodSync(launcherPath, 0o644)
|
||||
|
||||
expect(
|
||||
resolveCodexShellLaunchPreflightCommand({
|
||||
hooksEnabled: true,
|
||||
isPackaged: true,
|
||||
managedHomePath: '/managed/home',
|
||||
userDataPath,
|
||||
resourcesPath,
|
||||
platform: 'darwin'
|
||||
})
|
||||
).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('skips the preflight when the packaged build exposes no resources root', () => {
|
||||
const { userDataPath } = makeCliRoot()
|
||||
|
||||
@@ -381,7 +440,7 @@ describe('Codex shell launch preflight command', () => {
|
||||
|
||||
it.each([
|
||||
{ hooksEnabled: false, isWsl: false, managedHomePath: '/managed/home' },
|
||||
{ hooksEnabled: true, isWsl: true, managedHomePath: '/managed/home' },
|
||||
{ hooksEnabled: true, isWsl: true, managedHomePath: '/managed/home', isPackaged: false },
|
||||
{ hooksEnabled: true, isWsl: false, managedHomePath: null }
|
||||
])('does not enable an unsupported preflight for %o', (options) => {
|
||||
const { userDataPath, resourcesPath } = makeCliRoot()
|
||||
@@ -390,7 +449,7 @@ describe('Codex shell launch preflight command', () => {
|
||||
expect(
|
||||
resolveCodexShellLaunchPreflightCommand({
|
||||
...options,
|
||||
isPackaged: true,
|
||||
isPackaged: options.isPackaged ?? true,
|
||||
userDataPath,
|
||||
resourcesPath,
|
||||
platform: 'darwin'
|
||||
|
||||
@@ -29,7 +29,7 @@ export type CodexShellLaunchPreflightCommandOptions = {
|
||||
export function resolveCodexShellLaunchPreflightCommand(
|
||||
options: CodexShellLaunchPreflightCommandOptions
|
||||
): string | null {
|
||||
if (!options.hooksEnabled || options.isWsl || !options.managedHomePath) {
|
||||
if (!options.hooksEnabled || !options.managedHomePath) {
|
||||
return null
|
||||
}
|
||||
const platform = options.platform ?? process.platform
|
||||
@@ -42,7 +42,14 @@ export function resolveCodexShellLaunchPreflightCommand(
|
||||
...DEV_LAUNCHER_DIR,
|
||||
platform === 'win32' ? `${DEV_COMMAND_NAME}.cmd` : DEV_COMMAND_NAME
|
||||
)
|
||||
return candidate && isExecutableFileOnDisk(candidate, platform) ? candidate : null
|
||||
if (!candidate || !isExecutableFileOnDisk(candidate, platform)) {
|
||||
return null
|
||||
}
|
||||
if (!options.isWsl) {
|
||||
return candidate
|
||||
}
|
||||
// Why: WSLENV /p translates the verified Windows launcher with the distro's configured automount root.
|
||||
return platform === 'win32' && options.isPackaged ? candidate : null
|
||||
}
|
||||
|
||||
function isExecutableFileOnDisk(path: string, platform: NodeJS.Platform): boolean {
|
||||
@@ -65,7 +72,7 @@ export function getPosixCodexShellLaunchPreflight(): string {
|
||||
# Why || : twice — zsh alone aborts inside the substitution, but every shell's
|
||||
# assignment adopts its exit status, so an absent codex trips set -e in bash too.
|
||||
__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)"
|
||||
if [[ -n "\${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "\${__orca_codex_binary:-}" && -x "\${__orca_codex_binary}" ]]; then
|
||||
if [[ -n "\${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "\${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "\${__orca_codex_binary:-}" && -x "\${__orca_codex_binary}" ]]; then
|
||||
# Why the function reserved word: it suppresses alias expansion of the name,
|
||||
# which otherwise rewrites this header at parse time and aborts the whole file.
|
||||
function codex {
|
||||
@@ -78,7 +85,7 @@ unset __orca_codex_binary
|
||||
}
|
||||
|
||||
export function getFishCodexShellLaunchPreflight(): string {
|
||||
return `if test -n "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
|
||||
return `if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
|
||||
function codex
|
||||
command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true
|
||||
command codex $argv
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('addOrcaWslInteropEnv', () => {
|
||||
expect(env.WSLENV).toContain('ORCA_TERMINAL_HANDLE/u')
|
||||
expect(env.WSLENV).toContain('ORCA_USER_DATA_PATH/p')
|
||||
expect(env.WSLENV).toContain('ORCA_CLI_COMMAND/u')
|
||||
expect(env.WSLENV).not.toContain('ORCA_CODEX_LAUNCH_PREFLIGHT')
|
||||
expect(env.WSLENV).toContain('ORCA_CODEX_LAUNCH_PREFLIGHT/p')
|
||||
expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p')
|
||||
expect(env.WSLENV).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION')
|
||||
expect(env.WSLENV).toContain('ORCA_PANE_KEY/u')
|
||||
|
||||
@@ -77,6 +77,7 @@ export function addOrcaWslInteropEnv(env: Record<string, string>): void {
|
||||
// and it cannot derive the hash segment from ORCA_USER_DATA_PATH alone.
|
||||
'ORCA_SHELL_READY_ROOT/p',
|
||||
'ORCA_CLI_COMMAND/u',
|
||||
'ORCA_CODEX_LAUNCH_PREFLIGHT/p',
|
||||
'ORCA_PANE_KEY/u',
|
||||
'ORCA_TAB_ID/u',
|
||||
'ORCA_WORKTREE_ID/u',
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { isStreamingMethod, type RpcContext } from '../core'
|
||||
|
||||
const { installForRuntimeHomeSerializedMock, realpathMock } = vi.hoisted(() => ({
|
||||
installForRuntimeHomeSerializedMock: vi.fn(),
|
||||
realpathMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../codex/hook-service', () => ({
|
||||
codexHookService: { installForRuntimeHomeSerialized: installForRuntimeHomeSerializedMock }
|
||||
}))
|
||||
vi.mock('node:fs/promises', () => ({ realpath: realpathMock }))
|
||||
|
||||
import { AGENT_HOOK_METHODS } from './agent-hooks'
|
||||
import {
|
||||
_internals as managedWslHomeRegistryInternals,
|
||||
recordManagedWslCodexHome
|
||||
} from '../../../codex/managed-wsl-codex-home-registry'
|
||||
|
||||
const LINUX_HOME = '/home/jin/.local/share/orca/codex-runtime-home/home'
|
||||
const RUNTIME_HOME =
|
||||
'\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
|
||||
function prepareMethod() {
|
||||
const method = AGENT_HOOK_METHODS.find(
|
||||
(candidate) => candidate.name === 'agentHooks.prepareCodexForWslPane'
|
||||
)
|
||||
if (!method || isStreamingMethod(method)) {
|
||||
throw new Error('Missing agentHooks.prepareCodexForWslPane request method')
|
||||
}
|
||||
return method
|
||||
}
|
||||
|
||||
function runtimeWithSettings(enabled = true, disabledTuiAgents: string[] = []): OrcaRuntimeService {
|
||||
return {
|
||||
getClientSettings: vi.fn(() => ({
|
||||
agentStatusHooksEnabled: enabled,
|
||||
disabledTuiAgents
|
||||
}))
|
||||
} as unknown as OrcaRuntimeService
|
||||
}
|
||||
|
||||
describe('agent hook RPC methods', () => {
|
||||
beforeEach(() => {
|
||||
installForRuntimeHomeSerializedMock.mockReset()
|
||||
realpathMock.mockReset()
|
||||
realpathMock.mockImplementation(async (path: string) => path)
|
||||
managedWslHomeRegistryInternals.clearRecordedManagedWslCodexHomes()
|
||||
recordManagedWslCodexHome('Ubuntu-24.04', RUNTIME_HOME)
|
||||
})
|
||||
|
||||
it('installs the pane-selected WSL home once and returns its status', async () => {
|
||||
const status = { agent: 'codex', state: 'installed' }
|
||||
installForRuntimeHomeSerializedMock.mockResolvedValue(status)
|
||||
const method = prepareMethod()
|
||||
const params = method.params!.parse({
|
||||
codexHome: LINUX_HOME,
|
||||
orcaCodexHome: LINUX_HOME,
|
||||
wslDistro: 'Ubuntu-24.04'
|
||||
})
|
||||
|
||||
await expect(method.handler(params, { runtime: runtimeWithSettings() })).resolves.toBe(status)
|
||||
expect(installForRuntimeHomeSerializedMock).toHaveBeenCalledExactlyOnceWith(RUNTIME_HOME, {
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'Ubuntu-24.04'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[false, []],
|
||||
[true, ['codex']]
|
||||
])('does not install when hooks are disabled (%s, %j)', async (enabled, disabledTuiAgents) => {
|
||||
const method = prepareMethod()
|
||||
const params = method.params!.parse({
|
||||
codexHome: '/home/jin/.local/share/orca/codex-runtime-home/home',
|
||||
orcaCodexHome: '/home/jin/.local/share/orca/codex-runtime-home/home',
|
||||
wslDistro: 'Ubuntu-24.04'
|
||||
})
|
||||
|
||||
await expect(
|
||||
method.handler(params, { runtime: runtimeWithSettings(enabled, disabledTuiAgents) })
|
||||
).resolves.toBeNull()
|
||||
expect(installForRuntimeHomeSerializedMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['runtime', 'mobile'] as const)('rejects non-local %s callers', async (clientKind) => {
|
||||
const method = prepareMethod()
|
||||
const params = method.params!.parse({
|
||||
codexHome: '/home/jin/.local/share/orca/codex-runtime-home/home',
|
||||
orcaCodexHome: '/home/jin/.local/share/orca/codex-runtime-home/home',
|
||||
wslDistro: 'Ubuntu-24.04'
|
||||
})
|
||||
|
||||
await expect(
|
||||
method.handler(params, {
|
||||
runtime: runtimeWithSettings(),
|
||||
clientKind
|
||||
} as RpcContext)
|
||||
).rejects.toThrow(/only available to the local Orca CLI/)
|
||||
expect(installForRuntimeHomeSerializedMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propagates an attempted installer failure', async () => {
|
||||
installForRuntimeHomeSerializedMock.mockRejectedValue(new Error('install failed'))
|
||||
const method = prepareMethod()
|
||||
const params = method.params!.parse({
|
||||
codexHome: '/home/jin/.local/share/orca/codex-runtime-home/home',
|
||||
orcaCodexHome: '/home/jin/.local/share/orca/codex-runtime-home/home',
|
||||
wslDistro: 'Ubuntu-24.04'
|
||||
})
|
||||
|
||||
await expect(method.handler(params, { runtime: runtimeWithSettings() })).rejects.toThrow(
|
||||
'install failed'
|
||||
)
|
||||
expect(installForRuntimeHomeSerializedMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects a managed-looking home that resolves through a symlink', async () => {
|
||||
realpathMock.mockResolvedValue(
|
||||
'\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\outside-managed-home'
|
||||
)
|
||||
const method = prepareMethod()
|
||||
const params = method.params!.parse({
|
||||
codexHome: LINUX_HOME,
|
||||
orcaCodexHome: LINUX_HOME,
|
||||
wslDistro: 'Ubuntu-24.04'
|
||||
})
|
||||
|
||||
await expect(method.handler(params, { runtime: runtimeWithSettings() })).resolves.toBeNull()
|
||||
expect(installForRuntimeHomeSerializedMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects malformed distro names at the RPC schema', () => {
|
||||
const method = prepareMethod()
|
||||
|
||||
expect(() =>
|
||||
method.params!.parse({
|
||||
codexHome: '/home/jin/.local/share/orca/codex-runtime-home/home',
|
||||
orcaCodexHome: '/home/jin/.local/share/orca/codex-runtime-home/home',
|
||||
wslDistro: 'Ubuntu\\..\\host'
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { z } from 'zod'
|
||||
import { prepareManagedWslCodexHomeBeforeShellLaunch } from '../../../codex/managed-wsl-home-shell-preflight'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
|
||||
const PrepareCodexForWslPaneParams = z
|
||||
.object({
|
||||
codexHome: z.string().max(4_096),
|
||||
orcaCodexHome: z.string().max(4_096),
|
||||
wslDistro: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.regex(/^[^\\/\r\n]+$/)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const AGENT_HOOK_METHODS: readonly RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'agentHooks.prepareCodexForWslPane',
|
||||
params: PrepareCodexForWslPaneParams,
|
||||
handler: async (params, { runtime, clientKind }) => {
|
||||
if (clientKind !== undefined) {
|
||||
throw new Error('Codex hook preparation is only available to the local Orca CLI.')
|
||||
}
|
||||
const settings = runtime.getClientSettings()
|
||||
return await prepareManagedWslCodexHomeBeforeShellLaunch({
|
||||
env: {
|
||||
CODEX_HOME: params.codexHome,
|
||||
ORCA_CODEX_HOME: params.orcaCodexHome,
|
||||
WSL_DISTRO_NAME: params.wslDistro
|
||||
},
|
||||
hooksEnabled:
|
||||
settings.agentStatusHooksEnabled && !settings.disabledTuiAgents.includes('codex')
|
||||
})
|
||||
}
|
||||
})
|
||||
]
|
||||
@@ -43,12 +43,14 @@ import { PAIRING_METHODS } from './pairing'
|
||||
import { UPDATER_METHODS } from './updater'
|
||||
import { AGENT_SESSION_METHODS } from './agent-session'
|
||||
import { ARTIFACT_METHODS } from './artifacts'
|
||||
import { AGENT_HOOK_METHODS } from './agent-hooks'
|
||||
|
||||
// Why: a flat manifest keeps registration order explicit and provides one
|
||||
// grep-point for "what methods does the RPC server expose?" — useful when
|
||||
// auditing the security boundary or wiring new CLI commands.
|
||||
export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
|
||||
...STATUS_METHODS,
|
||||
...AGENT_HOOK_METHODS,
|
||||
...AI_VAULT_METHODS,
|
||||
...ARTIFACT_METHODS,
|
||||
...AUTOMATION_METHODS,
|
||||
|
||||
Reference in New Issue
Block a user