fix(agent-hooks): install OpenCode status plugin into the WSL guest so status works over WSL (#10328)

* fix(agent-hooks): install OpenCode status plugin into the WSL guest so status works over WSL

OpenCode reports agent status via a JS plugin dropped into OPENCODE_CONFIG_DIR
(unlike Claude/Codex, which use managed hooks.json scripts). Over the WSL
runtime that plugin was never materialized inside the guest and
OPENCODE_CONFIG_DIR never crossed into the guest, so OpenCode status never
reached Orca's sidebar (the workspace stayed green). Codex already worked; this
was OpenCode-specific.

Mirror the SSH plugin-overlay path for WSL:
- Guest relay registers AGENT_HOOK_INSTALL_PLUGINS_METHOD, byte-caps the source,
  and materializes an OpenCode config overlay via PluginOverlayManager (the same
  electron-free path the SSH relay uses), returning overlayDirs.opencode.
- Host manager ships the plugin source over the existing stdio channel after
  installers run (and on mid-session reinstall) and records the guest overlay
  dir; -32601 / CONNECTION_LOST / DISPOSED are swallowed like ssh-relay-session.
- PTY env points OPENCODE_CONFIG_DIR/ORCA_OPENCODE_CONFIG_DIR at the guest
  overlay; until the relay reports it (first spawn / older guest bundle) it drops
  those vars rather than crossing the Windows overlay path into WSL — so
  in-guest OpenCode falls back to its own config (pre-fix behavior, no
  regression).
- WSLENV passes OPENCODE_CONFIG_DIR/ORCA_OPENCODE_CONFIG_DIR through (/u for
  guest paths).

SSH is untouched: the same JSON-RPC constant is reused and the guest response
merely gains an optional overlayDirs field the SSH host ignores.

Runtime repro: native opencode launched in a WSL Orca terminal had
ORCA_AGENT_HOOK_PORT/ORCA_PANE_KEY but no OPENCODE_CONFIG_DIR and no Orca plugin
in ~/.config/opencode, so agentStatusByPaneKey stayed empty.

* fix(agent-hooks): stop the WSL OpenCode overlay leaking Windows paths and churning under running agents

Review fixes on top of the WSL OpenCode plugin install:

- Never cross a Windows OPENCODE_CONFIG_DIR into the guest. The /p flag was
  not a defensive default but WSLENV's translate-and-deliver flag, and
  buildWslRelaySpawnEnv spreads process.env while the daemon merge resurrects
  keys buildPtyHostEnv only deleted -- so a Windows value reached the guest as
  /mnt/c/... and was adopted as its OpenCode config root. Register the two
  vars only when the value is already a guest POSIX path.

- Make guest materialization idempotent. The overlay id is instance-scoped,
  and the host re-ships on every reinstall (60s after connect, and on later
  pane spawns), so materializeOpenCode's remove-and-rebuild wiped the config
  root under running agents and raced panes spawning against the path just
  handed to them. Rebuild only when the shipped source changed or the overlay
  went missing.

- Mirror the guest's default ~/.config/opencode (honouring XDG_CONFIG_HOME)
  when no explicit dir is discoverable, so pointing OPENCODE_CONFIG_DIR at the
  overlay no longer silently drops the user's models/agents/skills/mcp.

- Carry opencodeOverlayDir across relay relaunch; it is instance-keyed and on
  the distro's persistent filesystem, so dropping it only blanked status on
  panes spawned mid-relaunch.

* fix(agent-hooks): stop advertising a WSL OpenCode overlay the guest failed to rebuild

Round-2 review fixes:

- materializeOpenCode wipes before rebuilding, and every failure path after the
  wipe returns null leaving the dir present but plugin-less. The host treated
  that null the same as "no handler / teardown" and silently kept the previous
  value, so a pane could be pointed at an empty config root -- worse than the
  documented fallback of dropping the var. requestGuestOpenCodeOverlayDir now
  distinguishes 'none' (guest answered, no dir) from 'unavailable', and the
  manager clears the recorded dir on 'none'.

- The handler cache keyed only on plugin source, so a ~/.config/opencode created
  after the relay connected was never mirrored for the relay's lifetime. Key on
  the resolved source dir too, and validate the cache by the plugin file rather
  than the directory -- the directory is exactly what a failed rebuild leaves
  behind, so checking it alone made the bad state stick.

* fix(agent-hooks): don't mirror the XDG default OpenCode config into the WSL overlay

OPENCODE_CONFIG_DIR is APPENDED to OpenCode's config-dir list, not a
replacement for it. Verified against the shipped binary: the list is built as
[Path.config, ...project .opencode dirs, ...OPENCODE_CONFIG_DIR ? [it] : []],
and Path.config is derived independently from XDG_CONFIG_HOME/$HOME/.config.

So ~/.config/opencode is read whether or not Orca overrides the var, and the
earlier fallback that mirrored it into the overlay made OpenCode load the
user's config -- and their plugins -- twice. Resolve only an explicitly-set
dir, which is the one case that genuinely leaves the list when Orca overwrites
the variable. This also restores parity with the SSH and local paths.

* docs(agent-hooks): correct the WSL install-plugins cache comment and test framing

The per-call source-dir re-resolution comment still described the XDG default
branch that 6745eba90 removed. The relay's env is fixed for its lifetime and
the rc scan behind it is memoized, so the sourceDir cache key is defensive
rather than live -- say so, and retitle the test that simulates it by mutating
the captured env, so neither reads as coverage of a production scenario.
This commit is contained in:
OrcaWin 2026-07-25 02:30:59 -07:00 committed by GitHub
parent f009500677
commit e1eca7f311
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 561 additions and 8 deletions

View File

@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest'
import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
function fakeMux(
request: () => Promise<unknown>,
isDisposed = false
): { mux: SshChannelMultiplexer } {
return { mux: { request, isDisposed: () => isDisposed } as unknown as SshChannelMultiplexer }
}
function deps() {
return {
pluginSources: () => ({ opencodePluginSource: '// src' }),
warn: vi.fn<(message: string) => void>()
}
}
describe('requestGuestOpenCodeOverlayDir', () => {
it('reports the guest overlay dir', async () => {
const { mux } = fakeMux(async () => ({ overlayDirs: { opencode: '/home/jin/.orca-relay/x' } }))
await expect(requestGuestOpenCodeOverlayDir(mux, deps(), 'Ubuntu')).resolves.toEqual({
kind: 'dir',
dir: '/home/jin/.orca-relay/x'
})
})
it("reports 'none' when the guest answered but materialization produced no dir", async () => {
// Why: distinct from 'unavailable' — the caller must CLEAR a previously recorded
// dir here, since a rebuild that failed after wiping leaves it plugin-less.
const { mux } = fakeMux(async () => ({ installed: { opencode: true }, overlayDirs: {} }))
await expect(requestGuestOpenCodeOverlayDir(mux, deps(), 'Ubuntu')).resolves.toEqual({
kind: 'none'
})
})
it("reports 'unavailable' for an older guest bundle and for teardown, without warning", async () => {
for (const code of [-32601, 'CONNECTION_LOST', 'DISPOSED']) {
const d = deps()
const { mux } = fakeMux(async () => {
throw Object.assign(new Error('nope'), { code })
})
await expect(requestGuestOpenCodeOverlayDir(mux, d, 'Ubuntu')).resolves.toEqual({
kind: 'unavailable'
})
expect(d.warn).not.toHaveBeenCalled()
}
})
it("warns but still reports 'unavailable' on an unexpected failure", async () => {
const d = deps()
const { mux } = fakeMux(async () => {
throw new Error('boom')
})
await expect(requestGuestOpenCodeOverlayDir(mux, d, 'Ubuntu')).resolves.toEqual({
kind: 'unavailable'
})
expect(d.warn).toHaveBeenCalledWith(expect.stringContaining('boom'))
})
})

View File

@ -0,0 +1,45 @@
// Ships plugin/extension source to the guest WSL relay and reports the OpenCode
// config-overlay dir it materialized. Best-effort: an older guest bundle lacks
// the handler (-32601) and routine teardown races resolve to `unavailable`.
// Mirrors the SSH relay's installPluginsOnRelay swallow list.
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../../shared/agent-hook-relay'
import type { PluginSources } from '../../relay/plugin-overlay'
/** Structural, not the deps type itself, so this stays free of the deps module. */
type GuestPluginInstallDeps = {
pluginSources: () => PluginSources
warn: (message: string) => void
}
/** `none` (guest answered, but materialization failed) must not be conflated
* with `unavailable` (no handler / teardown): only `none` means the previously
* recorded dir is now unusable and must stop being advertised to PTYs. */
export type GuestOverlayResult =
| { kind: 'dir'; dir: string }
| { kind: 'none' }
| { kind: 'unavailable' }
export async function requestGuestOpenCodeOverlayDir(
mux: SshChannelMultiplexer,
deps: GuestPluginInstallDeps,
distro: string
): Promise<GuestOverlayResult> {
try {
const res = (await mux.request(AGENT_HOOK_INSTALL_PLUGINS_METHOD, deps.pluginSources())) as {
overlayDirs?: { opencode?: unknown }
}
const dir = res?.overlayDirs?.opencode
return typeof dir === 'string' && dir.length > 0 ? { kind: 'dir', dir } : { kind: 'none' }
} catch (err) {
// Why: -32601 = older guest bundle without the handler; CONNECTION_LOST/DISPOSED = routine mid-flight teardown — swallow both.
const code = (err as { code?: unknown })?.code
if (code === -32601 || code === 'CONNECTION_LOST' || code === 'DISPOSED' || mux.isDisposed()) {
return { kind: 'unavailable' }
}
deps.warn(
`[agent-hooks] WSL installPlugins for '${distro}' failed: ${err instanceof Error ? err.message : String(err)}`
)
return { kind: 'unavailable' }
}
}

View File

@ -6,6 +6,8 @@ import { readFileSync } from 'node:fs'
import { agentHookServer } from './server'
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import { getOpenCodePluginSource } from '../opencode/hook-service'
import type { PluginSources } from '../../relay/plugin-overlay'
import {
isWslDistroRunning,
resolveWslHookRelayBundle,
@ -57,6 +59,8 @@ export type WslHookRelayManagerDeps = {
waitForSentinel: typeof waitForWslRelaySentinel
ingest: (envelope: Record<string, unknown>, connectionId: string) => void
installHooks: typeof installRemoteManagedAgentHooks
/** Plugin source strings shipped to the guest relay so an Orca update needn't redeploy the relay bundle. */
pluginSources: () => PluginSources
warn: (message: string) => void
transientRetryDelayMs: number
}
@ -85,6 +89,8 @@ export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = {
connectionId
),
installHooks: installRemoteManagedAgentHooks,
// 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() }),
warn: (message) => console.warn(message),
transientRetryDelayMs: WSL_RELAY_TRANSIENT_RETRY_DELAY_MS
}

View File

@ -16,6 +16,7 @@ import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers
import { WslHookRelayManager } from './wsl-hook-relay-manager'
import { FAILURE_COOLDOWN_BASE_MS, type WslHookRelayManagerDeps } from './wsl-hook-relay-deps'
import {
AGENT_HOOK_INSTALL_PLUGINS_METHOD,
AGENT_HOOK_NOTIFICATION_METHOD,
AGENT_HOOK_REQUEST_REPLAY_METHOD
} from '../../shared/agent-hook-relay'
@ -135,6 +136,7 @@ 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 opencodeOverlayDir = `${home}/.orca-relay/opencode-overlays/deadbeefcafe`
let harnesses: GuestHarness[]
beforeEach(() => {
@ -164,13 +166,20 @@ describe('WslHookRelayManager', () => {
return child as unknown as ChildProcessWithoutNullStreams & { emitClose: () => void }
}
function guestTransport(): MultiplexerTransport {
function guestTransport(registerInstallPlugins = true): MultiplexerTransport {
const harness = createGuestHarness()
harnesses.push(harness)
registerWslHookFsHandlers(harness.guestDispatcher, home)
harness.guestDispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => ({
replayed: 0
}))
// A guest bundle predating the plugin overlay omits this handler (-32601).
if (registerInstallPlugins) {
harness.guestDispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async () => ({
installed: { opencode: true, pi: false, omp: false },
overlayDirs: { opencode: opencodeOverlayDir }
}))
}
return harness.transport
}
@ -203,6 +212,7 @@ describe('WslHookRelayManager', () => {
waitForSentinel: vi.fn(async () => guestTransport()),
ingest: vi.fn(),
installHooks: vi.fn(async () => []),
pluginSources: () => ({ opencodePluginSource: '// opencode plugin source' }),
warn: vi.fn(),
transientRetryDelayMs: 1,
...overrides
@ -243,6 +253,25 @@ describe('WslHookRelayManager', () => {
manager.disposeAll()
})
it('ships the OpenCode plugin to the guest and exposes the overlay dir', async () => {
const { manager } = createManager({})
manager.ensureForDistro('Ubuntu')
await vi.waitFor(() => expect(manager.getOpenCodeOverlayDir('Ubuntu')).toBe(opencodeOverlayDir))
manager.disposeAll()
})
it('leaves the overlay dir null when the guest bundle lacks the installPlugins handler', async () => {
const waitForSentinel = vi.fn(async () => guestTransport(false))
const { manager, deps } = createManager({ waitForSentinel })
manager.ensureForDistro('Ubuntu')
// Connect still completes (hooks install); the -32601 is swallowed silently.
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
await new Promise((resolve) => setTimeout(resolve, 20))
expect(manager.getOpenCodeOverlayDir('Ubuntu')).toBeNull()
expect(deps.warn).not.toHaveBeenCalledWith(expect.stringContaining('installPlugins'))
manager.disposeAll()
})
it('resolves the default distro for null and dedupes it with the explicit name', async () => {
const { manager, deps } = createManager({})
manager.ensureForDistro(null)

View File

@ -18,6 +18,7 @@ import {
} from './wsl-hook-relay-deps'
import { wireWslRelayLink } from './wsl-hook-relay-link'
import { WslRelayRecovery } from './wsl-hook-relay-recovery'
import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install'
import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer'
import { AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../../shared/agent-hook-relay'
import {
@ -34,6 +35,7 @@ type DistroState = {
mux?: SshChannelMultiplexer
guestHome?: string
guestEndpointFilePath?: string
opencodeOverlayDir?: string
failures: number
cooldownUntil: number
connectedAt?: number
@ -85,14 +87,22 @@ export class WslHookRelayManager {
})
}
private stateFor(distro: string | null): DistroState | undefined {
// Empty key never matches a real (non-empty) distro state.
return this.states.get(distroKey(distro ?? this.defaultDistro ?? ''))
}
/** Guest endpoint file path once known; null before first connect
* (callers keep the /p-translated Windows endpoint path until then). */
getGuestEndpointFilePath(distro: string | null): string | null {
const name = distro ?? this.defaultDistro
if (!name) {
return null
}
return this.states.get(distroKey(name))?.guestEndpointFilePath ?? null
return this.stateFor(distro)?.guestEndpointFilePath ?? null
}
/** Guest OpenCode config-overlay dir once the guest relay materializes it;
* null before then (older bundle / relay not yet connected). Callers drop
* OPENCODE_CONFIG_DIR while null so no Windows overlay path crosses into WSL. */
getOpenCodeOverlayDir(distro: string | null): string | null {
return this.stateFor(distro)?.opencodeOverlayDir ?? null
}
disposeAll(): void {
@ -145,6 +155,9 @@ export class WslHookRelayManager {
distro,
phase: 'starting',
failures: existing?.failures ?? 0,
// 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,
cooldownUntil: 0
}
this.states.set(key, state)
@ -169,7 +182,9 @@ export class WslHookRelayManager {
{ cooldownBaseMs: NO_NODE_COOLDOWN_MS }
),
onFailure: (message) =>
this.markFailed(state, message, { cooldownBaseMs: FAILURE_COOLDOWN_BASE_MS }),
this.markFailed(state, message, {
cooldownBaseMs: FAILURE_COOLDOWN_BASE_MS
}),
connect: (transport, child) => this.connect(state, transport, child, instanceKey)
})
} catch (err) {
@ -267,6 +282,14 @@ export class WslHookRelayManager {
installHooks: this.deps.installHooks,
warn: this.deps.warn
})
// Why: ship OpenCode's status plugin and record the guest overlay dir the
// PTY env points OPENCODE_CONFIG_DIR at; identity-guarded against teardown.
const overlay = await requestGuestOpenCodeOverlayDir(mux, this.deps, state.distro)
if (state.mux === mux && overlay.kind !== 'unavailable') {
// Clearing on 'none' matters: a rebuild that failed after wiping leaves the dir
// present but plugin-less, and advertising it would hide the user's own config.
state.opencodeOverlayDir = overlay.kind === 'dir' ? overlay.dir : undefined
}
}
private async maybeReinstallHooks(state: DistroState): Promise<void> {
@ -281,6 +304,7 @@ export class WslHookRelayManager {
return
}
try {
// Why: runInstallers also re-ships the plugin source so a mid-session Orca upgrade refreshes it.
await this.runInstallers(state, mux, guestHome)
} catch (err) {
this.deps.warn(

View File

@ -230,6 +230,7 @@ import {
} from '../providers/ssh-pty-errors'
import { resolveWindowsShellLaunchArgs } from '../providers/windows-shell-args'
import { _resetWslCachesForTests, _setWslCachesForTests } from '../wsl'
import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager'
import { acquireWatcherRemovalGate } from './watcher-removal-gate'
// Why: Windows resolves a bare PowerShell name to an absolute exe before ConPTY, else CreateProcessW fails with error 5 (PR #6537 / #5161).
@ -2830,6 +2831,40 @@ describe('registerPtyHandlers', () => {
}
})
it('drops OPENCODE_CONFIG_DIR for a WSL daemon spawn until the guest overlay is known', async () => {
await withWin32Platform(async () => {
const env = await daemonSpawnAndGetEnv({}, undefined, undefined, undefined, {
shellOverride: 'wsl.exe'
})
// Why: relay not connected yet → never cross the Windows overlay path into WSL.
expect(env.OPENCODE_CONFIG_DIR).toBeUndefined()
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined()
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined()
})
})
it('points OPENCODE_CONFIG_DIR at the guest overlay when the WSL relay reports it', async () => {
const guestDir = '/home/jin/.orca-relay/opencode-overlays/abc'
const spy = vi.spyOn(wslHookRelayManager, 'getOpenCodeOverlayDir').mockReturnValue(guestDir)
try {
await withWin32Platform(async () => {
const env = await daemonSpawnAndGetEnv(
{ ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/home/jin/.config/opencode' },
undefined,
undefined,
undefined,
{ shellOverride: 'wsl.exe' }
)
expect(env.OPENCODE_CONFIG_DIR).toBe(guestDir)
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe(guestDir)
// The Windows-side source pointer must not cross into the guest.
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined()
})
} finally {
spy.mockRestore()
}
})
it('strips the daemon-inherited Orca-owned CODEX_HOME for real-home routing', async () => {
const spawnOptions = await daemonSpawnAndGetOptions(
{},

View File

@ -1073,6 +1073,18 @@ export function buildPtyHostEnv(
if (guestEndpoint) {
baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint
}
// Why: OpenCode loads its status plugin from a guest config overlay, so point OPENCODE_CONFIG_DIR at the guest dir the relay materialized.
const opencodeOverlayDir = wslHookRelayManager.getOpenCodeOverlayDir(distro)
if (opencodeOverlayDir) {
baseEnv.OPENCODE_CONFIG_DIR = opencodeOverlayDir
baseEnv.ORCA_OPENCODE_CONFIG_DIR = opencodeOverlayDir
delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR
} else {
// Why: relay not connected yet (or older guest bundle) — never cross the Windows overlay path into WSL; drop it so in-guest OpenCode uses its own config (pre-fix behavior, no status but no regression).
delete baseEnv.OPENCODE_CONFIG_DIR
delete baseEnv.ORCA_OPENCODE_CONFIG_DIR
delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR
}
}
}

View File

@ -124,10 +124,43 @@ describe('addOrcaWslInteropEnv', () => {
})
it('marks the WSL hook relay version for import on relay spawn envs', () => {
const env: Record<string, string> = { ORCA_WSL_HOOK_RELAY_VERSION: '0.1.0+abc' }
const env: Record<string, string> = {
ORCA_WSL_HOOK_RELAY_VERSION: '0.1.0+abc'
}
addOrcaWslInteropEnv(env)
expect(env.WSLENV).toBe('ORCA_WSL_HOOK_RELAY_VERSION/u')
})
it('crosses a guest-side OpenCode config overlay untranslated (/u)', () => {
const env: Record<string, string> = {
OPENCODE_CONFIG_DIR: '/home/jin/.orca-relay/opencode-overlays/abc',
ORCA_OPENCODE_CONFIG_DIR: '/home/jin/.orca-relay/opencode-overlays/abc'
}
addOrcaWslInteropEnv(env)
expect(env.WSLENV).toContain('OPENCODE_CONFIG_DIR/u')
expect(env.WSLENV).toContain('ORCA_OPENCODE_CONFIG_DIR/u')
expect(env.WSLENV).not.toContain('OPENCODE_CONFIG_DIR/p')
})
it('never crosses a Windows OpenCode config dir into the guest', () => {
// Why: the relay spawn env spreads process.env and the daemon inherits its
// own — a /p entry here would deliver C:\... as /mnt/c and in-guest OpenCode
// would adopt Orca's Windows overlay as its config root.
const env: Record<string, string> = {
OPENCODE_CONFIG_DIR: 'C:\\Users\\jin\\AppData\\Roaming\\Orca\\opencode-overlays\\abc',
ORCA_OPENCODE_CONFIG_DIR: 'C:\\Users\\jin\\AppData\\Roaming\\Orca\\opencode-overlays\\abc'
}
addOrcaWslInteropEnv(env)
expect(env.WSLENV).not.toContain('OPENCODE_CONFIG_DIR')
expect(env.WSLENV).not.toContain('ORCA_OPENCODE_CONFIG_DIR')
})
it('does not register the OpenCode config vars when they are absent', () => {
const env: Record<string, string> = { ORCA_TERMINAL_HANDLE: 'term_wsl' }
addOrcaWslInteropEnv(env)
expect(env.WSLENV).not.toContain('OPENCODE_CONFIG_DIR')
expect(env.WSLENV).not.toContain('ORCA_OPENCODE_CONFIG_DIR')
})
})
describe('addWorktreeSetupWslInteropEnv', () => {

View File

@ -54,6 +54,13 @@ export function addOrcaWslInteropEnv(env: Record<string, string>): void {
// via /mnt/c) until the WSL hook relay reports the guest home — then it is
// already a guest-side POSIX path and must cross untranslated.
const endpointFlag = env.ORCA_AGENT_HOOK_ENDPOINT?.startsWith('/') ? 'u' : 'p'
// Why: ONLY a guest-side POSIX overlay may cross. /p would path-translate a
// Windows value into /mnt/c and let in-guest OpenCode adopt it as its config
// root — reachable via the relay spawn's process.env (wsl-hook-relay-launch)
// and via daemon-inherited env, which buildPtyHostEnv's delete cannot reach.
const opencodeOverlayEntries = (['OPENCODE_CONFIG_DIR', 'ORCA_OPENCODE_CONFIG_DIR'] as const)
.filter((name) => env[name]?.startsWith('/'))
.map((name) => `${name}/u`)
// Why: wsl.exe only imports selected Windows env vars, so WSL needs the wrapper root, pane identity, and hook/OMP coordinates at start.
const passthroughEntries = [
'ORCA_TERMINAL_HANDLE/u',
@ -68,6 +75,7 @@ export function addOrcaWslInteropEnv(env: Record<string, string>): void {
'ORCA_AGENT_HOOK_ENV/u',
'ORCA_AGENT_HOOK_VERSION/u',
`ORCA_AGENT_HOOK_ENDPOINT/${endpointFlag}`,
...opencodeOverlayEntries,
'ORCA_WSL_HOOK_RELAY_VERSION/u',
'ORCA_WSL_HOOK_INSTANCE/u',
'ORCA_OMP_SOURCE_AGENT_DIR/p',

View File

@ -83,6 +83,12 @@ export function getRelayPiStatusExtensionPath(agentDir: string): string {
return join(agentDir, 'extensions', PI_EXTENSION_FILE)
}
/** Presence of this file is what makes an overlay usable a rebuild that failed
* after the wipe leaves the dir itself present but the plugin missing. */
export function getRelayOpenCodePluginPath(overlayDir: string): string {
return join(overlayDir, 'plugins', OPENCODE_PLUGIN_FILE)
}
export class PluginOverlayManager {
private opencodePluginSource: string | null = null
private piExtensionSources: Record<PiAgentKind, string | null> = {

View File

@ -16,7 +16,10 @@ import { RELAY_SENTINEL } from './protocol'
import { RelayDispatcher } from './dispatcher'
import { RelayAgentHookServer } from './agent-hook-server'
import { registerWslHookFsHandlers } from './wsl-hook-fs-bridge'
import { PluginOverlayManager } from './plugin-overlay'
import { createInstallPluginsHandler } from './wsl-install-plugins-handler'
import {
AGENT_HOOK_INSTALL_PLUGINS_METHOD,
AGENT_HOOK_NOTIFICATION_METHOD,
AGENT_HOOK_REQUEST_REPLAY_METHOD
} from '../shared/agent-hook-relay'
@ -60,6 +63,15 @@ async function main(): Promise<void> {
dispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => ({
replayed: hookServer.replayCachedPayloadsForPanes()
}))
// Why: OpenCode reports status via a plugin (not a hooks.json script), so the
// host ships its source over the wire and the guest materializes a config
// overlay here — the same PluginOverlayManager path the SSH relay uses. One
// handler for the relay's life: it remembers the materialized overlay so
// repeat installs don't rebuild it under running agents.
const installPlugins = createInstallPluginsHandler(new PluginOverlayManager(), process.env)
dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => installPlugins(params))
registerWslHookFsHandlers(dispatcher, homedir(), () => ({
portFallback: hookServer.usedPortFallback,
boundPort: hookServer.getCoordinates().port

View File

@ -0,0 +1,193 @@
// POSIX-only: the guest relay runs inside the Linux distro and materializes
// overlays under a real $HOME. On a Windows dev host tmpdir() yields C:\ paths
// the overlay logic is not meant to serve; live coverage comes from the rig.
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { PluginOverlayManager } from './plugin-overlay'
import { createInstallPluginsHandler } from './wsl-install-plugins-handler'
import { PLUGIN_SOURCE_MAX_BYTES } from './plugin-source-limit'
describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (guest side)', () => {
function freshHome(): string {
return mkdtempSync(join(tmpdir(), 'wsl-guest-home-'))
}
function withHome(run: (home: string) => void): void {
const home = freshHome()
try {
run(home)
} finally {
rmSync(home, { recursive: true, force: true })
}
}
it('writes orca-opencode-status.js into the overlay and returns that dir', () => {
withHome((home) => {
const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), {
HOME: home,
ORCA_WSL_HOOK_INSTANCE: 'inst1'
} as NodeJS.ProcessEnv)
const source = '// orca opencode status plugin\nexport const Plugin = () => ({})\n'
const res = install({ opencodePluginSource: source })
expect(res.installed.opencode).toBe(true)
const dir = res.overlayDirs.opencode
expect(typeof dir).toBe('string')
const pluginPath = join(dir as string, 'plugins', 'orca-opencode-status.js')
expect(existsSync(pluginPath)).toBe(true)
expect(readFileSync(pluginPath, 'utf8')).toBe(source)
})
})
it('reuses the overlay on repeat installs instead of rebuilding it', () => {
withHome((home) => {
const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), {
HOME: home,
ORCA_WSL_HOOK_INSTANCE: 'inst1'
} as NodeJS.ProcessEnv)
const source = '// v1\n'
const dir = install({ opencodePluginSource: source }).overlayDirs.opencode as string
// Why: a wipe-and-rebuild would delete this alongside the rest of the tree,
// pulling the config root out from under an agent already running against it.
const canary = join(dir, 'opencode.json')
writeFileSync(canary, '{"model":"user-set"}')
// The host re-ships on every reinstall (60s one-shot, later pane spawns).
expect(install({ opencodePluginSource: source }).overlayDirs.opencode).toBe(dir)
expect(install({}).overlayDirs.opencode).toBe(dir)
expect(existsSync(canary)).toBe(true)
})
})
it('rebuilds if the resolved source dir ever changes (defensive)', () => {
withHome((home) => {
// The relay's env is fixed for its lifetime, so nothing in production reaches
// this branch today; it exists so a plugin-only overlay can't outlive a source
// dir becoming resolvable. Simulated by mutating the env the factory captured.
const userConfig = join(home, 'my-opencode')
const env = { HOME: home, ORCA_WSL_HOOK_INSTANCE: 'inst1' } as NodeJS.ProcessEnv
const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), env)
const source = '// v1\n'
install({ opencodePluginSource: source })
mkdirSync(userConfig, { recursive: true })
writeFileSync(join(userConfig, 'opencode.json'), '{"model":"late"}')
env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = userConfig
const dir = install({ opencodePluginSource: source }).overlayDirs.opencode as string
expect(readFileSync(join(dir, 'opencode.json'), 'utf8')).toBe('{"model":"late"}')
})
})
it('rebuilds when the cached overlay lost its plugin file', () => {
withHome((home) => {
const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), {
HOME: home,
ORCA_WSL_HOOK_INSTANCE: 'inst1'
} as NodeJS.ProcessEnv)
const source = '// v1\n'
const dir = install({ opencodePluginSource: source }).overlayDirs.opencode as string
// Why: a rebuild that failed after the wipe leaves the dir but not the plugin;
// an existsSync on the dir alone would call that a cache hit forever.
rmSync(join(dir, 'plugins', 'orca-opencode-status.js'))
expect(install({ opencodePluginSource: source }).overlayDirs.opencode).toBe(dir)
expect(existsSync(join(dir, 'plugins', 'orca-opencode-status.js'))).toBe(true)
})
})
it('re-materializes when the shipped source changes', () => {
withHome((home) => {
const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), {
HOME: home,
ORCA_WSL_HOOK_INSTANCE: 'inst1'
} as NodeJS.ProcessEnv)
install({ opencodePluginSource: '// v1\n' })
// Why: a mid-session Orca upgrade ships new plugin source; future spawns must see it.
const dir = install({ opencodePluginSource: '// v2\n' }).overlayDirs.opencode as string
expect(readFileSync(join(dir, 'plugins', 'orca-opencode-status.js'), 'utf8')).toBe('// v2\n')
})
})
it('rebuilds when the cached overlay disappeared from the guest', () => {
withHome((home) => {
const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), {
HOME: home,
ORCA_WSL_HOOK_INSTANCE: 'inst1'
} as NodeJS.ProcessEnv)
const source = '// v1\n'
const dir = install({ opencodePluginSource: source }).overlayDirs.opencode as string
rmSync(dir, { recursive: true, force: true })
expect(install({ opencodePluginSource: source }).overlayDirs.opencode).toBe(dir)
expect(existsSync(join(dir, 'plugins', 'orca-opencode-status.js'))).toBe(true)
})
})
it('mirrors an explicitly-set config root so overriding the var does not drop it', () => {
withHome((home) => {
// Why: setting OPENCODE_CONFIG_DIR to the overlay removes the user's own value
// from OpenCode's config-dir list, so that one must be mirrored in.
const userConfig = join(home, 'my-opencode')
mkdirSync(userConfig, { recursive: true })
writeFileSync(join(userConfig, 'opencode.json'), '{"model":"user-set"}')
const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), {
HOME: home,
ORCA_OPENCODE_SOURCE_CONFIG_DIR: userConfig,
ORCA_WSL_HOOK_INSTANCE: 'inst1'
} as NodeJS.ProcessEnv)
const dir = install({ opencodePluginSource: '// v1\n' }).overlayDirs.opencode as string
expect(readFileSync(join(dir, 'opencode.json'), 'utf8')).toBe('{"model":"user-set"}')
expect(existsSync(join(dir, 'plugins', 'orca-opencode-status.js'))).toBe(true)
})
})
it('does not mirror the XDG default config root', () => {
withHome((home) => {
// Why: OpenCode APPENDS OPENCODE_CONFIG_DIR to its config-dir list rather than
// replacing it, so ~/.config/opencode is read anyway — mirroring it here would
// load the user's config and plugins twice.
const defaultConfig = join(home, '.config', 'opencode')
mkdirSync(defaultConfig, { recursive: true })
writeFileSync(join(defaultConfig, 'opencode.json'), '{"model":"default"}')
const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), {
HOME: home,
ORCA_WSL_HOOK_INSTANCE: 'inst1'
} as NodeJS.ProcessEnv)
const dir = install({ opencodePluginSource: '// v1\n' }).overlayDirs.opencode as string
expect(existsSync(join(dir, 'opencode.json'))).toBe(false)
expect(existsSync(join(dir, 'plugins', 'orca-opencode-status.js'))).toBe(true)
})
})
it('rejects a source that exceeds the byte cap before writing anything', () => {
withHome((home) => {
const overlay = new PluginOverlayManager({ homeDir: home })
const install = createInstallPluginsHandler(overlay, {
HOME: home
} as NodeJS.ProcessEnv)
const tooBig = 'a'.repeat(PLUGIN_SOURCE_MAX_BYTES + 1)
expect(() => install({ opencodePluginSource: tooBig })).toThrow(/byte cap/)
expect(overlay.hasOpenCodeSource()).toBe(false)
})
})
it('returns no overlay dir when no opencode source is provided', () => {
withHome((home) => {
const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), {
HOME: home
} as NodeJS.ProcessEnv)
const res = install({})
expect(res.installed.opencode).toBe(false)
expect(res.overlayDirs.opencode).toBeUndefined()
})
})
})

