Files
orca/src/relay/wsl-install-plugins-handler.test.ts
OrcaWin e1eca7f311 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.
2026-07-25 02:30:59 -07:00

194 lines
8.5 KiB
TypeScript

// 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()
})
})
})