diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index 83332aefa44..27e5103e76a 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -25,6 +25,7 @@ import { RELAY_BUILD_PLATFORMS, RELAY_VERSION_FILENAME, RELAY_OPENCODE_SQLITE_READER_FILENAME, + WSL_CLAUDE_PROFILE_HELPER_FILENAME, relayOptionalArtifactFilenames, isWindowsRelayPlatform, relayArtifactFilenames @@ -331,6 +332,18 @@ for (const platform of RELAY_BUILD_PLATFORMS) { .slice(0, 12) writeFileSync(join(outDir, '.browser-network-version'), `${RELAY_VERSION}+${browserNetworkHash}`) console.log(`Built WSL browser network relay → ${outDir}/wsl-browser-network-relay.js`) + + // Why here, not the relay dirs: only the desktop runs it, inside WSL; SSH hosts never upload it. + await build({ + entryPoints: [join(ROOT, 'src/main/claude-accounts/claude-profile-wsl-entry.ts')], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile: join(outDir, WSL_CLAUDE_PROFILE_HELPER_FILENAME), + minify: true, + define: { 'process.env.NODE_ENV': '"production"' } + }) } console.log('Relay build complete.') diff --git a/src/main/agent-hooks/wsl-hook-relay-launch.ts b/src/main/agent-hooks/wsl-hook-relay-launch.ts index ae1ea9c20d7..c60f4364bf2 100644 --- a/src/main/agent-hooks/wsl-hook-relay-launch.ts +++ b/src/main/agent-hooks/wsl-hook-relay-launch.ts @@ -6,7 +6,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { getAppEnvironment } from '../../shared/app-environment' +import { relayBundleCandidates } from '../ssh/relay-bundle-paths' import type { MultiplexerTransport } from '../ssh/ssh-channel-multiplexer' import { @@ -33,24 +33,7 @@ const INSTALL_TIMEOUT_MS = 30_000 export type WslHookRelayBundle = { jsPath: string; version: string } export function resolveWslHookRelayBundle(): WslHookRelayBundle | null { - // Mirrors getLocalRelayCandidates in ssh-relay-deploy: env override for - // tests/dev, then packaged extraResources, then dev out/ paths. - const candidates: string[] = [] - if (process.env.ORCA_RELAY_PATH) { - candidates.push(join(process.env.ORCA_RELAY_PATH, 'wsl')) - } - if (process.resourcesPath) { - candidates.push(join(process.resourcesPath, 'relay', 'wsl')) - candidates.push(join(process.resourcesPath, 'app.asar.unpacked', 'out', 'relay', 'wsl')) - } - try { - const appPath = getAppEnvironment().getAppPath() - candidates.push(join(appPath, 'resources', 'relay', 'wsl')) - candidates.push(join(appPath, 'out', 'relay', 'wsl')) - } catch { - // app not ready in some test contexts — env/resources candidates suffice. - } - for (const dir of candidates) { + for (const dir of relayBundleCandidates('wsl')) { const jsPath = join(dir, WSL_HOOK_RELAY_BUNDLE_NAME) const versionPath = join(dir, WSL_HOOK_RELAY_VERSION_FILE) if (existsSync(jsPath) && existsSync(versionPath)) { diff --git a/src/main/browser/wsl-browser-network-relay-launch.ts b/src/main/browser/wsl-browser-network-relay-launch.ts index 8949f21e074..89e1c964c14 100644 --- a/src/main/browser/wsl-browser-network-relay-launch.ts +++ b/src/main/browser/wsl-browser-network-relay-launch.ts @@ -1,7 +1,7 @@ import { spawnProcess } from '../../shared/child-process/run-process' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { getAppEnvironment } from '../../shared/app-environment' +import { relayBundleCandidates } from '../ssh/relay-bundle-paths' import { WSL_BROWSER_NETWORK_RELAY_BUNDLE_NAME, WSL_BROWSER_NETWORK_RELAY_DIR, @@ -28,22 +28,7 @@ export type WslBrowserNetworkRelayChild = ReturnType & { type WslBrowserNetworkRelayBundle = { jsPath: string; version: string } export function resolveWslBrowserNetworkRelayBundle(): WslBrowserNetworkRelayBundle | null { - const candidates: string[] = [] - if (process.env.ORCA_RELAY_PATH) { - candidates.push(join(process.env.ORCA_RELAY_PATH, 'wsl')) - } - if (process.resourcesPath) { - candidates.push(join(process.resourcesPath, 'relay', 'wsl')) - candidates.push(join(process.resourcesPath, 'app.asar.unpacked', 'out', 'relay', 'wsl')) - } - try { - const appPath = getAppEnvironment().getAppPath() - candidates.push(join(appPath, 'resources', 'relay', 'wsl')) - candidates.push(join(appPath, 'out', 'relay', 'wsl')) - } catch { - // Tests, early startup and plain-Node hosts have no app path — env/resources candidates suffice. - } - for (const dir of candidates) { + for (const dir of relayBundleCandidates('wsl')) { const jsPath = join(dir, WSL_BROWSER_NETWORK_RELAY_BUNDLE_NAME) const versionPath = join(dir, WSL_BROWSER_NETWORK_RELAY_VERSION_FILE) if (!existsSync(jsPath) || !existsSync(versionPath)) { diff --git a/src/main/claude-accounts/claude-profile-installed-router.ts b/src/main/claude-accounts/claude-profile-installed-router.ts index 761305cefef..a0c04e3c9ba 100644 --- a/src/main/claude-accounts/claude-profile-installed-router.ts +++ b/src/main/claude-accounts/claude-profile-installed-router.ts @@ -2,7 +2,7 @@ import { lstatSync, readdirSync } from 'node:fs' import { join } from 'node:path' // Why type-only: scan workers import this module, and the router's setup graph must not load there. import type { ClaudeProfileRouter } from './claude-profile-router' -import type { CodexAccountSelectionTarget } from '../../shared/codex-selection-lane' +import type { ClaudeAccountSelectionTarget } from './runtime-selection' /** A real directory; a link is false, so a history folder shared by link is not read twice. */ export function isDirectory(path: string): boolean { @@ -35,13 +35,13 @@ export function getClaudeProfileRouter(): ClaudeProfileRouter | undefined { return installed } -/** A pane's env with the routed account's pointer added; SSH panes and WSL distros keep theirs. */ +/** A local or WSL pane's env with the routed account's pointer added; SSH panes keep theirs. */ export function withClaudeProfileTerminalEnv | undefined>( env: Env, connectionId: string | null | undefined, - target: CodexAccountSelectionTarget + target: ClaudeAccountSelectionTarget ): Env | Record { - const profileEnv = connectionId || target.runtime === 'wsl' ? undefined : installed?.terminalEnv() + const profileEnv = connectionId ? undefined : installed?.terminalEnv(target) return profileEnv ? { ...env, ...profileEnv } : env } diff --git a/src/main/claude-accounts/claude-profile-paths.ts b/src/main/claude-accounts/claude-profile-paths.ts index 67010c50ab3..74ee5222c8e 100644 --- a/src/main/claude-accounts/claude-profile-paths.ts +++ b/src/main/claude-accounts/claude-profile-paths.ts @@ -97,6 +97,13 @@ function readOwnershipMarker(file: string): string | null { * The only gate before writing into a profile: namespace, containment, no linked components, * outside Claude's default homes, and an ownership marker beside the home. Refuses before creating anything. */ +// Written by setup's ownership gate when setup starts, not when it completes: its absence means +// setup never started here, and its presence does not prove setup finished. +export function claudeProfileMarkerPath(profile: ClaudeProfileDescriptor): string { + const path = profile.target.runtime === 'wsl' ? hostPath.posix : hostPath + return path.join(path.dirname(profile.home), 'profile.json') +} + export function prepareClaudeProfileDirectory( dataRoot: string, profile: ClaudeProfileDescriptor, @@ -117,7 +124,7 @@ export function prepareClaudeProfileDirectory( } assertClaudeProfileDescendant(dataRoot, profile.home) assertOutsideDefaultClaudeHomes(profile.home, userHome, userConfigDir) - const markerPath = join(dirname(profile.home), 'profile.json') + const markerPath = claudeProfileMarkerPath(profile) const distro = profile.target.runtime === 'wsl' ? profile.target.distro : undefined const record = ownershipRecord(profile.version, profile.accountId, profile.target.runtime, distro) const marker = readOwnershipMarker(markerPath) diff --git a/src/main/claude-accounts/claude-profile-router.ts b/src/main/claude-accounts/claude-profile-router.ts index 0c8bb732c49..3b12f6f7c82 100644 --- a/src/main/claude-accounts/claude-profile-router.ts +++ b/src/main/claude-accounts/claude-profile-router.ts @@ -9,6 +9,7 @@ import { writeFileAtomically } from '../codex-accounts/fs-utils' import { resolveClaudeCommand } from '../codex-cli/command' import { CLAUDE_INJECTED_CONFIG_DIR_ENV, + claudeProfileMarkerPath, describeClaudeProfile, type ClaudeProfileDescriptor, readUserClaudeConfigDir, @@ -18,7 +19,11 @@ import type { ClaudeProfileSetupReport } from './claude-profile-setup' import { runClaudeProfileSetupInWorker } from './claude-profile-setup-worker' import type { ClaudeEnvPatch } from './environment' import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' -import { getSelectedClaudeAccountIdForTarget } from './runtime-selection' +import { + getSelectedClaudeAccountIdForTarget, + type ClaudeAccountSelectionTarget +} from './runtime-selection' +import { wslClaudeProfilePointer } from './claude-profile-wsl-paths' import { isDirectory, listClaudeProfileHomes } from './claude-profile-installed-router' export type ClaudeProfileRouterSettings = Pick< @@ -33,12 +38,6 @@ export type ClaudeProfileRouterSettings = Pick< export const CLAUDE_PROFILE_SETUP_FAILED_MESSAGE = 'The selected Claude account could not be set up. Try again or choose another account.' -// Written by setup's ownership gate when setup starts, not when it completes: its absence means -// setup never started here, and its presence does not prove setup finished. -function profileMarkerPath(profile: ClaudeProfileDescriptor): string { - return join(dirname(profile.home), 'profile.json') -} - export const CLAUDE_PROFILE_MISSING_MESSAGE = "The selected Claude account's folder is missing. Sign in to it again or choose another account." @@ -110,7 +109,7 @@ export class ClaudeProfileRouter { /** Waits for setup only for a folder that was never set up; otherwise launches at once. */ async prepareLaunch(): Promise { const profile = this.selectedProfile() - if (profile && isDirectory(profile.home) && !existsSync(profileMarkerPath(profile))) { + if (profile && isDirectory(profile.home) && !existsSync(claudeProfileMarkerPath(profile))) { const report = await this.setUp(profile).catch(() => null) if (report?.outcome !== 'prepared') { throw new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) @@ -158,7 +157,11 @@ export class ClaudeProfileRouter { } /** A pane's spawn env. Never throws, so a broken selection cannot stop a terminal opening. */ - terminalEnv(): ClaudeEnvPatch { + terminalEnv(target?: ClaudeAccountSelectionTarget): ClaudeEnvPatch { + // Why only the pointer: the guest's `claude` reads it, so a pane never waits on the guest. + if (target?.runtime === 'wsl') { + return { [CLAUDE_PROFILE_POINTER_ENV]: `~/${wslClaudeProfilePointer(this.args.dataRoot)}` } + } try { return this.launchEnv() } catch { diff --git a/src/main/claude-accounts/claude-profile-wsl-entry.ts b/src/main/claude-accounts/claude-profile-wsl-entry.ts new file mode 100644 index 00000000000..47629865c54 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-wsl-entry.ts @@ -0,0 +1,25 @@ +import { provisionClaudeAccountProfile } from './claude-profile-setup' +import { wslClaudeProfile } from './claude-profile-wsl-paths' + +// Runs inside a WSL distro on Orca's pinned Node: ` `. +// Hooks are not installed here: they reach the account through the settings merge from ~/.claude. +async function main(): Promise { + const [userHome = '', distro = '', accountId = ''] = process.argv.slice(2) + const { dataRoot, profile } = wslClaudeProfile(userHome, distro, accountId) + const report = await provisionClaudeAccountProfile({ + dataRoot, + profile, + userHome, + installHooks: null + }) + if (report.outcome !== 'prepared') { + console.error(JSON.stringify(report)) + process.exitCode = 2 + } else if (report.warnings.length > 0) { + process.stdout.write(JSON.stringify(report)) + } +} +void main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +}) diff --git a/src/main/claude-accounts/claude-profile-wsl-paths.ts b/src/main/claude-accounts/claude-profile-wsl-paths.ts new file mode 100644 index 00000000000..a5096c040ee --- /dev/null +++ b/src/main/claude-accounts/claude-profile-wsl-paths.ts @@ -0,0 +1,27 @@ +import { basename, posix } from 'node:path' +import { describeClaudeProfile, type ClaudeProfileDescriptor } from './claude-profile-paths' + +/** A WSL account folder in the guest: the one spelling setup, launch and sign-in use. */ +export function wslClaudeProfile( + guestHome: string, + distro: string, + accountId: string +): { dataRoot: string; profile: ClaudeProfileDescriptor } { + const dataRoot = posix.join(guestHome, '.local/share/orca') + const profile = describeClaudeProfile(dataRoot, accountId, { + executionHostId: 'local', + runtime: 'wsl', + distro + }) + return { dataRoot, profile } +} + +/** + * The guest's which-account file, relative to the guest home: a pane spawn cannot ask the guest + * for its home, so the pane value is `~/` plus this and the `claude` function expands it. Named + * after the host data folder so a dev build and the packaged app never share one. + */ +export function wslClaudeProfilePointer(hostDataRoot: string): string { + const build = basename(hostDataRoot).replace(/[^\w.-]/g, '_') + return `.local/share/orca/claude-profiles/selected-wsl-${build}` +} diff --git a/src/main/claude-accounts/claude-profile-wsl-router.test.ts b/src/main/claude-accounts/claude-profile-wsl-router.test.ts new file mode 100644 index 00000000000..decde2f9d35 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-wsl-router.test.ts @@ -0,0 +1,215 @@ +import { spawnSync } from 'node:child_process' +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { build } from 'esbuild' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' +import type { WslSpec } from '../wsl/wsl-runner' +import type * as WslPaths from '../../shared/wsl-paths' + +const guest = vi.hoisted(() => ({ home: '' })) +// The guest is this machine: its "UNC" paths are the Linux paths, and scripts run in /bin/sh. +vi.mock('../../shared/wsl-paths', async (original) => ({ + ...(await original()), + toWindowsWslPath: (linuxPath: string) => linuxPath +})) +vi.mock('../wsl', () => ({ + getWslHomeAsync: async (distro: string) => `\\\\wsl.localhost\\${distro}${guest.home}`, + listRunningWslDistrosAsync: async () => ['Ubuntu'] +})) +vi.mock('../wsl/wsl-runner', () => ({ + runWslProcess: async (spec: WslSpec) => { + const result = spawnSync('/bin/sh', ['-c', spec.script ?? '', 'sh', ...(spec.args ?? [])], { + encoding: 'utf8' + }) + return { code: result.status, stdout: result.stdout, stderr: result.stderr, timedOut: false } + } +})) + +import { + CLAUDE_PROFILE_MISSING_MESSAGE, + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, + type ClaudeProfileRouterSettings +} from './claude-profile-router' +import { ClaudeWslProfileRouter } from './claude-profile-wsl-router' + +// Why skipped on Windows: the guest is Linux; these run its scripts and Node bundle as the guest. +const posixHost = process.platform !== 'win32' +const roots: string[] = [] +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'claude-wsl-router-')) + roots.push(root) + guest.home = join(root, 'home') + mkdirSync(join(guest.home, '.claude'), { recursive: true }) + const account = (id: string): ClaudeManagedAccount => ({ + id, + email: `${id}@example.test`, + authMethod: 'subscription-oauth', + managedAuthPath: '/unused-legacy', + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + }) + const wsl: Record = { Ubuntu: 'a' } + const settings: ClaudeProfileRouterSettings = { + claudeManagedAccounts: [account('a')], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl }, + agentStatusHooksEnabled: false, + disabledTuiAgents: [] + } + const setup = { calls: 0, fail: false, gate: Promise.resolve() } + const router = new ClaudeWslProfileRouter({ + getSettings: () => settings, + dataRoot: join(root, 'orca-dev'), + runSetup: async () => { + setup.calls += 1 + await setup.gate + if (setup.fail) { + throw new Error('refused') + } + writeFileSync(join(profileHome, '..', 'profile.json'), '{}') + } + }) + const profileHome = join(guest.home, '.local/share/orca/claude-profiles/a/home') + const pointer = join(guest.home, '.local/share/orca/claude-profiles/selected-wsl-orca-dev') + return { settings, wsl, setup, router, profileHome, pointer, account } +} + +describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { + it('writes the guest pointer per build, sets up only a signed-in folder, and removes it with the last account', async () => { + const f = fixture() + await f.router.publish('Ubuntu') + expect(readFileSync(f.pointer, 'utf8')).toBe(f.profileHome) + expect(f.setup.calls).toBe(0) + + mkdirSync(f.profileHome, { recursive: true }) + await f.router.publish('Ubuntu') + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + + f.wsl.Ubuntu = null + await f.router.publish('Ubuntu') + expect(readFileSync(f.pointer, 'utf8')).toBe('') + + f.settings.claudeManagedAccounts = [] + await f.router.publish('Ubuntu') + expect(existsSync(f.pointer)).toBe(false) + }) + + it('refuses a missing folder, waits for a never-set-up one, then launches at once', async () => { + const f = fixture() + await expect(f.router.prepareLaunch('Ubuntu')).rejects.toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) + + mkdirSync(f.profileHome, { recursive: true }) + f.setup.fail = true + await expect(f.router.prepareLaunch('Ubuntu')).rejects.toThrow( + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE + ) + f.setup.fail = false + const prepared = await f.router.prepareLaunch('Ubuntu') + expect(prepared).toMatchObject({ + configDir: f.profileHome, + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: f.profileHome, + envPatch: { + ORCA_CLAUDE_PROFILE_POINTER: '~/.local/share/orca/claude-profiles/selected-wsl-orca-dev', + CLAUDE_CONFIG_DIR: f.profileHome, + ORCA_CLAUDE_INJECTED_CONFIG_DIR: f.profileHome + } + }) + await f.router.prepareLaunch('Ubuntu') + expect(f.setup.calls).toBe(2) + }) + + it('writes a missing guest pointer before a launch returns', async () => { + const f = fixture() + mkdirSync(f.profileHome, { recursive: true }) + writeFileSync(join(f.profileHome, '..', 'profile.json'), '{}') + expect(existsSync(f.pointer)).toBe(false) + await f.router.prepareLaunch('Ubuntu') + expect(readFileSync(f.pointer, 'utf8')).toBe(f.profileHome) + }) + + it('a launch waiting on setup leaves a selection made meanwhile in the pointer', async () => { + const f = fixture() + mkdirSync(f.profileHome, { recursive: true }) + let release = () => {} + f.setup.gate = new Promise((resolve) => (release = resolve)) + const launch = f.router.prepareLaunch('Ubuntu') + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + + f.settings.claudeManagedAccounts = [f.account('a'), f.account('b')] + f.wsl.Ubuntu = 'b' + await f.router.publish('Ubuntu') + const homeB = join(f.profileHome, '../../b/home') + expect(readFileSync(f.pointer, 'utf8')).toBe(homeB) + release() + await launch + expect(readFileSync(f.pointer, 'utf8')).toBe(homeB) + }) + + it('overwrites a guest pointer that names another account before a launch returns', async () => { + const f = fixture() + mkdirSync(f.profileHome, { recursive: true }) + writeFileSync(join(f.profileHome, '..', 'profile.json'), '{}') + mkdirSync(join(f.pointer, '..'), { recursive: true }) + writeFileSync(f.pointer, join(f.profileHome, '../../b/home')) + await f.router.prepareLaunch('Ubuntu') + expect(readFileSync(f.pointer, 'utf8')).toBe(f.profileHome) + }) + + it('launches System default from the guest ~/.claude with no account env', async () => { + const f = fixture() + f.wsl.Ubuntu = null + const prepared = await f.router.preparation('Ubuntu') + expect(prepared.wslLinuxConfigDir).toBe(join(guest.home, '.claude')) + expect(prepared.envPatch).toEqual({ + ORCA_CLAUDE_PROFILE_POINTER: '~/.local/share/orca/claude-profiles/selected-wsl-orca-dev' + }) + expect(await f.router.runningDistros()).toEqual(['Ubuntu']) + }) +}) + +it.skipIf(!posixHost)( + 'the guest helper runs Step 1 setup as a standalone Linux Node bundle', + async () => { + const root = mkdtempSync(join(tmpdir(), 'claude-wsl-helper-')) + roots.push(root) + const home = join(root, 'home') + mkdirSync(join(home, '.claude', 'projects'), { recursive: true }) + const profileHome = join(home, '.local/share/orca/claude-profiles/a/home') + mkdirSync(profileHome, { recursive: true }) + const helper = join(root, 'claude-profile-wsl.cjs') + await build({ + entryPoints: [resolve('src/main/claude-accounts/claude-profile-wsl-entry.ts')], + outfile: helper, + bundle: true, + platform: 'node', + format: 'cjs', + external: ['electron'], + logLevel: 'silent' + }) + const run = (accountId: string) => + spawnSync(process.execPath, [helper, home, 'Ubuntu', accountId], { encoding: 'utf8' }) + + expect(run('a').status).toBe(0) + expect(existsSync(join(profileHome, '..', 'profile.json'))).toBe(true) + // History is a Linux link into the guest's own ~/.claude. + expect(lstatSync(join(profileHome, 'projects')).isSymbolicLink()).toBe(true) + expect(run('../escape').status).not.toBe(0) + } +) diff --git a/src/main/claude-accounts/claude-profile-wsl-router.ts b/src/main/claude-accounts/claude-profile-wsl-router.ts new file mode 100644 index 00000000000..0fba0eb59a6 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-wsl-router.ts @@ -0,0 +1,234 @@ +import { existsSync } from 'node:fs' +import { lstat, readFile } from 'node:fs/promises' +import { join, posix } from 'node:path' +import { getAppEnvironment } from '../../shared/app-environment' +import { CLAUDE_PROFILE_POINTER_ENV } from '../../shared/claude-profile-routing' +import { WSL_CLAUDE_PROFILE_HELPER_FILENAME } from '../../shared/relay-artifacts' +import { parseWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' +import { getWslHomeAsync, listRunningWslDistrosAsync } from '../wsl' +import { ensureWslPinnedRuntime } from '../wsl/wsl-pinned-runtime' +import { relayBundleCandidates } from '../ssh/relay-bundle-paths' +import { runWslProcess, type WslSpec } from '../wsl/wsl-runner' +import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, + claudeProfileMarkerPath, + type ClaudeProfileDescriptor +} from './claude-profile-paths' +import { + CLAUDE_PROFILE_MISSING_MESSAGE, + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, + type ClaudeProfileRouterSettings +} from './claude-profile-router' +import { wslClaudeProfile, wslClaudeProfilePointer } from './claude-profile-wsl-paths' +import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' +import { getClaudeWslSelectionKey, getSelectedClaudeAccountIdForTarget } from './runtime-selection' + +type WslSetup = (distro: string, guestHome: string, accountId: string) => Promise + +/** + * The host router's rules for one WSL distro: account folders, setup and the which-account file + * all live in the guest, so links are Linux links and history shares within the guest. + */ +export class ClaudeWslProfileRouter { + private readonly setups = new Map>() + constructor( + private readonly args: { + getSettings: () => ClaudeProfileRouterSettings + /** The host's data folder; it names the guest pointer per Orca build. */ + dataRoot: string + /** Tests replace the guest helper. */ + runSetup?: WslSetup + } + ) {} + + private accountIn(distro: string): boolean { + const key = getClaudeWslSelectionKey(distro) + return this.args + .getSettings() + .claudeManagedAccounts.some( + (account) => + account.managedAuthRuntime === 'wsl' && + getClaudeWslSelectionKey(account.wslDistro) === key + ) + } + + /** Running distros that hold an Orca account; startup must not boot a stopped one. */ + async runningDistros(): Promise { + const running = await listRunningWslDistrosAsync({ requireConfirmed: true }) + return running.filter((distro) => this.accountIn(distro)) + } + + private async resolve(distro: string) { + const home = parseWslUncPath((await getWslHomeAsync(distro)) ?? '')?.linuxPath + if (!home?.startsWith('/')) { + throw new Error(`Could not read the home folder of WSL distro ${distro}.`) + } + return { home, profile: this.selectedProfile(home, distro) } + } + + private selectedProfile(home: string, distro: string): ClaudeProfileDescriptor | null { + const id = getSelectedClaudeAccountIdForTarget(this.args.getSettings(), { + runtime: 'wsl', + wslDistro: distro + }) + return id ? wslClaudeProfile(home, distro, id).profile : null + } + + private pointerIn(home: string): string { + return posix.join(home, wslClaudeProfilePointer(this.args.dataRoot)) + } + + /** Pointer first, then setup in the background, as on the host. No accounts here means no pointer. */ + async publish(distro: string): Promise { + const { home, profile } = await this.resolve(distro) + if (!this.accountIn(distro)) { + await runGuest(distro, { + script: 'rm -f -- "$1"', + args: [this.pointerIn(home)], + loginPath: 'none' + }) + return + } + await writePointer(distro, this.pointerIn(home), profile?.home ?? '') + // Why the existence check: setup creates the folder, and only sign-in may create an account. + if (profile && (await guestStat(distro, profile.home))?.isDirectory()) { + this.setUp(distro, home, profile.accountId).catch((error: unknown) => { + console.warn('[claude-profile] WSL account setup failed:', error) + }) + } + } + + /** Waits for setup only for a folder that was never set up; otherwise launches at once. */ + async prepareLaunch(distro: string): Promise { + const { home, profile } = await this.resolve(distro) + await this.assertPresent(distro, profile) + if (profile && !(await guestStat(distro, claudeProfileMarkerPath(profile)))?.isFile()) { + await this.setUp(distro, home, profile.accountId).catch((error: unknown) => { + console.warn('[claude-profile] WSL account setup failed:', error) + throw new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + }) + } + // Why: a missing or stale guest pointer would run the pane's `claude` under another account. + // Re-read the selection: one made during setup has already published its own pointer. + if (this.accountIn(distro)) { + const selected = this.selectedProfile(home, distro) + await writePointer(distro, this.pointerIn(home), selected?.home ?? '') + } + return this.preparationFor(distro, home, profile) + } + + async preparation(distro: string): Promise { + const { home, profile } = await this.resolve(distro) + await this.assertPresent(distro, profile) + return this.preparationFor(distro, home, profile) + } + + /** Falling back would run the wrong account. */ + private async assertPresent(distro: string, profile: ClaudeProfileDescriptor | null) { + if (profile && !(await guestStat(distro, profile.home))?.isDirectory()) { + throw new Error(CLAUDE_PROFILE_MISSING_MESSAGE) + } + } + + /** The shape main's WSL account path returns, so trust and rate limits need nothing new. */ + private preparationFor( + distro: string, + home: string, + profile: ClaudeProfileDescriptor | null + ): ClaudeRuntimeAuthPreparation { + const configHome = profile?.home ?? posix.join(home, '.claude') + return { + configDir: toWindowsWslPath(configHome, distro), + runtime: 'wsl', + wslDistro: distro, + wslLinuxConfigDir: configHome, + envPatch: { + [CLAUDE_PROFILE_POINTER_ENV]: `~/${wslClaudeProfilePointer(this.args.dataRoot)}`, + ...(profile + ? { CLAUDE_CONFIG_DIR: profile.home, [CLAUDE_INJECTED_CONFIG_DIR_ENV]: profile.home } + : {}) + }, + stripAuthEnv: true, + provenance: profile ? `profile:${profile.accountId}:wsl:${distro}` : `wsl:${distro}:system` + } + } + + /** One setup per account at a time; a later request reuses the running one. */ + private setUp(distro: string, home: string, accountId: string): Promise { + const running = this.setups.get(accountId) + if (running) { + return running + } + const run = (this.args.runSetup ?? runWslSetup)(distro, home, accountId).finally(() => + this.setups.delete(accountId) + ) + this.setups.set(accountId, run) + return run + } +} + +// Why over the distro's share: a launch must not wait on a guest process for two stats. +async function guestStat(distro: string, linuxPath: string) { + return lstat(toWindowsWslPath(linuxPath, distro)).catch(() => null) +} + +/** Writes in the guest only when the file differs, so a launch reads it over the share instead. */ +async function writePointer(distro: string, pointer: string, home: string): Promise { + // Why the timeout: a hung share must not stall startup's serialized publish; the guest write decides. + const current = await Promise.race([ + readFile(toWindowsWslPath(pointer, distro), 'utf8').catch(() => null), + new Promise((resolve) => setTimeout(resolve, 2_000, null).unref()) + ]) + if (current === home) { + return + } + await runGuest(distro, { + script: + 'umask 077; mkdir -p -- "${1%/*}" && printf %s "$2" > "$1.tmp" && mv -f -- "$1.tmp" "$1"', + args: [pointer, home], + loginPath: 'none' + }) +} + +async function runGuest(distro: string, spec: WslSpec, timeoutMs = 15_000): Promise { + const result = await runWslProcess({ ...spec, distro, timeoutMs, maxOutputBytes: 256 * 1024 }) + if (result.code !== 0 || result.timedOut) { + throw new Error( + `WSL ${distro}: ${result.stderr.trim() || (result.timedOut ? 'timed out' : 'command failed')}` + ) + } + return result.stdout.trim() +} + +/** Step 1's setup, run as Linux inside the distro on Orca's pinned Node. */ +const runWslSetup: WslSetup = async (distro, home, accountId) => { + const helper = relayBundleCandidates('wsl') + .map((dir) => join(dir, WSL_CLAUDE_PROFILE_HELPER_FILENAME)) + .find(existsSync) + if (!helper) { + throw new Error('The bundled WSL Claude account helper is missing. Reinstall Orca.') + } + const run = (spec: WslSpec, timeoutMs?: number) => runGuest(distro, spec, timeoutMs) + const node = await ensureWslPinnedRuntime( + run, + join(getAppEnvironment().getPath('userData'), 'orcad-artifacts'), + AbortSignal.timeout(180_000) + ) + const guestHelper = await run({ + program: 'wslpath', + args: ['-a', '-u', helper], + loginPath: 'none' + }) + // Prints its report only when setup was incomplete; a refusal exits non-zero. + const report = await run( + { + program: '/usr/bin/env', + args: ['-u', 'NODE_OPTIONS', node, guestHelper, home, distro, accountId], + loginPath: 'none' + }, + 120_000 + ) + if (report) { + console.warn('[claude-profile] WSL account setup was incomplete:', report) + } +} diff --git a/src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts b/src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts index 31b4acc41bd..61c3323b632 100644 --- a/src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts +++ b/src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts @@ -15,6 +15,7 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' +import type * as ClaudeProfileRouting from '../../shared/claude-profile-routing' vi.mock('electron', () => createElectronMock()) @@ -272,4 +273,40 @@ describe('ClaudeRuntimeAuthService', () => { } } }) + + it('routes a WSL distro through its guest router, and a failed guest publish never fails a select', async () => { + setPlatform('win32') + const wslRouter = { + prepareLaunch: vi.fn(async (distro: string) => ({ runtime: 'wsl', wslDistro: distro })), + publish: vi.fn(async () => { + throw new Error('distro is gone') + }), + runningDistros: vi.fn(async () => []) + } + vi.doMock('../../shared/claude-profile-routing', async (original) => ({ + ...(await original()), + claudeProfileRoutingEnabled: () => true + })) + vi.doMock('./claude-profile-wsl-router', () => ({ + ClaudeWslProfileRouter: function ClaudeWslProfileRouter() { + return wslRouter + } + })) + try { + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const store = createStore(createSettings()) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service reads only getSettings/updateSettings, which the harness store implements. + const service = new ClaudeRuntimeAuthService(store as never) + await expect( + service.prepareForClaudeLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + ).resolves.toMatchObject({ wslDistro: 'Ubuntu' }) + await expect( + service.syncForCurrentSelection({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + ).resolves.toBeUndefined() + expect(wslRouter.publish).toHaveBeenCalledWith('Ubuntu') + } finally { + vi.doUnmock('../../shared/claude-profile-routing') + vi.doUnmock('./claude-profile-wsl-router') + } + }) }) diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 9f47d24c99a..adbbce30da0 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -1,6 +1,7 @@ import { getAppEnvironment } from '../../shared/app-environment' import { claudeProfileRoutingEnabled } from '../../shared/claude-profile-routing' import { ClaudeProfileRouter } from './claude-profile-router' +import { ClaudeWslProfileRouter } from './claude-profile-wsl-router' import { getClaudeProfileRouter, installClaudeProfileRouter @@ -15,21 +16,22 @@ import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-t export type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' -// Why host only: WSL keeps the legacy path until guest account folders exist (Step 3). function routerFor(target: ClaudeAccountSelectionTarget): ClaudeProfileRouter | undefined { return target.runtime === 'wsl' ? undefined : getClaudeProfileRouter() } export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { + private readonly wslRouter?: ClaudeWslProfileRouter + constructor(store: Store) { super(store) if (claudeProfileRoutingEnabled()) { - installClaudeProfileRouter( - new ClaudeProfileRouter({ - getSettings: () => store.getSettings(), - dataRoot: getAppEnvironment().getPath('userData') - }) - ) + const args = { + getSettings: () => store.getSettings(), + dataRoot: getAppEnvironment().getPath('userData') + } + installClaudeProfileRouter(new ClaudeProfileRouter(args)) + this.wslRouter = process.platform === 'win32' ? new ClaudeWslProfileRouter(args) : undefined } this.initializeLastSyncedState() void this.safeSyncForCurrentSelection() @@ -39,6 +41,10 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { target?: ClaudeAccountSelectionTarget ): Promise { const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() + const wsl = this.wslRouteFor(effectiveTarget) + if (wsl) { + return wsl.router.prepareLaunch(wsl.distro) + } const router = routerFor(effectiveTarget) if (router) { return router.prepareLaunch() @@ -51,6 +57,10 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { target?: ClaudeAccountSelectionTarget ): Promise { const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() + const wsl = this.wslRouteFor(effectiveTarget) + if (wsl) { + return wsl.router.preparation(wsl.distro) + } const router = routerFor(effectiveTarget) if (router) { return router.preparation() @@ -62,16 +72,50 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { async syncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise { await this.serializeMutation(async () => { const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() + const wsl = this.wslRouteFor(effectiveTarget) const router = routerFor(effectiveTarget) - await (router ? router.publish() : this.doSyncForCurrentSelection(effectiveTarget)) + if (wsl) { + await this.publishWsl(wsl.router, wsl.distro) + } else if (router) { + router.publish() + } else { + await this.doSyncForCurrentSelection(effectiveTarget) + } }) } + /** Null when the target is not a WSL distro routed by account folders. */ + private wslRouteFor( + target: ClaudeAccountSelectionTarget + ): { router: ClaudeWslProfileRouter; distro: string } | null { + const distro = + target.runtime === 'wsl' ? this.resolveWslDefaultTarget(target).wslDistro?.trim() : null + return this.wslRouter && distro ? { router: this.wslRouter, distro } : null + } + + // Why never thrown: a guest Orca cannot reach also cannot run a pane, and a deleted distro must + // not block removing its accounts. The next select, or a start while it runs, rewrites it. + private async publishWsl(router: ClaudeWslProfileRouter, distro: string): Promise { + await router.publish(distro).catch((error: unknown) => { + console.warn(`[claude-profile] Could not update the Claude account in WSL ${distro}:`, error) + }) + } + + /** Startup and rollback republish only running distros: neither may boot a stopped one. */ + private async publishRunningWslDistros(): Promise { + const router = this.wslRouter + if (router) { + const distros = await router.runningDistros() + await Promise.all(distros.map((distro) => this.publishWsl(router, distro))) + } + } + async forceMaterializeCurrentSelectionForRollback(): Promise { await this.serializeMutation(async () => { const router = getClaudeProfileRouter() if (router) { router.publish() + await this.publishRunningWslDistros() return } const settings = this.store.getSettings() @@ -103,7 +147,15 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { private async safeSyncForCurrentSelection(): Promise { try { const router = getClaudeProfileRouter() - await (router ? router.publish() : this.syncForCurrentSelection()) + if (!router) { + await this.syncForCurrentSelection() + return + } + // Why serialized: an account change during startup must not be overwritten by this older read. + await this.serializeMutation(async () => { + router.publish() + await this.publishRunningWslDistros() + }) } catch (error) { console.warn('[claude-runtime-auth] Failed to sync runtime auth state:', error) } diff --git a/src/main/daemon/pty-subprocess-wsl-launch.test.ts b/src/main/daemon/pty-subprocess-wsl-launch.test.ts index 067dc729aad..8c8922eafd1 100644 --- a/src/main/daemon/pty-subprocess-wsl-launch.test.ts +++ b/src/main/daemon/pty-subprocess-wsl-launch.test.ts @@ -415,6 +415,42 @@ describe('createPtySubprocess', () => { ) }) + it('imports the guest-relative Claude pointer and profile home verbatim into daemon WSL terminals', async () => { + spawnMock.mockReturnValue(mockPtyProcess()) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + const home = '/home/jin/.local/share/orca/claude-profiles/a/home' + try { + await createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', + env: { + CLAUDE_CONFIG_DIR: home, + ORCA_CLAUDE_INJECTED_CONFIG_DIR: home, + ORCA_CLAUDE_PROFILE_POINTER: '~/.local/share/orca/claude-profiles/selected-wsl-orca' + } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + const env = spawnMock.mock.calls.at(-1)?.[2].env + // Why no flag: /p or /u would translate a guest path as if it were a Windows one. + expect(env.WSLENV.split(':')).toEqual( + expect.arrayContaining([ + 'CLAUDE_CONFIG_DIR', + 'ORCA_CLAUDE_PROFILE_POINTER', + 'ORCA_CLAUDE_INJECTED_CONFIG_DIR' + ]) + ) + expect(env.ORCA_CLAUDE_PROFILE_POINTER).toBe( + '~/.local/share/orca/claude-profiles/selected-wsl-orca' + ) + }) + it('does not mark deleted Powerlevel10k wizard env for daemon WSL import', async () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess/shell-launch-plan.ts b/src/main/daemon/pty-subprocess/shell-launch-plan.ts index 073cdc867c7..e29fff5330a 100644 --- a/src/main/daemon/pty-subprocess/shell-launch-plan.ts +++ b/src/main/daemon/pty-subprocess/shell-launch-plan.ts @@ -169,6 +169,9 @@ export function createPtyShellLaunchPlan( if (env.CLAUDE_CONFIG_DIR) { addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) } + if (env.ORCA_CLAUDE_PROFILE_POINTER) { + addWslEnvKeys(env, ['ORCA_CLAUDE_PROFILE_POINTER', 'ORCA_CLAUDE_INJECTED_CONFIG_DIR']) + } if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV]) } diff --git a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts index a299327fc3f..cfbace932ab 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { BrowserWindow } from 'electron' import { getDefaultSettings } from '../../../../shared/constants' import { finishPtyShutdown } from '../provider/liveness' @@ -6,6 +6,8 @@ import { prepareRuntimePtySpawn } from './spawn-preflight' import { buildRuntimePtySpawnOptions } from './spawn-options' import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-state' import type { PtyRuntimeControllerDeps } from './controller-deps' +import { ClaudeProfileRouter } from '../../../claude-accounts/claude-profile-router' +import { installClaudeProfileRouter } from '../../../claude-accounts/claude-profile-installed-router' const HOST_DEFAULT_SHELL = 'powershell.exe' const hostPlatform = process.platform @@ -80,3 +82,37 @@ describe('runtime pty spawn preflight: requested shell on a local Windows host', await expect(resolveSpawnShell(undefined)).resolves.toBe(HOST_DEFAULT_SHELL) }) }) + +describe('runtime pty spawn preflight: Claude account routing in a WSL pane', () => { + afterEach(() => { + installClaudeProfileRouter(undefined) + Object.defineProperty(process, 'platform', { configurable: true, value: hostPlatform }) + }) + + it('gives a wsl.exe pane the guest-relative pointer without touching the guest', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const runSetup = vi.fn() + installClaudeProfileRouter( + new ClaudeProfileRouter({ + getSettings: () => getDefaultSettings('/tmp'), + dataRoot: '/data/orca', + runSetup + }) + ) + const args: RuntimePtySpawnArgs = { + cols: 120, + rows: 40, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\u', + shellOverride: 'wsl.exe', + env: { KEEP: '1' } + } + const ctx = createRuntimePtySpawnState(makeDeps(), args) + await expect(prepareRuntimePtySpawn(ctx)).resolves.toBeNull() + expect(ctx.codexSelectionTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(args.env).toEqual({ + KEEP: '1', + ORCA_CLAUDE_PROFILE_POINTER: '~/.local/share/orca/claude-profiles/selected-wsl-orca' + }) + expect(runSetup).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/providers/local-pty-provider-windows-shell-launch.test.ts b/src/main/providers/local-pty-provider-windows-shell-launch.test.ts index bc96cdb1d6a..0f985f56151 100644 --- a/src/main/providers/local-pty-provider-windows-shell-launch.test.ts +++ b/src/main/providers/local-pty-provider-windows-shell-launch.test.ts @@ -160,6 +160,34 @@ describe('LocalPtyProvider', () => { }) describe('spawn', () => { + it('passes the guest Claude pointer and injected-home marker through WSLENV', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + provider.configure({ + buildSpawnEnv: (_id, env) => ({ + ...env, + CLAUDE_CONFIG_DIR: '/home/fake/.local/share/orca/claude-profiles/a/home', + ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/home/fake/.local/share/orca/claude-profiles/a/home', + ORCA_CLAUDE_PROFILE_POINTER: '~/.local/share/orca/claude-profiles/selected-wsl-orca' + }) + }) + await provider.spawn({ + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\fake\\repo' + }) + const env = spawnMock.mock.calls.at(-1)?.[2].env + expect(env.WSLENV.split(':')).toEqual( + expect.arrayContaining([ + 'CLAUDE_CONFIG_DIR', + 'ORCA_CLAUDE_PROFILE_POINTER', + 'ORCA_CLAUDE_INJECTED_CONFIG_DIR' + ]) + ) + expect(env.ORCA_CLAUDE_PROFILE_POINTER).toBe( + '~/.local/share/orca/claude-profiles/selected-wsl-orca' + ) + }) + it('does not pass a Windows Codex home into WSL terminals', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) provider.configure({ diff --git a/src/main/providers/local-pty-windows-spawn-environment.ts b/src/main/providers/local-pty-windows-spawn-environment.ts index f7967d710f7..63ad18883dc 100644 --- a/src/main/providers/local-pty-windows-spawn-environment.ts +++ b/src/main/providers/local-pty-windows-spawn-environment.ts @@ -55,6 +55,9 @@ export function finalizeWindowsLocalPtySpawnEnvironment(args: { // Why: managed WSL Claude passes a Linux CLAUDE_CONFIG_DIR through wsl.exe; non-default vars need WSLENV import. addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) } + if (env.ORCA_CLAUDE_PROFILE_POINTER) { + addWslEnvKeys(env, ['ORCA_CLAUDE_PROFILE_POINTER', 'ORCA_CLAUDE_INJECTED_CONFIG_DIR']) + } if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { // Why: wsl.exe drops custom Windows env vars; the startup wrapper needs this imported inside WSL. addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV]) diff --git a/src/main/ssh/relay-bundle-paths.test.ts b/src/main/ssh/relay-bundle-paths.test.ts new file mode 100644 index 00000000000..cfa4a5d138d --- /dev/null +++ b/src/main/ssh/relay-bundle-paths.test.ts @@ -0,0 +1,37 @@ +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +vi.mock('../../shared/app-environment', () => ({ + getAppEnvironment: () => { + throw new Error('AppEnvironment not initialized') + } +})) +import { relayBundleCandidates } from './relay-bundle-paths' + +afterEach(() => vi.unstubAllEnvs()) + +it('lists the WSL guest bundle dirs in the order the WSL relays searched them', () => { + vi.stubEnv('ORCA_RELAY_PATH', join('/env', 'relay')) + const resources = Object.getOwnPropertyDescriptor(process, 'resourcesPath') + Object.defineProperty(process, 'resourcesPath', { value: '/res', configurable: true }) + try { + expect(relayBundleCandidates('wsl', '/app')).toEqual([ + join('/env', 'relay', 'wsl'), + join('/res', 'relay', 'wsl'), + join('/res', 'app.asar.unpacked', 'out', 'relay', 'wsl'), + join('/app', 'resources', 'relay', 'wsl'), + join('/app', 'out', 'relay', 'wsl') + ]) + // No app environment (tests, early startup): the env and resources dirs still resolve. + expect(relayBundleCandidates('wsl')).toEqual([ + join('/env', 'relay', 'wsl'), + join('/res', 'relay', 'wsl'), + join('/res', 'app.asar.unpacked', 'out', 'relay', 'wsl') + ]) + } finally { + if (resources) { + Object.defineProperty(process, 'resourcesPath', resources) + } else { + Reflect.deleteProperty(process, 'resourcesPath') + } + } +}) diff --git a/src/main/ssh/relay-bundle-paths.ts b/src/main/ssh/relay-bundle-paths.ts index d20dccb4cdb..d859c4df2c1 100644 --- a/src/main/ssh/relay-bundle-paths.ts +++ b/src/main/ssh/relay-bundle-paths.ts @@ -1,7 +1,21 @@ import { join } from 'node:path' +import { getAppEnvironment } from '../../shared/app-environment' import type { RelayPlatform } from './relay-protocol' -export function relayBundleCandidates(platform: RelayPlatform, appPath: string): string[] { +function currentAppPath(): string | undefined { + try { + return getAppEnvironment().getAppPath() + } catch { + // Tests, early startup and plain-Node hosts have no app path; env/resources candidates suffice. + return undefined + } +} + +/** `wsl` is the WSL-only guest bundle dir (`out/relay/wsl`), never uploaded to SSH hosts. */ +export function relayBundleCandidates( + platform: RelayPlatform | 'wsl', + appPath = currentAppPath() +): string[] { return [ ...new Set([ ...(process.env.ORCA_RELAY_PATH ? [join(process.env.ORCA_RELAY_PATH, platform)] : []), @@ -11,8 +25,9 @@ export function relayBundleCandidates(platform: RelayPlatform, appPath: string): join(process.resourcesPath, 'app.asar.unpacked', 'out', 'relay', platform) ] : []), - join(appPath, 'resources', 'relay', platform), - join(appPath, 'out', 'relay', platform) + ...(appPath + ? [join(appPath, 'resources', 'relay', platform), join(appPath, 'out', 'relay', platform)] + : []) ]) ] } diff --git a/src/main/wsl/wsl-pinned-runtime.test.ts b/src/main/wsl/wsl-pinned-runtime.test.ts new file mode 100644 index 00000000000..7a263f2b530 --- /dev/null +++ b/src/main/wsl/wsl-pinned-runtime.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi } from 'vitest' +import { ensureWslPinnedRuntime, type WslRuntimeCommand } from './wsl-pinned-runtime' +import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' +const mocks = vi.hoisted(() => ({ + download: vi.fn( + async (_target: string, _root: string, _options: { signal?: AbortSignal }) => + 'C:/cache/pinned.tar.gz' + ) +})) +vi.mock('../ssh/pinned-runtime-materializer', () => ({ + materializeNodeRuntimeArchive: mocks.download +})) +function runner(present = false, libc = 'glibc 2.31', promoted = 'ORCA_NODE_RUNTIME_READY') { + return vi.fn(async (spec) => { + if (spec.program === 'uname') { + return 'x86_64' + } + if (spec.program === 'wslpath') { + return '/mnt/c/cache/pinned.tar.gz' + } + if (spec.script?.startsWith('getconf')) { + return libc + } + if (spec.script?.startsWith('printf')) { + return '/home/fake' + } + if (spec.script?.includes('ORCA_NODE_RUNTIME_EXTRACT_FAILED')) { + return promoted + } + return present ? 'ORCA_NODE_RUNTIME_READY' : 'ORCA_NODE_RUNTIME_MISSING' + }) +} +describe('shared WSL pinned runtime', () => { + it('uses the existing materializer and verifies the pinned guest executable without a host Node prerequisite', async () => { + mocks.download.mockClear() + const run = runner() + const result = await ensureWslPinnedRuntime(run, '/fake/cache', new AbortController().signal) + expect(mocks.download).toHaveBeenCalledWith('linux-x64-glibc', '/fake/cache', expect.anything()) + expect(result).toContain(NODE_RUNTIME_ASSETS['linux-x64-glibc'].executableSha256) + const install = run.mock.calls.find(([spec]) => + spec.script?.includes('ORCA_NODE_RUNTIME_EXTRACT_FAILED') + )?.[0] + expect(install?.args).toEqual(['/mnt/c/cache/pinned.tar.gz']) + expect(install?.script).toContain('--version') + expect(run.mock.calls.some(([spec]) => spec.program === 'node')).toBe(false) + }) + it('reuses a verified old guest cache without downloading or replacing it', async () => { + mocks.download.mockClear() + const run = runner(true) + await ensureWslPinnedRuntime(run, '/fake/cache', new AbortController().signal) + expect(mocks.download).not.toHaveBeenCalled() + expect( + run.mock.calls.some(([spec]) => spec.script?.includes('ORCA_NODE_RUNTIME_EXTRACT_FAILED')) + ).toBe(false) + expect(run.mock.calls.some(([spec]) => spec.script?.includes('sha256sum'))).toBe(true) + }) + it('refuses download and guest verification failures without a system-node fallback', async () => { + mocks.download.mockRejectedValueOnce(new Error('offline')) + await expect( + ensureWslPinnedRuntime(runner(), '/fake/cache', new AbortController().signal) + ).rejects.toThrow('offline') + const run = runner() + run.mockImplementation(async (spec) => { + if (spec.program === 'uname') { + return 'x86_64' + } + if (spec.script?.startsWith('getconf')) { + return 'glibc 2.31' + } + if (spec.script?.startsWith('printf')) { + return '/home/fake' + } + return 'broken runtime' + }) + await expect( + ensureWslPinnedRuntime(run, '/fake/cache', new AbortController().signal) + ).rejects.toThrow('did not verify') + }) + it('refuses a distro below the pinned glibc floor before downloading, naming both versions', async () => { + mocks.download.mockClear() + const run = runner(false, 'glibc 2.27') + await expect( + ensureWslPinnedRuntime(run, '/fake/cache', new AbortController().signal) + ).rejects.toThrow('glibc 2.27 is older than 2.28') + expect(mocks.download).not.toHaveBeenCalled() + expect( + run.mock.calls.some(([spec]) => spec.script?.includes('ORCA_NODE_RUNTIME_EXTRACT_FAILED')) + ).toBe(false) + await expect( + ensureWslPinnedRuntime( + runner(false, 'musl libc (x86_64)'), + '/fake/cache', + new AbortController().signal + ) + ).resolves.toContain('/home/fake/.cache/orca') + }) + it('gives a shared download its own deadline and lets each caller leave on its own signal', async () => { + const archive = Promise.withResolvers() + let downloadSignal: AbortSignal | undefined + mocks.download.mockReset().mockImplementation((_target, _root, options) => { + downloadSignal = options.signal + return archive.promise + }) + const first = new AbortController() + const second = new AbortController() + const a = ensureWslPinnedRuntime(runner(), '/fake/cache', first.signal) + const b = ensureWslPinnedRuntime(runner(), '/fake/cache', second.signal) + await vi.waitFor(() => expect(mocks.download).toHaveBeenCalledTimes(1)) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(downloadSignal).not.toBe(first.signal) + first.abort(new Error('first caller deadline')) + await expect(a).rejects.toThrow('first caller deadline') + expect(downloadSignal?.aborted).toBe(false) + archive.resolve('C:/cache/pinned.tar.gz') + await expect(b).resolves.toContain('/home/fake/.cache/orca') + mocks.download.mockReset().mockImplementation(async () => 'C:/cache/pinned.tar.gz') + }) +}) diff --git a/src/main/wsl/wsl-pinned-runtime.ts b/src/main/wsl/wsl-pinned-runtime.ts new file mode 100644 index 00000000000..29fb5305b94 --- /dev/null +++ b/src/main/wsl/wsl-pinned-runtime.ts @@ -0,0 +1,96 @@ +import { randomBytes } from 'node:crypto' +import { basename } from 'node:path' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { materializeNodeRuntimeArchive } from '../ssh/pinned-runtime-materializer' +import { parseGlibcVersion, parseOrcadLinuxLibc } from '../ssh/orcad-deployment-target' +import { + installNodeRuntimeFromHostArchiveCommand, + nodeRuntimeStoreDir, + posixNodeRuntimeExecutable, + probeRemoteNodeRuntimeCommand, + REMOTE_NODE_RUNTIME_READY +} from '../ssh/orcad-remote-node-runtime' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { assertRemoteNodeRuntimePromoted } from '../ssh/orcad-remote-node-runtime-report' +import { isGlibcBelow, PINNED_NODE_GLIBC_FLOOR } from '../ssh/ssh-relay-pinned-node' +import type { WslSpec } from './wsl-runner' + +const downloads = new Map>() +const DOWNLOAD_TIMEOUT_MS = 180_000 +/** Runs one command in the distro and returns its trimmed stdout; throws on failure. */ +export type WslRuntimeCommand = (spec: WslSpec, timeoutMs?: number) => Promise + +/** + * Orca's pinned Node in the distro's guest store (the one OpenCode's WSL reader uses), installed + * from the host's archive cache on first use. Never substitutes a user-installed runtime. + */ +export async function ensureWslPinnedRuntime( + run: WslRuntimeCommand, + cacheRoot: string, + signal: AbortSignal +): Promise { + const arch = await run({ program: 'uname', args: ['-m'], loginPath: 'none' }) + if (arch !== 'x86_64' && arch !== 'aarch64' && arch !== 'arm64') { + throw new Error(`Unsupported WSL architecture: ${arch}`) + } + const libcProbe = await run({ + script: + 'getconf GNU_LIBC_VERSION 2>/dev/null || ldd --version 2>&1 || ' + + 'for loader in /lib/ld-musl-*.so.1; do [ ! -e "$loader" ] || { echo musl; break; }; done', + loginPath: 'none' + }) + const libc = parseOrcadLinuxLibc(libcProbe) + const glibc = libc === 'glibc' ? parseGlibcVersion(libcProbe) : null + // Why before any download: the pinned Node cannot load on an older glibc, as SSH hosts refuse. + if (glibc && isGlibcBelow(glibc, PINNED_NODE_GLIBC_FLOOR)) { + throw new Error( + `This WSL distro's glibc ${glibc.major}.${glibc.minor} is older than ` + + `${PINNED_NODE_GLIBC_FLOOR.major}.${PINNED_NODE_GLIBC_FLOOR.minor}, which Orca's Node runtime needs.` + ) + } + const target = `linux-${arch === 'x86_64' ? 'x64' : 'arm64'}-${libc}` as const + const home = await run({ script: 'printf %s "$HOME"', loginPath: 'none' }) + if (!home.startsWith('/')) { + throw new Error('WSL did not provide an absolute home directory.') + } + const host = getRemoteHostPlatform(arch === 'x86_64' ? 'linux-x64' : 'linux-arm64') + const runtimeDir = nodeRuntimeStoreDir(host, `${home}/.cache/orca`, target) + const executable = posixNodeRuntimeExecutable(host, runtimeDir) + const probe = await run({ + script: probeRemoteNodeRuntimeCommand(host, runtimeDir, target), + loginPath: 'none' + }) + if (probe === REMOTE_NODE_RUNTIME_READY) { + return executable + } + const key = `${cacheRoot}:${target}` + let download = downloads.get(key) + if (!download) { + // Why its own deadline: a joining caller's abort must not cancel another caller's download. + download = materializeNodeRuntimeArchive(target, cacheRoot, { + signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) + }).finally(() => downloads.delete(key)) + downloads.set(key, download) + } + const localArchive = await waitForPromiseWithSignal(download, signal) + const source = await run({ + program: 'wslpath', + args: ['-a', '-u', localArchive], + loginPath: 'none' + }) + const promoted = await run( + { + script: installNodeRuntimeFromHostArchiveCommand(host, { + runtimeDir, + archive: basename(localArchive), + target, + token: randomBytes(8).toString('hex') + }), + args: [source], + loginPath: 'none' + }, + 120_000 + ) + assertRemoteNodeRuntimePromoted(promoted) + return executable +} diff --git a/src/shared/claude-shell-function.test.ts b/src/shared/claude-shell-function.test.ts index b74575d67f7..dee5b3dc5f5 100644 --- a/src/shared/claude-shell-function.test.ts +++ b/src/shared/claude-shell-function.test.ts @@ -91,6 +91,13 @@ describe.each(SHELLS)('the claude function in %s', (shell) => { expect(f.run(shell, injected(f.a)).stdout).toBe('HOME=default KEY=fake TWIN=none\n') }) + it('reads a WSL pane’s home-relative pointer against $HOME', () => { + const f = fixture() + writeFileSync(f.pointer, f.a) + const relative = f.run(shell, ['ORCA_CLAUDE_PROFILE_POINTER=~/selected']) + expect(relative.stdout).toBe(`HOME=${f.a} KEY=none TWIN=${f.a}\n`) + }) + it('refuses a selected account whose folder is missing', () => { const f = fixture() writeFileSync(f.pointer, join(f.a, 'gone')) diff --git a/src/shared/claude-shell-function.ts b/src/shared/claude-shell-function.ts index 546893f722b..abb3b97b368 100644 --- a/src/shared/claude-shell-function.ts +++ b/src/shared/claude-shell-function.ts @@ -22,8 +22,10 @@ export function getPosixClaudeShellFunction(): string { return `__orca_claude_binary="$(unalias claude 2>/dev/null || :; command -v claude 2>/dev/null || :)" if [[ -n "\${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "\${__orca_claude_binary:-}" && -x "\${__orca_claude_binary}" ]]; then function claude { - local __orca_claude_home - __orca_claude_home="$(cat "$ORCA_CLAUDE_PROFILE_POINTER" 2>/dev/null || :)" + local __orca_claude_home __orca_claude_pointer="\${ORCA_CLAUDE_PROFILE_POINTER:-}" + # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. + case "$__orca_claude_pointer" in '~/'*) __orca_claude_pointer="\${HOME:-}/\${__orca_claude_pointer#??}" ;; esac + __orca_claude_home="$(cat "$__orca_claude_pointer" 2>/dev/null || :)" if [ -n "\${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "\${ORCA_CLAUDE_INJECTED_CONFIG_DIR:-}" ]; then [ -z "$__orca_claude_home" ] || [ "$__orca_claude_home" = "$CLAUDE_CONFIG_DIR" ] || printf '%s\\n' '${OVERRIDE_NOTE}' >&2 command claude "$@"; return @@ -48,7 +50,9 @@ export function getFishClaudeShellFunction(): string { set -l __orca_claude_type (type -t claude 2>/dev/null) if test -n "$ORCA_CLAUDE_PROFILE_POINTER"; and test "$__orca_claude_type" = file function claude - set -l profile (cat "$ORCA_CLAUDE_PROFILE_POINTER" 2>/dev/null) + # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. + set -l pointer (string replace -r '^~/' "$HOME/" -- "$ORCA_CLAUDE_PROFILE_POINTER") + set -l profile (cat "$pointer" 2>/dev/null) if test -n "$CLAUDE_CONFIG_DIR"; and test "$CLAUDE_CONFIG_DIR" != "$ORCA_CLAUDE_INJECTED_CONFIG_DIR" if test -n "$profile"; and test "$profile" != "$CLAUDE_CONFIG_DIR" echo '${OVERRIDE_NOTE}' >&2 diff --git a/src/shared/relay-artifacts.ts b/src/shared/relay-artifacts.ts index 8a4633fb81c..66d62652483 100644 --- a/src/shared/relay-artifacts.ts +++ b/src/shared/relay-artifacts.ts @@ -48,6 +48,8 @@ export type RelayArtifact = { /** The bare Windows process-table addon; see docs/reference/windows-process-enumeration.md. */ export const RELAY_WINDOWS_PROCESS_TREE_FILENAME = 'windows-process-tree.node' export const RELAY_OPENCODE_SQLITE_READER_FILENAME = 'opencode-sqlite-reader.cjs' +/** Built into the WSL-only bundle dir (out/relay/wsl), never into an SSH relay dir. */ +export const WSL_CLAUDE_PROFILE_HELPER_FILENAME = 'claude-profile-wsl.cjs' export const RELAY_ARTIFACTS: readonly RelayArtifact[] = [ { filename: 'relay.js' },