View File

@ -0,0 +1,89 @@
// Guest-side handler for AGENT_HOOK_INSTALL_PLUGINS_METHOD: caches the plugin
// source the Windows host ships over the wire and materializes OpenCode's
// config overlay inside the guest. Extracted from the relay entrypoint so it is
// unit-testable without binding the hook server. Scope is OpenCode only for
// now; the payload/response shape matches the SSH relay so Pi/OMP are additive.
import { existsSync } from 'node:fs'
import { getRelayOpenCodePluginPath, type PluginOverlayManager } from './plugin-overlay'
import { resolveOpenCodeSourceConfigDir } from './plugin-overlay-env'
import { assertPluginSourceUnderByteCap } from './plugin-source-limit'
import {
sanitizeWslHookInstanceKey,
WSL_HOOK_RELAY_INSTANCE_ENV
} from '../shared/wsl-hook-relay-contract'
export type InstallPluginsResult = {
installed: { opencode: boolean; pi: boolean; omp: boolean }
overlayDirs: { opencode?: string }
}
export type InstallPluginsHandler = (params: Record<string, unknown>) => InstallPluginsResult
// Why NOT to fall back to ~/.config/opencode here: OpenCode APPENDS
// OPENCODE_CONFIG_DIR to its config-dir list, it does not replace it — the
// XDG default is always read too. Mirroring the default into the overlay would
// load the user's config (and plugins) twice. Only an explicitly-set dir is
// mirrored, because that one leaves the list when we override the var.
export function createInstallPluginsHandler(
pluginOverlay: PluginOverlayManager,
env: NodeJS.ProcessEnv
): InstallPluginsHandler {
// Why: materializeOpenCode wipes and rebuilds the overlay, and the id here is
// instance-scoped (not pane-scoped as on SSH). The host re-ships on every
// reinstall — 60s after connect and again on later pane spawns — so
// re-materializing unconditionally would delete the config root out from
// under running agents and race panes spawning against the path the host just
// handed them. Rebuild only when the shipped source actually changed.
let materialized: { source: string; sourceDir: string | undefined; dir: string } | null = null
return (params) => {
const opencode = params.opencodePluginSource
const pi = params.piExtensionSource
const omp = params.ompExtensionSource
// Why: bound per-source bytes so a buggy/hostile host can't OOM the guest relay.
assertPluginSourceUnderByteCap('opencodePluginSource', opencode)
assertPluginSourceUnderByteCap('piExtensionSource', pi)
assertPluginSourceUnderByteCap('ompExtensionSource', omp)
pluginOverlay.setSources({
opencodePluginSource: typeof opencode === 'string' ? opencode : undefined,
piExtensionSource: typeof pi === 'string' ? pi : undefined,
ompExtensionSource: typeof omp === 'string' ? omp : undefined
})
let opencodeDir: string | undefined
if (pluginOverlay.hasOpenCodeSource()) {
// An omitted source leaves the manager's cache untouched, so it counts as unchanged.
const incoming = typeof opencode === 'string' ? opencode : null
// Explicit-only (see header). Constant in practice for a relay's lifetime, so
// keying the cache on it is defensive; the rc scan behind it is memoized.
const sourceDir = resolveOpenCodeSourceConfigDir(env as Record<string, string>, env.SHELL)
const cached = materialized
if (
cached &&
(incoming === null || incoming === cached.source) &&
sourceDir === cached.sourceDir &&
// Why: the dir surviving a failed rebuild proves nothing — the plugin does.
existsSync(getRelayOpenCodePluginPath(cached.dir))
) {
opencodeDir = cached.dir
} else {
const overlayId =
sanitizeWslHookInstanceKey(env[WSL_HOOK_RELAY_INSTANCE_ENV]) ?? 'wsl-opencode'
// Why: null on write failure — caller falls back to the guest's own config (no status), never crossing a Windows overlay into WSL.
opencodeDir = pluginOverlay.materializeOpenCode(overlayId, sourceDir) ?? undefined
materialized =
opencodeDir && incoming !== null
? { source: incoming, sourceDir, dir: opencodeDir }
: null
}
}
return {
installed: {
opencode: pluginOverlay.hasOpenCodeSource(),
pi: pluginOverlay.hasPiSource('pi'),
omp: pluginOverlay.hasPiSource('omp')
},
overlayDirs: opencodeDir ? { opencode: opencodeDir } : {}
}
}
}