mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
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/<drive> shims. * refactor(wsl): track WSL host environments beside their registry
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 : ''
|
||||
}
|
||||
|
||||
@@ -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<Record<string, string>>
|
||||
}): 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<Record<string, unknown>>
|
||||
/** Account routing env merged over the startup agent's own. */
|
||||
accountEnv?: Readonly<Record<string, string>>
|
||||
}): Promise<{
|
||||
worktreeId: string
|
||||
/** The new workspace's SSH connection; `null` is local. The executor carries it but nothing
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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<typeof resolveEnvironment>
|
||||
@@ -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<unknown>
|
||||
try {
|
||||
response = await callRuntimeEnvironment(
|
||||
getUserDataPath(),
|
||||
environment.id,
|
||||
args.method,
|
||||
args.params,
|
||||
accounts.params,
|
||||
args.timeoutMs,
|
||||
args.expectedEnvironmentPairingRevision,
|
||||
undefined,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<typeof DrivePath>()
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<GlobalSettings, 'agentCmdOverrides' | 'agentDefaultEnv'>
|
||||
@@ -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/<drive> 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)) {
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
: {}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string>
|
||||
pendingFirstAgentMessageRename?: boolean
|
||||
automationProvenance?: AutomationWorkspaceProvenance
|
||||
/**
|
||||
|
||||
@@ -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<string, string>
|
||||
launchPreferences?: AgentLaunchPreferences
|
||||
terminalKittyKeyboardProtocol?: boolean
|
||||
terminalColorQueryReplies?: TerminalOscColorQueryReplyColors
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Store, 'getSettings'>): 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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> = {}
|
||||
): 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> = {}
|
||||
): 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()
|
||||
})
|
||||
})
|
||||
@@ -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<ClaudeRuntimeAuthPreparation>
|
||||
/** The pointer-only env a non-Claude pane gets, so a typed `claude` still follows the account. */
|
||||
claudeTerminalEnv(target: WslAccountTarget): Record<string, string>
|
||||
/** The selected Codex account's home (a UNC or guest path); null on System default. */
|
||||
prepareCodexHome(target: WslAccountTarget): Promise<string | null>
|
||||
}
|
||||
|
||||
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<string, string | undefined>>): Record<string, string> {
|
||||
const picked: Record<string, string> = {}
|
||||
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<Record<string, string>> {
|
||||
const target: WslAccountTarget = { runtime: 'wsl', wslDistro: distro }
|
||||
const env: Record<string, string> = {}
|
||||
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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function stringRecord(value: unknown): Record<string, string> {
|
||||
const record: Record<string, string> = {}
|
||||
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<string, unknown>): 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<WslOrcadAccountEnvResult> {
|
||||
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<string, string>
|
||||
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 } } }
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<string, string>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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.'
|
||||
@@ -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<Record<string, string>>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user