Claude account profiles: dormant WSL guest setup (Step 3 of 4) (#24384)

* feat(claude): add dormant profile setup and history sharing

* fix(claude): make profile setup one gated, typed, fail-safe entry

Review round 1 of the dormant profile setup found that the pieces could
be called without their safety checks, that one failed write or an
unreadable bookkeeping file could silently stop sharing for good, and
that Windows prompt history could bring back history the user cleared.

- One entry, provisionClaudeAccountProfile: the profile gate (namespace,
  no linked components, outside ~/.claude and ~/.config/claude, and an
  ownership marker beside the home naming the account and target) runs
  first and refuses before creating anything; then history sharing,
  config provisioning, and the hook install after the settings merge.
  Results come back per surface with closed warning codes instead of
  message text.
- The sharing ledger is keyed by surface name, records a value only
  after its write succeeded, and an unreadable ledger starts empty and
  is rewritten instead of blocking every surface.
- The profile state file goes through the same locked writer as folder
  trust (Claude's <file>.lock plus the in-process queue), generalized as
  updateClaudeGlobalConfig. Onboarding and trust are still applied when
  the personal state file is unreadable.
- WSL descriptors build guest POSIX paths; the state-file path style
  follows the injected platform.
- Orca's managed statusLine has one owner in a profile: the settings
  merge never shares it, a user's own statusLine is shared over it, and
  the profile installer follows the default home's slot so a default
  opt-out reaches every profile. remove() takes the same destination;
  the remote installer cannot accept one.
- Prompt history compares file identity (bigint dev+ino) on every
  platform, never drains the shared file into itself, drains retained
  copies in generation order, never reuses a stale cursor, and on Windows
  keeps a replaced default's old copy aside instead of replaying it.
  Directory merges keep going past a failed entry.

* fix(claude): share the user's own hooks and keep merged history whole

A user's own Claude hooks in ~/.claude (notifications, formatters) did
not run under a managed account, because the whole hooks key stayed
private. They are now shared like any other settings key: Orca's own
hook entries and its managed statusLine are stripped from both the
personal value and the profile's current value before the per-key
ledger comparison, so they never travel through the merge and never make
the key look user-owned. Orca entries already in the profile are kept on
write, and the profile hook installer adds them on top as before.

Prompt history: merged bytes that lack a final newline are terminated,
so Claude's next record no longer fuses onto the last merged line. When
a CLI rewrote the profile's history file (old records plus new), only
the lines past the part it shares with the default history are added,
instead of the whole file again.

* fix(claude): close review round 2 gaps in profile setup

Hooks and statusLine sharing:
- When ~/.claude holds only Orca's hook entries, the user's shared hooks
  now read as an empty value instead of a missing key. Removing the
  user's last own hook in ~/.claude therefore reaches profiles that
  never edited it, and deleting the only shared hook inside a profile
  stays deleted.
- A custom statusLine Orca shared, and the profile never edited, goes
  away when the default home drops it. When a shared custom line
  replaced Orca's line in a profile, the profile's statusline marker is
  dropped so Orca's line comes back once the default returns to it; a
  profile that opted out stays opted out. No other key gains deletion.
- install/remove/getStatus with a profile directory refuse when it is
  the default home, or its settings.json resolves to the default one,
  instead of editing System Default's hooks and opt-out state.
- The profile statusline rule reads the default settings under the
  userHome passed to the setup entry, not os.homedir().

Profile state and ownership:
- A malformed `projects` value skips only folder trust (new warning
  code trust-refused); onboarding and shared keys still apply.
- The ownership marker stores only host-local facts (account, runtime,
  distro). The execution host id is the caller's view of the host, so
  it stays in the in-memory descriptor and is not compared.

Prompt history interruption paths:
- With no cursor yet, a retained copy starts past the bytes it shares
  with the default history, so an interrupted share no longer replays
  the whole history.
- A retained name for the shared file itself is removed with its cursor
  instead of lingering until a later scrub makes it look new.
- The Windows link record is read three-state: unreadable stops the
  share instead of reading as "no link". If the record cannot be
  written after linking, the fresh link is undone.
- An unreadable retained copy is reported and no longer blocks linking.

* build(cli): list the new Claude hook modules in the CLI project

hook-service.ts and hook-settings.ts are compiled into the packaged CLI
project, which lists every file explicitly. The statusline policy and
profile destination modules they now import were missing, so the CLI
typecheck failed with TS6307. The CLI still loads hook-service through
the existing managed-agent-hook-controls build entry, which bundles
both modules; neither imports electron.

* fix(claude): close review round 3 regressions in profile setup

- A profile whose hooks hold only Orca's entries and that sharing never
  recorded is no longer treated as a user edit, so the user's first own
  hook in ~/.claude reaches it (for example when the profile was set up
  before ~/.claude had any hooks).
- A retained prompt-history file is removed as a second name for the
  shared file only when the default history does not itself link to it;
  otherwise it holds the only copy and is kept.
- Default-home checks compare file identity: the profile hook
  destination check uses device and inode, and the profile/default
  separation check resolves on-disk case, so a case-only alias of
  ~/.claude is refused on case-insensitive filesystems.
- A test pins that an unreadable leftover session tree no longer blocks
  linking.

* fix(claude): let shared keys leave a profile when ~/.claude drops them

QA found that removing a setting from ~/.claude never reached a managed
account: deleting the whole `hooks` block left the user's hook running
there. Only statusLine followed the default away.

Every shared key now follows the same rule through the existing per-key
ledger: when a key disappears from ~/.claude/settings.json (or
mcpServers/theme from the personal state file), it is removed from the
profile if the profile still holds exactly what Orca last shared. A
value changed inside the account is kept. Keys Orca never shared,
including denylisted ones, are never touched. Deleting the whole hooks
block removes the user's shared hooks and keeps Orca's own entries. A
missing source counts as empty; an unreadable source removes nothing.

* feat(claude): add dormant profile routing and account consumers

* fix(claude): drop the dormant profile selection RPC; clients negotiate by capability

Restores the inline mobile allowlist so its source-scan guard sees every
accounts.* method again, and the generated params catalog to generator order.

* fix(claude): guard the claude shell function and honour a hand-exported config dir

The function is defined only in a routed pane where claude is a real
executable (the codex function's guard), re-reads the pointer only while
CLAUDE_CONFIG_DIR is unset or still Orca's injected twin, accepts Git Bash
drive paths, and starts on its own line after the fish/PowerShell codex text.

* fix(claude): spawn-time profile env, total account listing, setup at lifecycle triggers

Round-1 review fixes for the dormant profile routing:
- Panes get the selected profile's CLAUDE_CONFIG_DIR plus an Orca twin at
  spawn, so nested shells and scripts inherit the account; System Default
  injects nothing and its home is the inherited CLAUDE_CONFIG_DIR.
- An absent routing owner is System Default, never a throw; AI Vault and
  session-search scans receive profile roots from their parent, and the
  capability is advertised only where an owner is installed.
- Account listing never throws: per-account readiness, a stale pointer is
  republished in the background and reported on the snapshot.
- Profiles are set up at select and startup; a launch only sets up one that
  never was, and a worker fault on a prepared profile is a warning. The
  Claude version probe is cached per binary identity.
- Pre-trust goes through the existing deadline- and realpath-guarded writer
  against the launch env's profile config.
- Skill discovery keeps a caller's Claude root and a broken Claude selection
  no longer fails other providers.
- The durable record carries a provider-neutral launchAccountHome, read
  through one helper by the launch fallback and the model catalog.

* test(claude): pin the version-probe cache, launch-account record and temp-home readers

* test(claude): pin dormant bash rc text alongside fish and PowerShell

* test(claude): read the fish launch init without a nullable index

* fix(claude): withdraw the profile pointer when a selection cannot be published

A pointer left naming the previous account would launch it silently; a
missing pointer makes the claude function refuse visibly. A newer selection
that raced the failed one keeps its pointer.

* fix(claude): read the fish profile pointer with read -z for fish older than 3.4

Shell tests skip system config and abort unless claude resolves to the fake.

* fix(claude): only the newest publish withdraws the pointer; total config dir lookup

- An overtaken publish that fails leaves the newer selection's pointer.
- The runtime config dir falls back to the legacy home for an unresolvable
  account or a WSL target, so skill roots never fail for other providers.
- WSL guest reader roots merge verbatim, never realpathed on this thread.
- History readers include ~/.claude, where step-1 setup pools profile history.
- System Default ignores a config dir an outer Orca injected (twin-marked).

* fix(claude): System Default launches and probes use the structured create resolver

A Claude agent-env CLAUDE_CONFIG_DIR the create path stored is now the home
the launch pins and the model probe accepts.

* test(claude): type the System Default launch record as an agent-session record

* fix(claude): install profile hook scripts under the setup job's home

A worker thread's os.homedir() ignores its own env, so the hook and
statusline scripts now go under the home the job names. The worker test pins
the process HOME to a sentinel, refuses to run unless the worker sees it, and
asserts nothing lands there.

* test(claude): skip shell cases whose shell the runner lacks

* fix(claude): remove env vars in the PowerShell claude function instead of setting null

On .NET 9+ (pwsh 7.5+) SetEnvironmentVariable with $null creates an empty
variable, so stripped auth vars reached claude as empty strings and the
restore left CLAUDE_CONFIG_DIR empty in the user's session.

* feat(claude): add dormant WSL guest profile setup

* fix(claude): open WSL panes without guest calls and coalesce same-profile publishes

A WSL pane now gets the same non-throwing, guest-free spawn env as a host
pane; only select, startup and Claude launches publish into the guest.
Overlapping publishes of one target share the newest publish while the
selection still names the same profile, instead of failing as superseded.
Publish issues name their WSL distro and drop out when the target is no
longer routed. A late inspect from an older selection no longer replaces
the newer one's verification, a failed guest request evicts the cached
guest, and readiness is derived per account from the guest's owned homes.

* fix(claude): roll back only the target whose selection failed

With profiles, a failed select or remove republishes just its own target
instead of running startup over every WSL distro, and a rollback failure is
logged instead of replacing the error that caused the rollback.

* fix(claude): scan WSL profile history only in running distros

Vault and usage scans pass Claude profile roots through the same
running-distro filter as every other WSL root, so a stopped distro's UNC
paths are never walked.

* fix(wsl): ship the Claude profile helper only in the WSL bundle dir

The helper only ever runs inside WSL from the desktop, so it moves out of
the SSH relay artifacts (no upload, no relay version change) into
out/relay/wsl beside the other WSL-only guest bundles. The three WSL bundle
resolvers share one candidate list.

* fix(wsl): refuse old glibc before downloading, and keep the shared download per caller

The pinned Node runtime needs glibc 2.28, so a distro below the floor is
refused before any download with a message naming both versions, as SSH
hosts are. The shared download again owns its own deadline and each caller
waits on its own signal, and the OpenCode reader keeps its architecture
error text.

* fix(claude): bound each WSL guest operation and run the helper through the WSL runner

A cached guest no longer carries its 180 s preparation deadline into later
requests. The helper runs through runWslProcess (stdin payload, WSL_UTF8),
the distro is confirmed running once per preparation and once per request,
a failed `claude --version` probe continues with an unknown version like
native setup, the helper resolves from the WSL bundle dir, and the guest
entry decodes stdin once so split UTF-8 survives.

* test(claude): cover WSL profile pre-trust routing and its deadline

* refactor(claude): drop WSL refresh cleanup that the failed publish's withdraw already does

* fix(claude): catch rollback failures only when profiles route the selection

With the gate off, select and remove surface the rollback error exactly as
before; only profile routing logs it and keeps the original error.

* fix(claude): give every WSL pane a guest-relative Claude profile pointer

WSL panes now always carry `~/.local/share/orca/claude-profiles/selected-wsl`,
which the bash/zsh and fish claude functions expand against the guest $HOME
at each invocation, so a pane opened before Orca has met the distro still
follows the selected account instead of falling back to ~/.claude. Absolute
pointers are untouched, PowerShell is unchanged, and a missing pointer file or
profile still refuses visibly. CLAUDE_CONFIG_DIR is set at spawn only when the
selection resolves without a guest call.

* test(claude): assert a missing guest-relative pointer refuses with a visible message

* test(claude): type the WSL runner mock in the transport test

* fix(claude): route only WSL distros that hold an Orca account, and re-derive their publish

A WSL distro is routed only while host settings hold an Orca Claude account
for it, decided from settings with no guest call. An unrouted distro behaves
as before profiles: its panes get no pointer or profile env, and a Claude
launch is System Default with no guest prepare. A distro that loses its last
account has its pointer withdrawn best-effort so older panes stop launching
the removed account.

A routed distro without a current publish (for example stopped at startup)
gets one non-blocking background publish from its next pane spawn, coalesced
per target; its failure stays that distro's issue and a later success clears
it. A late setup result from an older publish no longer replaces the newer
selection's verification. The owner contract moves to its own module so the
routing service stays under the size limit.

* fix(claude): read WSL profile history in native chat and adoption only in running distros

Native chat resolves Claude transcripts from host roots first and reads WSL
profile roots only after a miss, filtered to running distros like Codex's WSL
homes. Structured adoption candidates go through the same filter.

* fix(claude): target registration rollbacks and keep their errors in profile mode

A failed add or re-authentication rolls back only the account's own target.
With profiles, a failed re-authentication rollback is logged instead of
replacing the original error; with the gate off both behave as before.

* fix(claude): spell the guest pointer location once and keep set -u safe

The guest helper, the withdraw script and the pane pointer all derive from
one home-relative constant, and the posix claude function reads ${HOME:-}
so `set -u` with HOME unset refuses cleanly instead of aborting.

* test(claude): cover the IPC preflight and daemon WSLENV paths for WSL profile env

The renderer preflight is tested for wsl.exe and Windows shells with a \\wsl$
cwd (which always launch wsl.exe) and with the gate off, the daemon launch
plan imports the pointer and profile home without a WSLENV flag, and the
Windows launch test uses the guest-relative pointer production sends.

* fix(wsl): report why the guest runtime failed, with download context and trimmed stderr

The install's promote output is classified with the SSH classifier, so a
self-test failure shows the exit code and the loader's words (for example a
missing libstdc++ on Alpine) and a security-software change is named. A failed
runtime download says it was Orca's Node runtime for WSL, while a checksum
mismatch keeps its own text. Guest stderr is trimmed before it reaches a
refusal message.

* test(claude): pin that pointer retirement never runs for host targets or with the gate off

* test(claude): give the routed WSL preflight fixture its required authMethod

* fix(claude): let the pane-triggered WSL publish repair a distro stopped at startup

"Distro not running" is now a typed refusal: it never withdraws the pointer
(the distro's last pointer cannot be stale, and a withdraw racing the boot
could delete a valid one) and never records a distro issue. The background
publish a pane fires now waits a few seconds for the pane's own spawn to boot
the distro, probing three times, and is dropped silently and re-armed if the
distro stays down. It joins any publish already in flight for that target
instead of preparing the guest a second time. Per-target generations and
pointer-write ordering move to ClaudeProfilePointerQueue so the routing
service stays under the size limit.

* fix(claude): remove the last selected WSL account without a guest publish

With profiles, removal writes the account list and the selection in one
update, so a distro losing its last account is already unrouted when it syncs
and its pointer is retired best-effort. Removal no longer needs the distro to
be running or able to run Orca's runtime. The gate-off order is unchanged.

* fix(claude): keep native chat's legacy Claude roots first and unfiltered

Only roots added by WSL profiles are read after a miss and filtered to
running distros; a host CLAUDE_CONFIG_DIR on a \\wsl$ share is searched first
and unfiltered, as before profiles.

* test(claude): cover stopped-at-startup repair, launch join and last-account removal end to end

* test(claude): assert no running probe before the pane has had a turn to boot the distro

* fix(claude): let user-initiated profile work boot an idle-stopped WSL distro

WSL distros idle-stop on their own, and the legacy path boots them with its
spawn or \\wsl$ write. With profiles on, a Claude launch, a select, a remove,
a failed-change rollback and the retire after removing a distro's last
account now skip the running pre-check and let their first bounded guest
command (`wsl -d <distro> --exec ...` through runWslProcess) boot the
distro. They refuse only if that command fails, with wsl.exe's own reason,
for example a distro that does not exist. Startup, the pane-triggered repair
and the history readers keep the running pre-check and its typed refusal, so
background work never boots a distro. With the gate off nothing changes.

* fix(claude): let startup join a launch or select already publishing a WSL distro

Startup no longer overtakes a user's in-flight publish for the same target,
so a launch that is booting an idle-stopped distro is not handed startup's
"not running" refusal.

* fix(claude): remove accounts of a WSL distro that no longer exists, and name the helper once

wsl.exe's own failures (exit 0xFFFFFFFF, empty stderr, the diagnostic and its
WSL_E_* code on stdout) are now read by one shared reader used by the git
runner and the WSL profile transport, so profile refusals show wsl.exe's
message. WSL_E_DISTRO_NOT_FOUND becomes ClaudeProfileHostMissingError: with
profiles, removing an account from a distro that no longer exists keeps the
removal and logs a warning, while select and launch still refuse visibly.
The helper's file name is defined once in shared/relay-artifacts.ts and used
by the relay build and the transport.

* fix(claude): give plain fish tabs the claude function through the codex hand-off

Main now gives a plain fish tab Orca's codex function through a vendor_conf.d
snippet instead of a -C init. The claude function only rode the -C path, so a
plain fish tab would not re-read the account selection per invocation once
profiles are on. Define it at the first prompt beside codex; it stays empty
while the profile gate is off.

* fix(claude): share personal rules, themes, workflows and keybindings into account profiles

A managed account launches Claude with its own config folder, so user-level
rules/, custom themes/ (which a shared `custom:<slug>` theme points at),
personal workflows/ and keybindings.json silently stopped applying. Link the
three directories like skills and commands, and copy keybindings.json with the
same edit-preserving ledger as CLAUDE.md. routines/ stays unshared: routines
belong to the claude.ai account and the folder holds per-run state.

* test(claude): wait for the running child to read its account before switching

The test switched the selection after a fixed 20 ms, so under load the backgrounded claude
had not yet read the pointer and picked up the new account. The stand-in now marks when it has
started, and the test waits for that mark (bounded) before switching.

* fix(claude): accept WSL setup warnings for every shared Claude file

The guest reply schema listed CLAUDE.md by name, so a warning about the newly shared
keybindings.json would have rejected the whole reply. It now takes the shared-file list
from provisioning, like the shared folders.

* fix(claude): import the personal CLAUDE.md into account profiles instead of copying it

Claude also loads ~/.claude/CLAUDE.md as a parent folder's memory for any project under home,
so a copied account CLAUDE.md made every such session read the user's instructions twice
(checked live with Claude 2.1.288). An @~/.claude/CLAUDE.md import resolves to the same real
file, which Claude loads once from home, from projects under home and from folders outside it.

* refactor(claude): simplify account profile setup toward the prior art

- Windows keeps each account's history private; drop the hardlink, link
  record and conflict-copy machinery that only Windows reached.
- Share hooks and statusLine as ordinary settings keys: Orca writes the
  same entries into every folder, so the installer finds them present.
  Drops the Orca-entry carve-out, the per-profile statusline follow
  logic and its marker.
- Unreadable ledger is just an empty ledger.
- Share from the user's own CLAUDE_CONFIG_DIR when they set one (marked
  so Orca's injected value is never mistaken for it), and refuse a
  profile at or around it.
- Pin the one canonical profile path spelling in a test.

* refactor(claude): route launches through one account router, superset-shaped

Replace the routing service, owner interface, setup worker thread, reader-root
merging, persisted launch account and capability string with one
ClaudeProfileRouter: the pointer is written first and setup runs best-effort
after it (superset's order); a missing pointer means System default.

The claude shell function re-reads the pointer on every launch, refuses only
a selected account whose folder is missing, and prints a note when the user's
own CLAUDE_CONFIG_DIR overrides the selected account in that terminal.

Still dormant: claudeProfileRoutingEnabled() is false.

* test(claude): type router test settings instead of casting

* fix(claude): run account setup on a worker thread, never Electron main

publish() writes the pointer and starts setup in the background, so neither
startup nor an account switch blocks on a history merge. Each setup runs in a
one-shot worker (the profile-state backup worker's pattern); one setup per
account at a time, reused by later requests. A launch waits only for a folder
that was never set up, and refuses with a clear message if that setup fails.

* fix(claude): do not await the synchronous pointer publish

* refactor(claude): route WSL distros through a small guest router on the Step 2 shape

Replaces the WSL owner/transport/guest-inspect stack with ClaudeWslProfileRouter:
publish writes the guest pointer with one sh command and kicks Step 1's setup
best-effort; prepareLaunch checks the folder over the distro share and waits only
for a never-set-up folder; preparation returns main's WSL shape, so trust, rate
limits and readers need no new code. Setup runs as Linux in the guest on Orca's
pinned Node via a bundled helper (argv in, exit code out), without hooks.

Restores OpenCode's WSL runtime prep, git's wsl-host-failure, wsl-runner,
workspace trust, readers and account selection/registration to Step 2.
Names the guest pointer per Orca build so dev and packaged never share it.

* test(claude): give the routing launch test the merged resolver deps and handle shape

* test(claude): type the WSL routing mock's original() without an inline import()

* fix(claude-accounts): dedupe merged prompt history, drop drained copies, link setup folders by path

- Prompt-history drain appends only lines the shared file lacks, so a purge never re-adds lines.
- A set-aside history copy whose saved offset reaches its end is deleted on the next run.
- Setup folders link to the default home's own entry, not its resolved target.
- The profile gate and folder creation run once, in provisionClaudeAccountProfile.
- installHooks receives only configDir; drop a duplicate test key that fails CI.

* fix(claude-accounts): refuse a routed resume whose transcript is in another account; zsh claude function; setup timeout

- With account routing, a chat resume checks its transcript is in the launch folder; a missing one
  with a stored leaf refuses with historyInOtherAccount instead of starting fresh.
- The launch folder of a selected account comes from prepareLaunch(); the resolver stays for System default.
- zsh panes get the claude function like bash, fish and PowerShell (empty while routing is off).
- The setup worker is terminated after 60 s so a later launch can retry.
- Document that the setup marker means setup started, not finished.

* fix(claude-accounts): write the WSL account pointer before a launch returns; one relay bundle candidate list

- prepareLaunch awaits writePointer, so a missing or stale guest pointer cannot run another account.
- Startup's WSL republish runs inside serializeMutation, like rollback.
- relayBundleCandidates takes 'wsl'; the hook relay, browser relay and Claude helper use it, and
  wsl-relay-bundle-dirs.ts is gone.
- One setup-marker path helper for host and WSL; the guest pointer path is home-relative and only
  the pane value carries '~/'; drop a no-op esbuild external.

* fix(claude-accounts): refuse a routed resume only when the transcript is found in another folder

A transcript found in no known folder keeps the old stored-leaf resume.

* fix(claude-accounts): a WSL launch writes the pointer for the selection current at write time; bound the pointer read

A selection made while a launch waited on setup was overwritten by the launch's stale account.
A hung \\wsl.localhost read no longer stalls startup's serialized publish.

* fix(claude-accounts): record installed hooks as Orca-shared; skip symlink tests on Windows

After Orca installs its hooks into an account, record the account's hooks in
the settings ledger so a later run can still bring the user's own hooks in.
Tests that create real symlinks now skip on Windows.

* fix(claude-accounts): trim the which-account file in the PowerShell claude function

Co-Authored-By: Claude <noreply@anthropic.com>

* test(claude-accounts): spell the user's own config folder as an absolute path on every platform

Co-Authored-By: Claude <noreply@anthropic.com>

* test(claude): skip the POSIX-only WSL profile test on Windows

A WSL profile's data root is a POSIX path, so building one from a Windows
temp dir fails the absolute-path check there.

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Brennan Benson
2026-10-07 01:25:41 -04:00
committed by GitHub
co-authored by Jinwoo-H Claude
parent 2f49377425
commit 64bb9373da
24 changed files with 1032 additions and 66 deletions
+13
View File
@@ -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.')
+2 -19
View File
@@ -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)) {
@@ -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<typeof spawnProcess> & {
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)) {
@@ -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<Env extends Record<string, string> | undefined>(
env: Env,
connectionId: string | null | undefined,
target: CodexAccountSelectionTarget
target: ClaudeAccountSelectionTarget
): Env | Record<string, string> {
const profileEnv = connectionId || target.runtime === 'wsl' ? undefined : installed?.terminalEnv()
const profileEnv = connectionId ? undefined : installed?.terminalEnv(target)
return profileEnv ? { ...env, ...profileEnv } : env
}
@@ -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)
@@ -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<ClaudeRuntimeAuthPreparation> {
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 {
@@ -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: `<guest home> <distro> <account id>`.
// Hooks are not installed here: they reach the account through the settings merge from ~/.claude.
async function main(): Promise<void> {
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
})
@@ -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}`
}
@@ -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<typeof WslPaths>()),
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<string, string | null> = { 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)
}
)
@@ -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<void>
/**
* 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<string, Promise<void>>()
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<string[]> {
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<void> {
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<ClaudeRuntimeAuthPreparation> {
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<ClaudeRuntimeAuthPreparation> {
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<void> {
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<void> {
// 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<null>((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<string> {
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)
}
}
@@ -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<typeof ClaudeProfileRouting>()),
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')
}
})
})
@@ -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<ClaudeRuntimeAuthPreparation> {
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<ClaudeRuntimeAuthPreparation> {
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<void> {
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<void> {
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<void> {
const router = this.wslRouter
if (router) {
const distros = await router.runningDistros()
await Promise.all(distros.map((distro) => this.publishWsl(router, distro)))
}
}
async forceMaterializeCurrentSelectionForRollback(): Promise<void> {
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<void> {
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)
}
@@ -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)
@@ -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])
}
@@ -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()
})
})
@@ -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({
@@ -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])
+37
View File
@@ -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')
}
}
})
+18 -3
View File
@@ -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)]
: [])
])
]
}
+118
View File
@@ -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<WslRuntimeCommand>(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<string>()
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')
})
})
+96
View File
@@ -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<string, Promise<string>>()
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<string>
/**
* 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<string> {
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
}
+7
View File
@@ -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'))
+7 -3
View File
@@ -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
+2
View File
@@ -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' },