From 41e6ea530a908b2b1f8f088d4434601e3b331ad4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:23:53 -0700 Subject: [PATCH] feat(wsl): accounts shim for agents on a WSL orcad host (WSL-via-orcad C9) (#27874) * feat(wsl): accounts shim for agents on a WSL orcad host (WSL-via-orcad C9) The desktop prepares the selected Claude/Codex account's in-distro home with the existing WSL account code and sends it as env on terminal.create / session.tabs.createTerminal, or as agent.launch's new optional accountEnv. Structured sessions keep their host-derived home and warn. Launch-time CLI resolution inside a WSL guest skips /mnt/ shims. * refactor(wsl): track WSL host environments beside their registry --- .../agent-launch-account-env.test.ts | 88 +++++++++ .../agent-launch/agent-launch-executor.ts | 39 +--- .../agent-launch-surface-factories.ts | 4 + .../agent-launch/agent-launch-warnings.ts | 35 ++++ .../claude-profile-wsl-router.test.ts | 9 + ...ntime-environment-connectivity-handlers.ts | 13 +- src/main/ipc/wsl-guest-windows-drive-path.ts | 13 ++ ...tructured-agent-command-guest-path.test.ts | 83 +++++++++ .../structured-agent-command-resolution.ts | 10 +- .../methods/agent-launch-intent-resolution.ts | 3 +- .../rpc/methods/agent-launch-surfaces.ts | 4 +- .../methods/agent-launch-worktree-creation.ts | 6 +- .../runtime/runtime-agent-terminal-startup.ts | 4 +- .../runtime-managed-worktree-create-types.ts | 2 + .../runtime/runtime-terminal-contracts.ts | 3 + .../runtime-worktree-agent-startup.test.ts | 18 ++ .../runtime/runtime-worktree-agent-startup.ts | 8 +- .../startup/main-process-runtime-service.ts | 2 + .../startup/wsl-orcad-account-env-wiring.ts | 28 +++ src/main/wsl/wsl-orcad-account-env.test.ts | 164 +++++++++++++++++ src/main/wsl/wsl-orcad-account-env.ts | 170 ++++++++++++++++++ src/main/wsl/wsl-orcad-connection.test.ts | 9 + src/main/wsl/wsl-orcad-connection.ts | 5 + src/main/wsl/wsl-orcad-host-environments.ts | 34 ++++ src/shared/agent-launch-account-env.ts | 62 +++++++ src/shared/agent-launch-intent.ts | 2 + .../agent-launch-params-account-env.test.ts | 55 ++++++ .../rpc-contract/agent-launch-params.ts | 9 +- 28 files changed, 840 insertions(+), 42 deletions(-) create mode 100644 src/main/agent-launch/agent-launch-account-env.test.ts create mode 100644 src/main/agent-launch/agent-launch-warnings.ts create mode 100644 src/main/native-chat/structured-agent-command-guest-path.test.ts create mode 100644 src/main/startup/wsl-orcad-account-env-wiring.ts create mode 100644 src/main/wsl/wsl-orcad-account-env.test.ts create mode 100644 src/main/wsl/wsl-orcad-account-env.ts create mode 100644 src/main/wsl/wsl-orcad-host-environments.ts create mode 100644 src/shared/agent-launch-account-env.ts create mode 100644 src/shared/rpc-contract/agent-launch-params-account-env.test.ts diff --git a/src/main/agent-launch/agent-launch-account-env.test.ts b/src/main/agent-launch/agent-launch-account-env.test.ts new file mode 100644 index 00000000000..3b305305699 --- /dev/null +++ b/src/main/agent-launch/agent-launch-account-env.test.ts @@ -0,0 +1,88 @@ +/** + * `agent.launch`'s `accountEnv` reaches only terminal agents; a structured launch keeps its + * host-derived home and says so. + */ +import { describe, expect, it, vi } from 'vitest' +import { executeAgentLaunch, type AgentLaunchExecution } from './agent-launch-executor' +import type { AgentLaunchIntent } from '../../shared/agent-launch-intent' +import { STRUCTURED_ACCOUNT_ENV_IGNORED_WARNING } from '../../shared/agent-launch-account-env' + +const ACCOUNT_ENV = { CLAUDE_CONFIG_DIR: '/home/u/.orca/claude/a', CODEX_HOME: '/home/u/.codex-b' } + +function harness(structured: boolean) { + const createWorktree = vi.fn(async (args: { startupAgent: string | undefined }) => ({ + worktreeId: 'wt-new', + connectionId: null, + startupTerminalHandle: args.startupAgent ? 'term_agent_first' : undefined + })) + const createStructuredSession = vi.fn(async () => ({ + sessionId: 'sess-1', + handle: 'handle_structured', + fence: 4 + })) + const createTerminalAgent = vi.fn(async () => ({ handle: 'term_1' })) + const runtime = { + getClientSettings: () => (structured ? { experimentalNativeChat: true } : null), + getStructuredAgentSessionCreateSupport: async () => ({ supported: true }) + } + return { + createWorktree, + createStructuredSession, + createTerminalAgent, + run: (intent: AgentLaunchIntent) => + executeAgentLaunch({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements the two runtime methods the executor reaches; a missing one throws. + runtime: runtime as unknown as AgentLaunchExecution['runtime'], + intent, + surfaces: { createStructuredSession, createTerminalAgent }, + workspaces: { createWorktree } + }) + } +} + +describe('agent.launch account env', () => { + it('passes the account env to a terminal agent in an existing workspace', async () => { + const h = harness(false) + const result = await h.run({ + agent: 'claude', + target: { kind: 'existing', worktree: 'wt-7' }, + accountEnv: ACCOUNT_ENV + }) + expect(h.createTerminalAgent).toHaveBeenCalledWith( + expect.objectContaining({ accountEnv: ACCOUNT_ENV }) + ) + expect(result.warning).toBeUndefined() + }) + + it('passes it to the startup terminal of a worktree the launch creates', async () => { + const h = harness(false) + await h.run({ + agent: 'codex', + target: { kind: 'create-worktree', create: { repo: 'id:repo-1', name: 'task' } }, + accountEnv: ACCOUNT_ENV + }) + expect(h.createWorktree).toHaveBeenCalledWith( + expect.objectContaining({ startupAgent: 'codex', accountEnv: ACCOUNT_ENV }) + ) + }) + + it('keeps a structured session on its host-derived home and warns', async () => { + const h = harness(true) + const result = await h.run({ + agent: 'claude', + target: { kind: 'existing', worktree: 'wt-7' }, + accountEnv: ACCOUNT_ENV + }) + expect(result.outcome.kind).toBe('structured') + expect(h.createStructuredSession).toHaveBeenCalledWith( + expect.not.objectContaining({ accountEnv: expect.anything() }) + ) + expect(result.warning).toBe(STRUCTURED_ACCOUNT_ENV_IGNORED_WARNING) + }) + + it('adds no warning to a structured launch that carried none', async () => { + const h = harness(true) + const result = await h.run({ agent: 'claude', target: { kind: 'existing', worktree: 'wt-7' } }) + expect(result.warning).toBeUndefined() + }) +}) diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 3ac195301c5..df525886c7f 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -35,6 +35,10 @@ import type { AgentLaunchTarget } from '../../shared/agent-launch-intent' import { withoutReservedAgentCreateFields } from '../../shared/agent-launch-intent' +import { + combineLaunchWarnings, + ignoredStructuredLaunchInputsWarning +} from './agent-launch-warnings' import { argvLaunchPrompt, deliverTerminalLaunchPrompt, @@ -307,26 +311,12 @@ async function createSurface( ...(session.tabId ? { tabId: session.tabId } : {}) }, structured: session, - ...ignoredStructuredAgentArgsWarning(intent) + ...ignoredStructuredLaunchInputsWarning(intent) } } return createTerminalSurface(execution, workspace) } -/** - * Structured chat uses saved Arguments, so a per-call override still needs a truthful warning. - */ -function ignoredStructuredAgentArgsWarning( - intent: AgentLaunchIntent -): { warning: string } | undefined { - return intent.agentArgs === undefined - ? undefined - : { - warning: - 'Started a structured chat session using saved agent Arguments; the per-launch argument override was ignored.' - } -} - /** What every route that builds a terminal agent passes on, so the startup terminal of a new * workspace and the terminal of an existing one start the same agent. */ function terminalLaunchInputs(intent: AgentLaunchIntent) { @@ -336,7 +326,8 @@ function terminalLaunchInputs(intent: AgentLaunchIntent) { ...(intent.agentArgs !== undefined ? { agentArgs: intent.agentArgs } : {}), ...(intent.cwd ? { cwd: intent.cwd } : {}), ...(intent.launchSource ? { launchSource: intent.launchSource } : {}), - ...(intent.paneKey ? { paneKey: intent.paneKey } : {}) + ...(intent.paneKey ? { paneKey: intent.paneKey } : {}), + ...(intent.accountEnv ? { accountEnv: intent.accountEnv } : {}) } } @@ -368,22 +359,6 @@ async function createTerminalSurface( } } -/** - * Two warnings, both true, neither droppable. - * - * Mirrors how the create combines its own failures — `appendFailure` in - * runtime-local-worktree-terminal-startup.ts — which append rather than replace. - */ -export function combineLaunchWarnings( - create: string | undefined, - surface: string | undefined -): string | undefined { - if (!create || !surface) { - return create ?? surface - } - return `${create} Also ${surface[0].toLowerCase()}${surface.slice(1)}` -} - function existingWorktreeId(target: AgentLaunchTarget): string { return target.kind === 'existing' ? target.worktree : '' } diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index 9166bba4fa8..09fcfbb6ac4 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -39,6 +39,8 @@ export type AgentLaunchSurfaceFactory = { paneKey?: string /** The tab's first view, derived on the host by the window's own rule. */ viewMode?: 'terminal' | 'chat' + /** Account routing env merged over the agent's own (`agent.launch`'s `accountEnv`). */ + accountEnv?: Readonly> }): Promise<{ handle: string /** The pane this create minted; a factory whose runtime reports none omits it, never invents. */ @@ -121,6 +123,8 @@ export type AgentLaunchWorkspaceFactory = { paneKey?: string /** The launch's session options, read as the startup terminal's model/effort/mode preferences. */ options?: Readonly> + /** Account routing env merged over the startup agent's own. */ + accountEnv?: Readonly> }): Promise<{ worktreeId: string /** The new workspace's SSH connection; `null` is local. The executor carries it but nothing diff --git a/src/main/agent-launch/agent-launch-warnings.ts b/src/main/agent-launch/agent-launch-warnings.ts new file mode 100644 index 00000000000..3eec3e2aa8d --- /dev/null +++ b/src/main/agent-launch/agent-launch-warnings.ts @@ -0,0 +1,35 @@ +import type { AgentLaunchIntent } from '../../shared/agent-launch-intent' +import { STRUCTURED_ACCOUNT_ENV_IGNORED_WARNING } from '../../shared/agent-launch-account-env' + +/** + * Two warnings, both true, neither droppable. + * + * Mirrors how the create combines its own failures — `appendFailure` in + * runtime-local-worktree-terminal-startup.ts — which append rather than replace. + */ +export function combineLaunchWarnings( + create: string | undefined, + surface: string | undefined +): string | undefined { + if (!create || !surface) { + return create ?? surface + } + return `${create} Also ${surface[0].toLowerCase()}${surface.slice(1)}` +} + +/** + * Structured chat uses saved Arguments and its host-derived home, so a per-call override of either + * still needs a truthful warning. + */ +export function ignoredStructuredLaunchInputsWarning( + intent: AgentLaunchIntent +): { warning: string } | undefined { + const warning = combineLaunchWarnings( + intent.agentArgs === undefined + ? undefined + : 'Started a structured chat session using saved agent Arguments; the per-launch argument override was ignored.', + // Why: a structured home stays host-derived (no client-chosen credential dirs). + intent.accountEnv ? STRUCTURED_ACCOUNT_ENV_IGNORED_WARNING : undefined + ) + return warning ? { warning } : undefined +} diff --git a/src/main/claude-accounts/claude-profile-wsl-router.test.ts b/src/main/claude-accounts/claude-profile-wsl-router.test.ts index be520cac3ee..3da53b6a4dc 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.test.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.test.ts @@ -147,6 +147,15 @@ describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { expect(f.setup.calls).toBe(3) }) + it('reads usage from the same guest folder a launch (local or WSL-host) runs in', async () => { + const f = fixture() + const launch = await f.router.prepareLaunch('Ubuntu') + const usage = await f.router.accountUsagePreparation('Ubuntu', 'a') + const poll = await f.router.preparation('Ubuntu') + expect(usage.wslLinuxConfigDir).toBe(launch.envPatch.CLAUDE_CONFIG_DIR) + expect(poll.wslLinuxConfigDir).toBe(launch.envPatch.CLAUDE_CONFIG_DIR) + }) + it('makes a launch redo a first setup that was cut off after its ownership gate', async () => { const f = fixture() mkdirSync(f.profileHome, { recursive: true }) diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index 7a3b88850a2..1d389cc3884 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -34,6 +34,7 @@ import { getRuntimeEnvironmentStatus } from './runtime-environment-transport-routing' import { publicRuntimeEnvironmentWithHostKey } from './runtime-environment-host-key' +import { withWslOrcadAccountEnv } from '../wsl/wsl-orcad-account-env' function manuallyDisconnectedResponse( environment: ReturnType @@ -224,13 +225,23 @@ function registerPassiveCallHandler(getUserDataPath: () => string): void { if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { return manuallyDisconnectedResponse(environment) } + // Why here: every renderer call to a paired host passes this one handler. + const accounts = await withWslOrcadAccountEnv(environment.id, args.method, args.params) + if (!accounts.ok) { + return { + id: args.method, + ok: false, + error: { code: 'account_preparation_failed', message: accounts.message }, + _meta: { runtimeId: environment.runtimeId } + } + } let response: RuntimeRpcResponse try { response = await callRuntimeEnvironment( getUserDataPath(), environment.id, args.method, - args.params, + accounts.params, args.timeoutMs, args.expectedEnvironmentPairingRevision, undefined, diff --git a/src/main/ipc/wsl-guest-windows-drive-path.ts b/src/main/ipc/wsl-guest-windows-drive-path.ts index 88144a4d4f1..9bd245e4c1d 100644 --- a/src/main/ipc/wsl-guest-windows-drive-path.ts +++ b/src/main/ipc/wsl-guest-windows-drive-path.ts @@ -88,3 +88,16 @@ export function readGuestWindowsDriveMounts(): readonly string[] { export function _resetGuestWindowsDriveMountsForTests(): void { cachedGuestMounts = null } + +/** `pathEnv` (default: this process's PATH) without Windows drive dirs on a WSL guest; as given + * elsewhere. For launch-time CLI resolution, which must pick the Linux CLI as detection did. */ +export function guestNativePathEnv( + pathEnv: string | null | undefined, + mounts: readonly string[] = readGuestWindowsDriveMounts() +): string | null | undefined { + if (mounts.length === 0) { + return pathEnv + } + const value = pathEnv ?? process.env.PATH ?? null + return value === null ? null : withoutWindowsDriveMountDirs(value, mounts) +} diff --git a/src/main/native-chat/structured-agent-command-guest-path.test.ts b/src/main/native-chat/structured-agent-command-guest-path.test.ts new file mode 100644 index 00000000000..e1b01cd4b90 --- /dev/null +++ b/src/main/native-chat/structured-agent-command-guest-path.test.ts @@ -0,0 +1,83 @@ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as DrivePath from '../ipc/wsl-guest-windows-drive-path' + +const guest = vi.hoisted((): { mounts: string[] } => ({ mounts: [] })) +vi.mock('../ipc/wsl-guest-windows-drive-path', async (original) => { + const actual = await original() + return { + ...actual, + guestNativePathEnv: (pathEnv: string | null | undefined) => + actual.guestNativePathEnv(pathEnv, guest.mounts) + } +}) + +import { guestNativePathEnv } from '../ipc/wsl-guest-windows-drive-path' +import { resolveStructuredAgentCommand } from './structured-agent-command-resolution' + +const scratch: string[] = [] +afterEach(() => { + guest.mounts = [] + vi.unstubAllEnvs() + for (const dir of scratch.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function binDir(name: string): string { + const directory = mkdtempSync(join(tmpdir(), `orca-${name}-`)) + scratch.push(directory) + for (const binary of ['codex', 'codex-custom']) { + writeFileSync(join(directory, binary), '#!/bin/sh\nexit 0\n') + chmodSync(join(directory, binary), 0o755) + } + return directory +} + +// Why posix-only: the guest is Linux; Windows resolution never consults drive mounts. +describe.skipIf(process.platform === 'win32')('launch-time CLI resolution in a WSL guest', () => { + it('skips a Windows shim on a drive mount for the Linux CLI behind it', () => { + const windowsDrive = binDir('drive') + const linuxBin = binDir('linux') + guest.mounts = [windowsDrive] + expect( + resolveStructuredAgentCommand('codex', {}, { pathEnv: `${windowsDrive}:${linuxBin}` }) + ).toBe(join(linuxBin, 'codex')) + }) + + it("filters this process's PATH when the caller gives none", () => { + const windowsDrive = binDir('drive') + const linuxBin = binDir('linux') + guest.mounts = [windowsDrive] + vi.stubEnv('PATH', `${windowsDrive}:${linuxBin}`) + expect(resolveStructuredAgentCommand('codex', {})).toBe(join(linuxBin, 'codex')) + }) + + it('filters the PATH a Command setting resolves through', () => { + const windowsDrive = binDir('drive') + const linuxBin = binDir('linux') + guest.mounts = [windowsDrive] + const settings = { + agentCmdOverrides: { codex: 'codex-custom' }, + agentDefaultEnv: { codex: { PATH: `${windowsDrive}:${linuxBin}` } } + } + expect(resolveStructuredAgentCommand('codex', settings)).toBe(join(linuxBin, 'codex-custom')) + }) + + it('keeps the first PATH match off a guest', () => { + const first = binDir('first') + const second = binDir('second') + expect(resolveStructuredAgentCommand('codex', {}, { pathEnv: `${first}:${second}` })).toBe( + join(first, 'codex') + ) + }) +}) + +describe('guestNativePathEnv', () => { + it('returns the PATH as given with no drive mounts', () => { + expect(guestNativePathEnv('/mnt/c/bin:/usr/bin')).toBe('/mnt/c/bin:/usr/bin') + expect(guestNativePathEnv(undefined)).toBeUndefined() + }) +}) diff --git a/src/main/native-chat/structured-agent-command-resolution.ts b/src/main/native-chat/structured-agent-command-resolution.ts index 828fd031760..456d7cf97ac 100644 --- a/src/main/native-chat/structured-agent-command-resolution.ts +++ b/src/main/native-chat/structured-agent-command-resolution.ts @@ -9,6 +9,7 @@ import { structuredAgentCommandToken } from '../../shared/tui-agent-launch-command-override' import { AgentSessionPreSpawnError } from './agent-session-wire/structured-agent-session-adapter' +import { guestNativePathEnv } from '../ipc/wsl-guest-windows-drive-path' export type StructuredAgentCommandSettings = Partial< Pick @@ -26,7 +27,7 @@ function resolveOverride( return token ? resolveExecutableCommand(token, { ...options, - pathEnv: options.pathEnv ?? overlay?.PATH ?? overlay?.Path + pathEnv: guestNativePathEnv(options.pathEnv ?? overlay?.PATH ?? overlay?.Path) }) : null } @@ -47,7 +48,12 @@ export function resolveStructuredAgentCommand( stock: { command?: string; resolve?: typeof resolveCliCommand } = {} ): string { if (!hasExplicitTuiLaunchCommand(settings, agent)) { - return (stock.resolve ?? resolveCliCommand)(stock.command ?? agent, options) + // Why: inside a WSL guest the inherited PATH ends in /mnt/ dirs holding Windows shims. + const pathEnv = guestNativePathEnv(options.pathEnv) + return (stock.resolve ?? resolveCliCommand)( + stock.command ?? agent, + pathEnv === options.pathEnv ? options : { ...options, pathEnv } + ) } const command = resolveOverride(agent, settings, options) if (!command || !spawnableOn(options.platform ?? process.platform, command)) { diff --git a/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts b/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts index be113c90c28..4362991f38f 100644 --- a/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts +++ b/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts @@ -54,7 +54,8 @@ async function agentLaunchIntent( ...(params.cwd ? { cwd: params.cwd } : {}), ...(params.launchSource ? { launchSource: params.launchSource } : {}), ...(paneKey ? { paneKey } : {}), - ...(params.sessionId ? { sessionId: params.sessionId } : {}) + ...(params.sessionId ? { sessionId: params.sessionId } : {}), + ...(params.accountEnv ? { accountEnv: params.accountEnv } : {}) } } diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts index 87ee01eec71..2b5f910070a 100644 --- a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts +++ b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts @@ -131,7 +131,8 @@ export function agentLaunchSurfaceFactory( launchSource, paneKey, options, - viewMode + viewMode, + accountEnv }) => { const launchPreferences = toAgentLaunchPreferences(options) let promptRodeLaunchCommand = false @@ -162,6 +163,7 @@ export function agentLaunchSurfaceFactory( ...(paneKey ? { ...paneIdentity(paneKey), requireFreshPane: true } : {}), ...(launchSource ? { launchSource } : {}), ...(viewMode ? { viewMode } : {}), + ...(accountEnv ? { accountEnv: { ...accountEnv } } : {}), ...(presentation && (!movesHostWindow || !hasEarlyTab || presentation === 'background') ? { presentation: movesHostWindow ? presentation : ('background' as const) } : {}), diff --git a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts index 586c79c7f37..f30fa28e944 100644 --- a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts @@ -18,7 +18,7 @@ import { resolveAutomationWorkspaceProvenance } from '../../../automations/workspace-provenance' import type { AgentLaunchWorkspaceFactory } from '../../../agent-launch/agent-launch-surface-factories' -import { combineLaunchWarnings } from '../../../agent-launch/agent-launch-executor' +import { combineLaunchWarnings } from '../../../agent-launch/agent-launch-warnings' import type { RpcContext } from '../core' import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-context' import { buildManagedWorktreeCreateArgs } from './worktree-create-args' @@ -48,7 +48,8 @@ export function agentLaunchWorkspaceFactory( cwd, launchSource, paneKey, - options + options, + accountEnv }) => { const startupLaunchPreferences = toAgentLaunchPreferences(options) let promptRodeLaunchCommand = false @@ -96,6 +97,7 @@ export function agentLaunchWorkspaceFactory( ...(launchSource ? { startupLaunchSource: launchSource } : {}), ...(paneKey ? { startupPaneKey: paneKey } : {}), ...(startupLaunchPreferences ? { startupLaunchPreferences } : {}), + ...(accountEnv ? { startupAccountEnv: { ...accountEnv } } : {}), // The launch owns the agent whichever surface it settles on, so the workspace records // it even when no startup terminal was created for it. createdWithAgent: agent, diff --git a/src/main/runtime/runtime-agent-terminal-startup.ts b/src/main/runtime/runtime-agent-terminal-startup.ts index 68932fc0058..27b16dcddfa 100644 --- a/src/main/runtime/runtime-agent-terminal-startup.ts +++ b/src/main/runtime/runtime-agent-terminal-startup.ts @@ -82,10 +82,12 @@ export async function buildRuntimeAgentTerminalStartupOptions( opts.onStartupPromptCarry?.(promptCarried) } + // Account routing rides over the plan's own env, as a local launch's auth patch does. + const env = opts.accountEnv ? { ...startupPlan.env, ...opts.accountEnv } : startupPlan.env return { ...opts, command: startupPlan.launchCommand, - ...(startupPlan.env ? { env: startupPlan.env } : {}), + ...(env ? { env } : {}), launchConfig: startupPlan.launchConfig, launchAgent: agent, startupCommandDelivery: startupPlan.startupCommandDelivery, diff --git a/src/main/runtime/runtime-managed-worktree-create-types.ts b/src/main/runtime/runtime-managed-worktree-create-types.ts index 0a9d241d8c7..c2d919ba4b3 100644 --- a/src/main/runtime/runtime-managed-worktree-create-types.ts +++ b/src/main/runtime/runtime-managed-worktree-create-types.ts @@ -65,6 +65,8 @@ export type RuntimeManagedWorktreeCreateArgs = { startupLaunchSource?: string /** A caller-minted `tabId:leafId` for the startup terminal's pane. */ startupPaneKey?: string + /** Account routing env merged over the startup agent's own env. */ + startupAccountEnv?: Record pendingFirstAgentMessageRename?: boolean automationProvenance?: AutomationWorkspaceProvenance /** diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 9b054451938..1e31d4e922c 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -65,6 +65,9 @@ export type TerminalCreateOptions = { * the runtime still builds, so overriding them does not take the launch away from it. */ agentArgs?: string | null + /** Account routing env merged over `startupAgent`'s plan env; an input to that plan, like + * `agentArgs`, so not part of `callerSuppliedLaunch`. */ + accountEnv?: Record launchPreferences?: AgentLaunchPreferences terminalKittyKeyboardProtocol?: boolean terminalColorQueryReplies?: TerminalOscColorQueryReplyColors diff --git a/src/main/runtime/runtime-worktree-agent-startup.test.ts b/src/main/runtime/runtime-worktree-agent-startup.test.ts index 1751b82a2a2..6ce7291e2f1 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.test.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.test.ts @@ -280,6 +280,24 @@ describe('buildWorktreeStartupForAgent extra agent args', () => { extraAgentArgs: '--effort high' }) }) + + it("merges a launch's account env over the startup plan's env", () => { + const build = vi.fn(() => ({ + agent: 'codex' as const, + startup: { command: 'codex', env: { CODEX_HOME: '/stale', KEEP: '1' } } + })) + const resolved = resolveWorktreeCreateAgentStartup( + { + repoSelector: 'repo-1', + name: 'Review', + startupAgent: 'codex', + startupAccountEnv: { CODEX_HOME: '/home/u/.codex-b' } + }, + build + ) + + expect(resolved?.startup.env).toEqual({ CODEX_HOME: '/home/u/.codex-b', KEEP: '1' }) + }) }) describe('resolveWorktreeStartupDraftAgent', () => { diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index 72b083b82a7..5b190a6e8ca 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -224,10 +224,16 @@ export function resolveWorktreeCreateAgentStartup( if (args.startup || !args.startupAgent) { return null } - return build(args.startupAgent, args.startupPrompt, args.startupLaunchPreferences, { + const built = build(args.startupAgent, args.startupPrompt, args.startupLaunchPreferences, { ...(args.startupAgentArgs !== undefined ? { agentArgs: args.startupAgentArgs } : {}), ...(args.startupExtraAgentArgs ? { extraAgentArgs: args.startupExtraAgentArgs } : {}), ...(args.startupLaunchSource ? { launchSource: args.startupLaunchSource } : {}), ...(args.onStartupPromptCarry ? { onPromptCarry: args.onStartupPromptCarry } : {}) }) + return args.startupAccountEnv + ? { + ...built, + startup: { ...built.startup, env: { ...built.startup.env, ...args.startupAccountEnv } } + } + : built } diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 3f5c9d6b929..6fa6ddb109e 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -26,6 +26,7 @@ import { getPreferredPairedRuntimeConnection } from '../../shared/runtime-enviro import { fingerprintOrchestrationPeer } from '../runtime/orchestration/environment-transport' import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' import { mainProcessState as state } from './main-process-state' +import { installWslOrcadAccountShim } from './wsl-orcad-account-env-wiring' import { codexStructuredLaunchHomeResolvers, prepareCodexRuntimeHomeForLaunch @@ -173,6 +174,7 @@ export function configureRuntimeServices(runtime: OrcaRuntimeService): void { ) runtime.setSkillCloudService(new SkillCloudService(app.getPath('userData'))) runtime.setAccountServices({ claudeAccounts, codexAccounts, rateLimits }) + installWslOrcadAccountShim(store) runtime.setCommitMessageAgentEnvironmentResolvers({ // Why: Codex hooks/auth live in Orca's managed runtime home even for the default path, so every launch must resolve CODEX_HOME via runtime-home. prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, diff --git a/src/main/startup/wsl-orcad-account-env-wiring.ts b/src/main/startup/wsl-orcad-account-env-wiring.ts new file mode 100644 index 00000000000..77c2b835e4b --- /dev/null +++ b/src/main/startup/wsl-orcad-account-env-wiring.ts @@ -0,0 +1,28 @@ +import { withClaudeProfileTerminalEnv } from '../claude-accounts/claude-profile-installed-router' +import { getSelectedCodexAccountIdForTarget } from '../codex-accounts/runtime-selection' +import type { Store } from '../persistence' +import { installWslOrcadAccountEnvDependencies } from '../wsl/wsl-orcad-account-env' +import { mainProcessState as state } from './main-process-state' + +/** The desktop's account services behind the WSL orcad accounts shim. */ +export function installWslOrcadAccountShim(store: Pick): void { + installWslOrcadAccountEnvDependencies({ + prepareClaudeLaunch: (target) => { + const auth = state.claudeRuntimeAuth + if (!auth) { + throw new Error('Claude account service is not initialized') + } + return auth.prepareForClaudeLaunch(target) + }, + claudeTerminalEnv: (target) => withClaudeProfileTerminalEnv({}, target), + prepareCodexHome: async (target) => { + const runtimeHome = state.codexRuntimeHome + if (!runtimeHome || !getSelectedCodexAccountIdForTarget(store.getSettings(), target)) { + return null + } + // Why not prepareCodexRuntimeHomeForLaunch: its hook install targets this desktop's relay, + // while agent status on a WSL host belongs to that host's orcad. + return runtimeHome.prepareForCodexLaunchAsync(target) + } + }) +} diff --git a/src/main/wsl/wsl-orcad-account-env.test.ts b/src/main/wsl/wsl-orcad-account-env.test.ts new file mode 100644 index 00000000000..f0850f038bc --- /dev/null +++ b/src/main/wsl/wsl-orcad-account-env.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth/runtime-auth-types' +import { + guestCodexHomePath, + prepareWslOrcadAccountEnv, + withWslOrcadAccountEnv, + type WslOrcadAccountEnvDependencies +} from './wsl-orcad-account-env' + +const POINTER = '~/.orca-claude-profile-pointer-test' +const CLAUDE_HOME = '/home/u/.orca/claude-accounts/acct-a/config' + +function claudeLaunch( + overrides: Partial = {} +): ClaudeRuntimeAuthPreparation { + return { + configDir: '\\\\wsl.localhost\\Ubuntu\\home\\u\\.orca\\claude-accounts\\acct-a\\config', + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: CLAUDE_HOME, + envPatch: { + ORCA_CLAUDE_PROFILE_POINTER: POINTER, + CLAUDE_CONFIG_DIR: CLAUDE_HOME, + ORCA_CLAUDE_INJECTED_CONFIG_DIR: CLAUDE_HOME + }, + provenance: 'profile:acct-a:wsl:Ubuntu', + ...overrides + } +} + +function dependencies( + overrides: Partial = {} +): WslOrcadAccountEnvDependencies { + return { + prepareClaudeLaunch: vi.fn(async () => claudeLaunch()), + claudeTerminalEnv: vi.fn(() => ({ ORCA_CLAUDE_PROFILE_POINTER: POINTER })), + prepareCodexHome: vi.fn(async () => '\\\\wsl.localhost\\Ubuntu\\home\\u\\.orca\\codex-b'), + ...overrides + } +} + +describe('prepareWslOrcadAccountEnv', () => { + it('gives a Claude launch the selected account folder and the Codex home', async () => { + const deps = dependencies() + await expect(prepareWslOrcadAccountEnv('Ubuntu', 'claude', deps)).resolves.toEqual({ + ORCA_CLAUDE_PROFILE_POINTER: POINTER, + CLAUDE_CONFIG_DIR: CLAUDE_HOME, + ORCA_CLAUDE_INJECTED_CONFIG_DIR: CLAUDE_HOME, + CODEX_HOME: '/home/u/.orca/codex-b' + }) + expect(deps.prepareClaudeLaunch).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('gives every other process only the Claude pointer, never waiting on Claude setup', async () => { + const deps = dependencies() + await expect(prepareWslOrcadAccountEnv('Ubuntu', 'codex', deps)).resolves.toEqual({ + ORCA_CLAUDE_PROFILE_POINTER: POINTER, + CODEX_HOME: '/home/u/.orca/codex-b' + }) + expect(deps.prepareClaudeLaunch).not.toHaveBeenCalled() + }) + + it('sends only the pointer for System default on both', async () => { + const deps = dependencies({ + prepareClaudeLaunch: async () => + claudeLaunch({ envPatch: { ORCA_CLAUDE_PROFILE_POINTER: POINTER } }), + prepareCodexHome: async () => null + }) + await expect(prepareWslOrcadAccountEnv('Ubuntu', 'claude', deps)).resolves.toEqual({ + ORCA_CLAUDE_PROFILE_POINTER: POINTER + }) + }) + + it("drops a preparation for another runtime or distro rather than send a host's path", async () => { + const deps = dependencies({ + prepareClaudeLaunch: async () => + claudeLaunch({ runtime: 'host', envPatch: { CLAUDE_CONFIG_DIR: '/Users/u/.claude-a' } }), + prepareCodexHome: async () => '\\\\wsl.localhost\\Debian\\home\\u\\.codex-b' + }) + await expect(prepareWslOrcadAccountEnv('Ubuntu', 'claude', deps)).resolves.toEqual({}) + }) + + it('refuses a home it cannot send', async () => { + const deps = dependencies({ claudeTerminalEnv: () => ({ ORCA_CLAUDE_PROFILE_POINTER: 'x' }) }) + await expect(prepareWslOrcadAccountEnv('Ubuntu', null, deps)).rejects.toThrow( + 'not a usable path' + ) + }) +}) + +describe('guestCodexHomePath', () => { + it('reads UNC and guest paths, case-insensitive on the distro', () => { + expect(guestCodexHomePath('\\\\wsl$\\ubuntu\\home\\u\\.codex', 'Ubuntu')).toBe('/home/u/.codex') + expect(guestCodexHomePath('/home/u//.codex', 'Ubuntu')).toBe('/home/u/.codex') + expect(guestCodexHomePath('C:\\Users\\u\\.codex', 'Ubuntu')).toBeNull() + }) +}) + +describe('withWslOrcadAccountEnv', () => { + const wsl = (id: string) => (id === 'wsl:ubuntu' ? 'Ubuntu' : null) + const run = (method: string, params: unknown, deps = dependencies(), id = 'wsl:ubuntu') => + withWslOrcadAccountEnv(id, method, params, deps, wsl) + + it('leaves calls to any other environment untouched', async () => { + const params = { worktree: 'id:wt' } + const deps = dependencies() + await expect(run('terminal.create', params, deps, 'ssh-1')).resolves.toEqual({ + ok: true, + params + }) + expect(deps.prepareCodexHome).not.toHaveBeenCalled() + }) + + it('adds the env to a plain shell and over a caller-built agent launch', async () => { + await expect(run('terminal.create', { worktree: 'id:wt' })).resolves.toMatchObject({ + params: { env: { ORCA_CLAUDE_PROFILE_POINTER: POINTER, CODEX_HOME: '/home/u/.orca/codex-b' } } + }) + const created = await run('session.tabs.createTerminal', { + worktree: 'id:wt', + command: 'claude', + launchAgent: 'claude', + env: { CLAUDE_CONFIG_DIR: '/stale', KEEP: '1' } + }) + expect(created).toMatchObject({ + params: { env: { KEEP: '1', CLAUDE_CONFIG_DIR: CLAUDE_HOME } } + }) + }) + + it('never turns a bare command or agent preset into a caller-built launch', async () => { + const bare = { worktree: 'id:wt', command: 'claude' } + await expect(run('terminal.create', bare)).resolves.toEqual({ ok: true, params: bare }) + const preset = { worktree: 'id:wt', agent: 'codex' } + await expect(run('session.tabs.createTerminal', preset)).resolves.toEqual({ + ok: true, + params: preset + }) + }) + + it('sets agent.launch accountEnv, except for a reused terminal', async () => { + const launch = { agent: 'claude', target: { kind: 'existing', worktree: 'id:wt' } } + await expect(run('agent.launch', launch)).resolves.toMatchObject({ + params: { accountEnv: { CLAUDE_CONFIG_DIR: CLAUDE_HOME } } + }) + const reused = { ...launch, reuseTerminal: { handle: 'term_1' } } + await expect(run('agent.launchReplay', reused)).resolves.toEqual({ ok: true, params: reused }) + }) + + it('refuses the call when the account cannot be prepared', async () => { + const deps = dependencies({ + prepareClaudeLaunch: async () => { + throw new Error('The selected Claude account could not be set up.') + } + }) + await expect( + run('agent.launch', { agent: 'claude', target: { kind: 'existing', worktree: 'x' } }, deps) + ).resolves.toEqual({ ok: false, message: 'The selected Claude account could not be set up.' }) + }) + + it('ignores methods that start no process', async () => { + const deps = dependencies() + await expect(run('terminal.list', {}, deps)).resolves.toEqual({ ok: true, params: {} }) + expect(deps.prepareCodexHome).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/wsl/wsl-orcad-account-env.ts b/src/main/wsl/wsl-orcad-account-env.ts new file mode 100644 index 00000000000..ca2abce49a5 --- /dev/null +++ b/src/main/wsl/wsl-orcad-account-env.ts @@ -0,0 +1,170 @@ +/** + * The accounts shim for a WSL distro's orcad (D10): orcad has no account services, so the desktop + * prepares the selected Claude/Codex account's in-distro home with the same WSL account code a + * local WSL pane uses, and sends the result as env on the calls that start a process there. + * + * Only calls whose env cannot change their meaning are patched: a create that already brings its + * own launch (env, launch config or launch agent), a plain shell, and `agent.launch`'s optional + * `accountEnv`. A bare `command` keeps the host's Settings-default launch untouched. + */ +import { posix } from 'node:path' +import { + AGENT_LAUNCH_ACCOUNT_ENV_KEYS, + AgentLaunchAccountEnv +} from '../../shared/agent-launch-account-env' +import { isTuiAgent } from '../../shared/tui-agent-config' +import type { TuiAgent } from '../../shared/tui-agent' +import { parseWslUncPath } from '../../shared/wsl-paths' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth/runtime-auth-types' +import { wslOrcadHostDistro } from './wsl-orcad-host-environments' + +export type WslAccountTarget = { runtime: 'wsl'; wslDistro: string } + +export type WslOrcadAccountEnvDependencies = { + /** `ClaudeRuntimeAuthService.prepareForClaudeLaunch`: waits for a first account setup. */ + prepareClaudeLaunch(target: WslAccountTarget): Promise + /** The pointer-only env a non-Claude pane gets, so a typed `claude` still follows the account. */ + claudeTerminalEnv(target: WslAccountTarget): Record + /** The selected Codex account's home (a UNC or guest path); null on System default. */ + prepareCodexHome(target: WslAccountTarget): Promise +} + +let installed: WslOrcadAccountEnvDependencies | null = null + +export function installWslOrcadAccountEnvDependencies( + dependencies: WslOrcadAccountEnvDependencies | null +): void { + installed = dependencies +} + +export class WslOrcadAccountEnvError extends Error { + constructor(message: string) { + super(message) + this.name = 'WslOrcadAccountEnvError' + } +} + +function sameDistro(a: string | null | undefined, b: string): boolean { + return a?.trim().toLowerCase() === b.trim().toLowerCase() +} + +function accountKeysOf(env: Readonly>): Record { + const picked: Record = {} + for (const key of AGENT_LAUNCH_ACCOUNT_ENV_KEYS) { + const value = env[key] + if (value) { + picked[key] = value + } + } + return picked +} + +/** The guest path of a Codex home; null when it belongs to another distro. */ +export function guestCodexHomePath(home: string, distro: string): string | null { + const unc = parseWslUncPath(home) + if (unc) { + return sameDistro(unc.distro, distro) ? unc.linuxPath : null + } + return home.startsWith('/') ? posix.normalize(home) : null +} + +/** The account env for one process started in `distro`; `agent` null is a plain shell. */ +export async function prepareWslOrcadAccountEnv( + distro: string, + agent: TuiAgent | null, + dependencies: WslOrcadAccountEnvDependencies +): Promise> { + const target: WslAccountTarget = { runtime: 'wsl', wslDistro: distro } + const env: Record = {} + if (agent === 'claude') { + const preparation = await dependencies.prepareClaudeLaunch(target) + // Why: off Windows the service routes a WSL target to the host, whose paths mean nothing here. + if (preparation.runtime === 'wsl' && sameDistro(preparation.wslDistro, distro)) { + Object.assign(env, accountKeysOf(preparation.envPatch)) + } + } else { + Object.assign(env, accountKeysOf(dependencies.claudeTerminalEnv(target))) + } + const codexHome = await dependencies.prepareCodexHome(target) + const guestHome = codexHome ? guestCodexHomePath(codexHome, distro) : null + if (guestHome) { + // Why not ORCA_CODEX_HOME too: orcad strips CODEX_HOME when it equals that marker. + env.CODEX_HOME = guestHome + } + const checked = AgentLaunchAccountEnv.safeParse(env) + if (!checked.success) { + throw new WslOrcadAccountEnvError('The selected account folder in WSL is not a usable path.') + } + return checked.data +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function stringRecord(value: unknown): Record { + const record: Record = {} + if (isRecord(value)) { + for (const [key, entry] of Object.entries(value)) { + if (typeof entry === 'string') { + record[key] = entry + } + } + } + return record +} + +function tuiAgentOf(value: unknown): TuiAgent | null { + return isTuiAgent(value) ? value : null +} + +type EnvPatchPlan = { field: 'env' | 'accountEnv'; agent: TuiAgent | null } + +/** Null leaves the call untouched. */ +function envPatchPlan(method: string, params: Record): EnvPatchPlan | null { + if (method === 'agent.launch' || method === 'agent.launchReplay') { + // A reused terminal starts no process, so it has no home to choose. + return params.reuseTerminal ? null : { field: 'accountEnv', agent: tuiAgentOf(params.agent) } + } + if (method !== 'terminal.create' && method !== 'session.tabs.createTerminal') { + return null + } + const launchAgent = tuiAgentOf(params.launchAgent) + const callerBuiltLaunch = + params.env !== undefined || params.launchConfig !== undefined || launchAgent !== null + const plainShell = params.command === undefined && params.agent === undefined + return callerBuiltLaunch || plainShell ? { field: 'env', agent: launchAgent } : null +} + +export type WslOrcadAccountEnvResult = + | { ok: true; params: unknown } + | { ok: false; message: string } + +/** `params` with the account env of the WSL distro behind `environmentId`; as is for any other. */ +export async function withWslOrcadAccountEnv( + environmentId: string, + method: string, + params: unknown, + dependencies: WslOrcadAccountEnvDependencies | null = installed, + distroOf: (environmentId: string) => string | null = wslOrcadHostDistro +): Promise { + const distro = distroOf(environmentId) + const plan = distro && dependencies && isRecord(params) ? envPatchPlan(method, params) : null + if (!plan || !distro || !dependencies || !isRecord(params)) { + return { ok: true, params } + } + let env: Record + try { + env = await prepareWslOrcadAccountEnv(distro, plan.agent, dependencies) + } catch (error) { + // Why refuse: starting without the prepared home would run another account. + return { ok: false, message: error instanceof Error ? error.message : String(error) } + } + if (Object.keys(env).length === 0) { + return { ok: true, params } + } + if (plan.field === 'accountEnv') { + return { ok: true, params: { ...params, accountEnv: env } } + } + return { ok: true, params: { ...params, env: { ...stringRecord(params.env), ...env } } } +} diff --git a/src/main/wsl/wsl-orcad-connection.test.ts b/src/main/wsl/wsl-orcad-connection.test.ts index da0a30e64c3..e2229e2ffb2 100644 --- a/src/main/wsl/wsl-orcad-connection.test.ts +++ b/src/main/wsl/wsl-orcad-connection.test.ts @@ -16,6 +16,7 @@ import { import type { WslOrcadLaunch } from './wsl-orcad-deploy' import { createWslOrcadHostTransport } from './wsl-orcad-host-transport' import { listenWslOrcadStdioBridge } from './wsl-orcad-stdio-bridge' +import { wslOrcadHostDistro } from './wsl-orcad-host-environments' let first: { server: Server; port: number } let second: { server: Server; port: number } @@ -291,4 +292,12 @@ describe('WslOrcadConnectionManager', () => { expect(onChange).toHaveBeenLastCalledWith('Ubuntu', null, { state: 'closed' }) await expect(dial(connection.localPort)).rejects.toThrow() }) + + it('lists its environment as a WSL host only while the connection is open', async () => { + const { manager } = setup() + const connection = await manager.ensure('Ubuntu') + expect(wslOrcadHostDistro(connection.environmentId)).toBe('Ubuntu') + await connection.close() + expect(wslOrcadHostDistro(connection.environmentId)).toBeNull() + }) }) diff --git a/src/main/wsl/wsl-orcad-connection.ts b/src/main/wsl/wsl-orcad-connection.ts index 627b1ec7c87..63002c55bff 100644 --- a/src/main/wsl/wsl-orcad-connection.ts +++ b/src/main/wsl/wsl-orcad-connection.ts @@ -26,6 +26,7 @@ import { } from './wsl-orcad-deploy' import type { WslOrcadHostTarget } from './wsl-orcad-host-target' import { createWslOrcadHostTransport } from './wsl-orcad-host-transport' +import { trackWslOrcadHostEnvironment } from './wsl-orcad-host-environments' import { listenWslOrcadStdioBridge, type WslOrcadStdioBridgeOptions @@ -166,6 +167,7 @@ export class WslOrcadConnectionManager { } this.active.delete(key) await entry.forward.close() + trackWslOrcadHostEnvironment(entry.connection, null) this.dependencies.onChange?.(entry.connection.distro, null, { state: 'closed' }) } @@ -221,6 +223,7 @@ export class WslOrcadConnectionManager { if (current) { this.active.delete(key) await current.forward.close() + trackWslOrcadHostEnvironment(current.connection, null) this.dependencies.onChange?.(distro, null, status.pending) } const forward = await this.listen(transport, { @@ -262,6 +265,7 @@ export class WslOrcadConnectionManager { } } this.active.set(key, entry) + trackWslOrcadHostEnvironment(entry.connection, entry.connection) this.dependencies.onChange?.(distro, entry.connection, status.ready) return entry.connection } catch (error) { @@ -287,6 +291,7 @@ export class WslOrcadConnectionManager { entry.connection = { ...entry.connection, pairingOffer, fullVersion, pid } this.active.set(key, entry) } + trackWslOrcadHostEnvironment(entry.connection, entry.connection) if (changed || announce) { this.dependencies.onChange?.(entry.connection.distro, entry.connection, announce ?? READY) } diff --git a/src/main/wsl/wsl-orcad-host-environments.ts b/src/main/wsl/wsl-orcad-host-environments.ts new file mode 100644 index 00000000000..2e6f953329d --- /dev/null +++ b/src/main/wsl/wsl-orcad-host-environments.ts @@ -0,0 +1,34 @@ +/** + * Which paired runtime environments are a WSL distro's orcad, so desktop-side shims (accounts) + * know the distro behind an environment id. Fed by `WslOrcadConnectionManager`: an environment is + * listed only while its connection is open. + */ +const distrosByEnvironment = new Map() + +export function registerWslOrcadHostEnvironment(environmentId: string, distro: string): void { + distrosByEnvironment.set(environmentId, distro) +} + +export function unregisterWslOrcadHostEnvironment(environmentId: string, distro: string): void { + if (distrosByEnvironment.get(environmentId) === distro) { + distrosByEnvironment.delete(environmentId) + } +} + +export function wslOrcadHostDistro(environmentId: string): string | null { + return distrosByEnvironment.get(environmentId) ?? null +} + +type WslOrcadHostEnvironmentRef = { environmentId: string; distro: string } + +/** Lists `next` while open; a closed connection (`next` null) unlists `previous`. */ +export function trackWslOrcadHostEnvironment( + previous: WslOrcadHostEnvironmentRef, + next: WslOrcadHostEnvironmentRef | null +): void { + if (next) { + registerWslOrcadHostEnvironment(next.environmentId, next.distro) + } else { + unregisterWslOrcadHostEnvironment(previous.environmentId, previous.distro) + } +} diff --git a/src/shared/agent-launch-account-env.ts b/src/shared/agent-launch-account-env.ts new file mode 100644 index 00000000000..e3d2b5478f2 --- /dev/null +++ b/src/shared/agent-launch-account-env.ts @@ -0,0 +1,62 @@ +/** + * The account routing a desktop prepared for a terminal agent on a host with no account services + * of its own (a WSL distro's orcad): which Claude/Codex home the agent's process starts with. + * + * Deliberately narrow. Only the four routing variables, only absolute POSIX paths (the pointer may + * be `~/`-relative, as the `claude` shell function expands it), and never read for a structured + * session, whose home stays host-derived. + */ + +import { z } from 'zod' +import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, + CLAUDE_PROFILE_POINTER_ENV +} from './claude-profile-routing' + +export const AGENT_LAUNCH_ACCOUNT_ENV_KEYS = [ + 'CLAUDE_CONFIG_DIR', + CLAUDE_INJECTED_CONFIG_DIR_ENV, + CLAUDE_PROFILE_POINTER_ENV, + 'CODEX_HOME' +] as const + +export type AgentLaunchAccountEnvKey = (typeof AGENT_LAUNCH_ACCOUNT_ENV_KEYS)[number] + +const MAX_ACCOUNT_PATH_LENGTH = 4096 + +function isAccountEnvKey(key: string): key is AgentLaunchAccountEnvKey { + return AGENT_LAUNCH_ACCOUNT_ENV_KEYS.some((allowed) => allowed === key) +} + +function isAccountPath(key: AgentLaunchAccountEnvKey, value: string): boolean { + if (value.length === 0 || value.length > MAX_ACCOUNT_PATH_LENGTH || /[\0\r\n]/.test(value)) { + return false + } + const relative = key === CLAUDE_PROFILE_POINTER_ENV && value.startsWith('~/') + if (!relative && !value.startsWith('/')) { + return false + } + return !value.split('/').includes('..') +} + +/** Why a refusal, not a drop: a dropped home would start the agent under another account. */ +export const AgentLaunchAccountEnv = z.record(z.string(), z.string()).superRefine((env, ctx) => { + for (const [key, value] of Object.entries(env)) { + if (!isAccountEnvKey(key)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: [key], message: 'Not an account variable' }) + } else if (!isAccountPath(key, value)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: [key], message: 'Invalid account path' }) + } + } + const injected = env[CLAUDE_INJECTED_CONFIG_DIR_ENV] + if (injected !== undefined && injected !== env.CLAUDE_CONFIG_DIR) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [CLAUDE_INJECTED_CONFIG_DIR_ENV], + message: 'Must name the injected CLAUDE_CONFIG_DIR' + }) + } +}) + +export const STRUCTURED_ACCOUNT_ENV_IGNORED_WARNING = + 'Account selection is not supported for this session type on WSL hosts yet; the chat uses the host default account.' diff --git a/src/shared/agent-launch-intent.ts b/src/shared/agent-launch-intent.ts index 0fbf3699562..902b0c8d4c5 100644 --- a/src/shared/agent-launch-intent.ts +++ b/src/shared/agent-launch-intent.ts @@ -98,6 +98,8 @@ export type AgentLaunchIntent = { /** The caller-minted id of the chat session a structured launch creates. Not a route input; * refused when that session already exists. */ sessionId?: string + /** Account routing env for a terminal agent (`agent.launch`'s `accountEnv`). Not a route input. */ + accountEnv?: Readonly> } /** diff --git a/src/shared/rpc-contract/agent-launch-params-account-env.test.ts b/src/shared/rpc-contract/agent-launch-params-account-env.test.ts new file mode 100644 index 00000000000..a58fd1b27b4 --- /dev/null +++ b/src/shared/rpc-contract/agent-launch-params-account-env.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { AgentLaunch, AgentLaunchFields } from './agent-launch-params' +import { computeAgentLaunchFingerprint } from '../agent-launch-operation' + +const BASE = { agent: 'claude', target: { kind: 'existing', worktree: 'id:wt-1' } } +const ACCOUNT_ENV = { + CLAUDE_CONFIG_DIR: '/home/u/.orca-claude/a/config', + ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/home/u/.orca-claude/a/config', + ORCA_CLAUDE_PROFILE_POINTER: '~/.orca-claude/pointer', + CODEX_HOME: '/home/u/.orca-codex/b' +} + +// What a host that predates the field parses with: the same object, without it. +const OldHostAgentLaunch = AgentLaunchFields.omit({ accountEnv: true }) + +describe('agent.launch accountEnv wire compatibility', () => { + it('carries a validated account env on a new host', () => { + const parsed = AgentLaunch.parse({ ...BASE, accountEnv: ACCOUNT_ENV }) + expect(parsed.accountEnv).toEqual(ACCOUNT_ENV) + }) + + it('is stripped, not refused, by an older host', () => { + const parsed = OldHostAgentLaunch.parse({ ...BASE, accountEnv: ACCOUNT_ENV }) + expect(parsed).not.toHaveProperty('accountEnv') + expect(parsed.agent).toBe('claude') + }) + + it('leaves an older client launch unchanged on a new host', () => { + const parsed = AgentLaunch.parse(BASE) + expect(parsed).not.toHaveProperty('accountEnv') + expect(parsed).toEqual(OldHostAgentLaunch.parse(BASE)) + }) + + it('stays outside the replay fingerprint', () => { + const withEnv = AgentLaunch.parse({ ...BASE, accountEnv: ACCOUNT_ENV }) + expect(computeAgentLaunchFingerprint(withEnv)).toBe( + computeAgentLaunchFingerprint(AgentLaunch.parse(BASE)) + ) + }) + + it.each([ + ['a non-account variable', { PATH: '/usr/bin' }], + ['a relative home', { CLAUDE_CONFIG_DIR: 'home/u/.claude' }], + ['a parent segment', { CODEX_HOME: '/home/u/../root/.codex' }], + ['a Windows path', { CODEX_HOME: 'C:\\Users\\u\\.codex' }], + ['a newline', { CODEX_HOME: '/home/u/.codex\nX=1' }], + ['a home-relative non-pointer', { CLAUDE_CONFIG_DIR: '~/.claude' }], + [ + 'an injected marker for another folder', + { CLAUDE_CONFIG_DIR: '/home/u/a', ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/home/u/b' } + ] + ])('refuses %s rather than starting another account', (_label, accountEnv) => { + expect(AgentLaunch.safeParse({ ...BASE, accountEnv }).success).toBe(false) + }) +}) diff --git a/src/shared/rpc-contract/agent-launch-params.ts b/src/shared/rpc-contract/agent-launch-params.ts index 641f2eb4eb1..02c7e5c66db 100644 --- a/src/shared/rpc-contract/agent-launch-params.ts +++ b/src/shared/rpc-contract/agent-launch-params.ts @@ -23,6 +23,7 @@ import { LaunchSourceParam } from './launch-source-param' import { TerminalTabIdParam } from './agent-session-params' import { SessionId } from './structured-agent-session-params' import { isStructuredAgentSessionIdFor } from '../structured-agent-session-create' +import { AgentLaunchAccountEnv } from '../agent-launch-account-env' const LaunchAgent = z .unknown() @@ -141,7 +142,13 @@ export const AgentLaunchFields = z.object({ .transform((value): 'focused' | 'background' | undefined => value === 'focused' || value === 'background' ? value : undefined ) - .optional() + .optional(), + /** + * The Claude/Codex home a desktop prepared for a host with no account services (WSL orcad). + * Terminal-only: a structured launch keeps its host-derived home and warns. Optional forever; an + * older host strips it and runs its default account. Outside the replay fingerprint. + */ + accountEnv: AgentLaunchAccountEnv.optional() }) /** A caller-minted session id must be shaped like every id the host mints, so an id still names