fix(wsl): session history reads never boot a stopped WSL distro (E9) (#35367)

* fix(wsl): session history reads never boot a stopped WSL distro (E9)

Transcript and session-history reads touched \\wsl.localhost shares, and ran
`wsl.exe --exec` probes, for any distro the root list named. Those touches are
the OpenCode reader, its setup, HOME and realpath probes, and the session
scanner and search index. Nothing re-checked that the distro still ran. A
`wsl --terminate` mid-scan, or a list taken while the distro was still
stopping, let background work boot it again.

Every UNC transcript touch (stat, readdir, open, access, readFile) and every
scanner `--exec` now asks a settled running read first. That read is no older
than 5 s, because the scanner process hears of no stop by itself. A user act on
the distro still passes.

* fix(wsl): opening a chat starts its stopped distro; scanner hears main's stop notes

A user-opened chat, a history page and an AI Vault resume start the
transcript's stopped WSL distro once, as a user act, so its history
shows; watch and scan reads stay background. Main forwards WSL stop and
wake notes to the session-scanner process so a terminate main saw is
honoured there at once.

* fix(wsl): type the scanner note channel structurally; test the resume wake

Keeps node:child_process out of the new scanner-notes module (the
process-host import check) and adds a test that fails if an AI Vault
resume stops starting the session's WSL distro.
This commit is contained in:
Neil
2026-10-28 17:32:51 -07:00
committed by GitHub
parent bc6a5ec32e
commit ed8f62cf94
28 changed files with 645 additions and 68 deletions
+10 -10
View File
@@ -14,13 +14,13 @@ const {
filterPathsToRunningWslDistrosAsync,
getCachedWslDistros,
hasCachedWslDistros,
listRunningWslHomeDirsAsync,
listRunningWslHomeDirsUnlessStopped,
scanAiVaultSessionsInService
} = vi.hoisted(() => ({
filterPathsToRunningWslDistrosAsync: vi.fn(async (paths: readonly string[]) => [...paths]),
getCachedWslDistros: vi.fn((): string[] | null => null),
hasCachedWslDistros: vi.fn(() => false),
listRunningWslHomeDirsAsync: vi.fn().mockResolvedValue([]),
listRunningWslHomeDirsUnlessStopped: vi.fn().mockResolvedValue([]),
scanAiVaultSessionsInService: vi.fn()
}))
@@ -30,9 +30,9 @@ vi.mock('./session-scanner-service-spawn', async (importOriginal) => ({
}))
vi.mock('../wsl', () => ({
getCachedWslDistros,
hasCachedWslDistros,
listRunningWslHomeDirsAsync
hasCachedWslDistros
}))
vi.mock('../wsl/wsl-home-unless-stopped', () => ({ listRunningWslHomeDirsUnlessStopped }))
vi.mock('../wsl-running-path-filter', () => ({ filterPathsToRunningWslDistrosAsync }))
import {
@@ -72,7 +72,7 @@ describe('invalidateAiVaultSessionListCache generation guard', () => {
filterPathsToRunningWslDistrosAsync.mockClear()
getCachedWslDistros.mockReset().mockReturnValue(null)
hasCachedWslDistros.mockReset().mockReturnValue(false)
listRunningWslHomeDirsAsync.mockReset().mockResolvedValue([])
listRunningWslHomeDirsUnlessStopped.mockReset().mockResolvedValue([])
scanAiVaultSessionsInService.mockReset()
})
afterEach(() => {
@@ -152,7 +152,7 @@ describe('invalidateAiVaultSessionListCache generation guard', () => {
expect(cached.scannedAt).toBe('scan-A')
expect(cached.scanId).toBe(first.scanId)
expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(1)
expect(listRunningWslHomeDirsAsync).toHaveBeenCalledTimes(1)
expect(listRunningWslHomeDirsUnlessStopped).toHaveBeenCalledTimes(1)
})
it('skips running-distro discovery once a probe has reported no installed WSL distro', async () => {
@@ -160,22 +160,22 @@ describe('invalidateAiVaultSessionListCache generation guard', () => {
getCachedWslDistros.mockReturnValue([])
await expect(getAiVaultWslHomeDirs()).resolves.toEqual([])
expect(listRunningWslHomeDirsAsync).not.toHaveBeenCalled()
expect(listRunningWslHomeDirsUnlessStopped).not.toHaveBeenCalled()
})
it('still discovers running distros before any distro probe has succeeded', async () => {
hasCachedWslDistros.mockReturnValue(false)
listRunningWslHomeDirsAsync.mockResolvedValue(['\\\\wsl.localhost\\Ubuntu\\home\\ada'])
listRunningWslHomeDirsUnlessStopped.mockResolvedValue(['\\\\wsl.localhost\\Ubuntu\\home\\ada'])
await expect(getAiVaultWslHomeDirs()).resolves.toEqual(['\\\\wsl.localhost\\Ubuntu\\home\\ada'])
expect(listRunningWslHomeDirsAsync).toHaveBeenCalledTimes(1)
expect(listRunningWslHomeDirsUnlessStopped).toHaveBeenCalledTimes(1)
})
it('skips WSL home discovery off Windows', async () => {
platform = 'linux'
await expect(getAiVaultWslHomeDirs()).resolves.toEqual([])
expect(listRunningWslHomeDirsAsync).not.toHaveBeenCalled()
expect(listRunningWslHomeDirsUnlessStopped).not.toHaveBeenCalled()
})
})
+3 -2
View File
@@ -5,7 +5,8 @@ import {
resetAiVaultScannerBackgroundForTests,
scanAiVaultSessionsInBackground
} from './session-scanner-background'
import { getCachedWslDistros, hasCachedWslDistros, listRunningWslHomeDirsAsync } from '../wsl'
import { getCachedWslDistros, hasCachedWslDistros } from '../wsl'
import { listRunningWslHomeDirsUnlessStopped } from '../wsl/wsl-home-unless-stopped'
import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter'
import {
isAiVaultScanCancelledError,
@@ -181,7 +182,7 @@ export async function getAiVaultWslHomeDirs(): Promise<string[]> {
if (hasCachedWslDistros() && getCachedWslDistros()?.length === 0) {
return []
}
return listRunningWslHomeDirsAsync()
return listRunningWslHomeDirsUnlessStopped()
}
export function aiVaultSessionListCacheGeneration(): number {
@@ -9,7 +9,9 @@ import type * as preparationModule from './opencode-wsl-runtime-preparation'
const mocks = vi.hoisted(() => ({
exists: vi.fn(() => true),
run: vi.fn(),
running: vi.fn(async (paths: readonly string[]) => [...paths]),
running: vi.fn(async (paths: readonly string[], _options?: { requireConfirmed?: boolean }) => [
...paths
]),
download: vi.fn(async () => 'C:\\cache\\node-v24.21.0-linux-arm64-musl.tar.gz'),
bundles: vi.fn((platform: string) => [`C:/relay/${platform}`])
}))
@@ -21,8 +23,13 @@ vi.mock('../../shared/app-environment', () => ({
})
}))
vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: mocks.run }))
vi.mock('../wsl-running-path-filter', () => ({
filterPathsToRunningWslDistrosAsync: mocks.running
// The admission gate asks the running list for the path's distro; `running` stands in for it.
vi.mock('../native-chat/wsl-transcript-running-admission', () => ({
admitWslTranscriptPath: async (path: string) => {
if ((await mocks.running([path], { requireConfirmed: true })).length === 0) {
throw new Error('WSL distro is not running.')
}
}
}))
vi.mock('../node-runtime/pinned-runtime-materializer', () => ({
materializeNodeRuntimeArchive: mocks.download
@@ -20,7 +20,7 @@ import { REMOTE_NODE_RUNTIME_READY } from '../ssh/orcad-remote-node-runtime-repo
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { runWslProcess, type WslSpec } from '../wsl/wsl-runner'
import { describeWslProcessFailure } from '../wsl/wsl-process-failure'
import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter'
import { admitWslTranscriptPath } from '../native-chat/wsl-transcript-running-admission'
import type { OpenCodeWslRuntime } from './session-scanner-opencode-wsl-runtime'
const preparation = new Map<string, { value: OpenCodeWslRuntime; expires: number }>()
@@ -81,15 +81,8 @@ async function prepare(distro: string): Promise<OpenCodeWslRuntime> {
const signal = AbortSignal.timeout(PREPARATION_TIMEOUT_MS)
const run = async (spec: WslSpec, timeoutMs = 15_000): Promise<string> => {
signal.throwIfAborted()
const running = await waitForPromiseWithSignal(
filterPathsToRunningWslDistrosAsync([toWindowsWslUncPath('/', distro)], {
requireConfirmed: true
}),
signal
)
if (running.length === 0) {
throw new Error(`WSL distro ${distro} is not running. Start it to read its history.`)
}
// Each `--exec` would boot a distro that stopped since the roots were listed.
await waitForPromiseWithSignal(admitWslTranscriptPath(toWindowsWslUncPath('/', distro)), signal)
signal.throwIfAborted()
const result = await runWslProcess({
...spec,
@@ -3,11 +3,18 @@ import { createAccumulator, finalizeSession } from './session-scanner-accumulato
import type { OpenCodeSqliteProcessOptions } from './session-scanner-opencode-sqlite-process-client'
const mocks = vi.hoisted(() => ({
running: vi.fn(async (paths: readonly string[]) => [...paths]),
running: vi.fn(async (paths: readonly string[], _options?: { requireConfirmed?: boolean }) => [
...paths
]),
create: vi.fn((_options: OpenCodeSqliteProcessOptions) => ({ dispose: vi.fn() }))
}))
vi.mock('../wsl-running-path-filter', () => ({
filterPathsToRunningWslDistrosAsync: mocks.running
// The admission gate asks the running list for the path's distro; `running` stands in for it.
vi.mock('../native-chat/wsl-transcript-running-admission', () => ({
admitWslTranscriptPath: async (path: string) => {
if ((await mocks.running([path], { requireConfirmed: true })).length === 0) {
throw new Error('WSL distro is not running.')
}
}
}))
vi.mock('./session-scanner-opencode-sqlite-process-client', () => ({
createOpenCodeSqliteProcessClient: mocks.create
@@ -4,7 +4,7 @@ import { buildWslExecArgs } from '../../shared/wsl-login-shell-command'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { resolveWslExecutablePath } from '../wsl/wsl-executable-path'
import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory'
import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter'
import { admitWslTranscriptPath } from '../native-chat/wsl-transcript-running-admission'
import { createOpenCodeSqliteProcessClient } from './session-scanner-opencode-sqlite-process-client'
import type { OpenCodeSqliteWorkerClient } from './session-scanner-opencode-sqlite-worker-client'
import type { OpenCodeWslRuntime } from './session-scanner-opencode-wsl-runtime'
@@ -62,13 +62,8 @@ export async function openCodeWslClient(
cwd: resolveWslInteropSpawnCwd(),
env: { ...buildRelayAiVaultServiceEnv(), WSL_UTF8: '1' },
async beforeSpawn(spawnSignal) {
const running = await waitForPromiseWithSignal(
filterPathsToRunningWslDistrosAsync([dbPath], { requireConfirmed: true }),
spawnSignal
)
if (running.length === 0) {
throw new Error(`WSL distro ${distro} is not running. Start it to read its history.`)
}
// A `--exec` of a stopped distro boots it; every respawn asks again.
await waitForPromiseWithSignal(admitWslTranscriptPath(dbPath), spawnSignal)
}
})
clients.set(key, client)
@@ -35,6 +35,7 @@ import {
type AiVaultServiceRequestBody,
type AiVaultServiceResultValue
} from './session-scanner-service-protocol'
import { forwardWslDistroNotesToChild } from './session-scanner-service-wsl-notes'
export class AiVaultScannerServiceClient {
private child: ChildProcess | null = null
@@ -48,6 +49,8 @@ export class AiVaultScannerServiceClient {
private readonly sessionSearch = new AiVaultServiceSessionSearchHold()
private disposed = false
private readonly stopForwardingWslNotes = forwardWslDistroNotesToChild(() => this.child)
constructor(private readonly options: AiVaultServiceClientOptions) {}
request<T>(
@@ -115,6 +118,7 @@ export class AiVaultScannerServiceClient {
return
}
this.disposed = true
this.stopForwardingWslNotes()
this.restartPolicy.dispose()
this.idleRetirement.clear()
const error = new Error('AI Vault service client was disposed.')
@@ -24,6 +24,7 @@ import {
import { readAiVaultSessionTitlesFromFiles } from './session-title-file-reader'
import { resolveHostReadableAiVaultTitleRequests } from './session-title-request-paths'
import { listLocalAiVaultSubagentSessions } from './session-subagent-reader'
import { noteWslDistroMayHaveStopped, noteWslDistroWoken } from '../wsl-background-distro-gate'
if (!process.send) {
throw new Error('AI Vault service requires a parent IPC channel.')
@@ -238,6 +239,14 @@ process.on('message', (raw: AiVaultServiceParentMessage) => {
void shutdown()
return
}
if (raw?.type === 'wslDistroNote' && typeof raw.distro === 'string') {
if (raw.kind === 'stopped') {
noteWslDistroMayHaveStopped(raw.distro)
} else {
noteWslDistroWoken(raw.distro)
}
return
}
if (isAiVaultServiceRequest(raw)) {
queueRequest(raw)
}
@@ -105,6 +105,8 @@ export type AiVaultServiceParentMessage =
roots: SessionSearchScanRoots | null
}
| { type: 'shutdown' }
// Main's WSL stop and wake notes, so the child's transcript gate never trusts a stale running read.
| { type: 'wslDistroNote'; kind: 'stopped' | 'woken'; distro: string }
export type AiVaultServiceResultValue =
| {
@@ -0,0 +1,43 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
_resetWslBackgroundDistroGateForTests,
noteWslDistroMayHaveStopped,
noteWslDistroWoken
} from '../wsl-background-distro-gate'
import { AiVaultScannerServiceClient } from './session-scanner-service-client'
import {
AiVaultServiceTestChild,
readyAiVaultServiceChild
} from './session-scanner-service-test-child'
afterEach(() => {
_resetWslBackgroundDistroGateForTests()
})
// verify-18 (a): a `wsl --terminate` main saw must reach the scanner before its running lease would.
describe('the session scanner hears main WSL stop and wake notes', () => {
it('forwards notes to a live child and stops after dispose', async () => {
const child = new AiVaultServiceTestChild()
const client = new AiVaultScannerServiceClient({
processFactory: () => child.asChildProcess(),
init: () => ({ sessionParseCache: null, sessionSearch: null })
})
void client.request({ type: 'request', operation: 'titles', requests: [] }).catch(() => {})
readyAiVaultServiceChild(child)
await Promise.resolve()
noteWslDistroMayHaveStopped('Ubuntu')
noteWslDistroWoken('Debian')
expect(child.sent).toEqual(
expect.arrayContaining([
{ type: 'wslDistroNote', kind: 'stopped', distro: 'Ubuntu' },
{ type: 'wslDistroNote', kind: 'woken', distro: 'Debian' }
])
)
client.dispose()
const sentBefore = child.sent.length
noteWslDistroMayHaveStopped('Ubuntu')
expect(child.sent).toHaveLength(sentBefore)
})
})
@@ -0,0 +1,15 @@
import { onWslDistroGateNote } from '../wsl-background-distro-gate'
type WslNoteChannel = {
send(message: unknown, callback: (error: Error | null) => void): boolean
}
/**
* The child's transcript gate cannot hear main's WSL stop and wake notes; without them it trusts a
* running read for its whole lease. Returns the unsubscribe.
*/
export function forwardWslDistroNotesToChild(child: () => WslNoteChannel | null): () => void {
return onWslDistroGateNote((note) =>
child()?.send({ type: 'wslDistroNote', ...note }, () => undefined)
)
}
+31
View File
@@ -7,6 +7,10 @@ import {
import { prepareAiVaultSessionResume } from './ai-vault-resume'
vi.mock('electron', () => ({ ipcMain: { handle: vi.fn() } }))
const wakeMock = vi.hoisted(() => vi.fn(async (_distro: string | null) => {}))
vi.mock('../native-chat/native-chat-open-wake', () => ({
wakeNativeChatTranscriptDistro: wakeMock
}))
afterEach(() => setStructuredAgentSessionHost(null))
it('refuses the native owner while preserving forks and other execution hosts', async () => {
@@ -46,3 +50,30 @@ it('refuses the native owner while preserving forks and other execution hosts',
})
expect(prepareSessionResume).toHaveBeenCalledWith(wsl)
})
// Resuming is a user act: its stopped distro must start before the transcript's gated reads.
it('starts the session file distro before preparing a WSL resume', async () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
const prepareSessionResume = vi.fn(async () => {
expect(wakeMock).toHaveBeenCalledWith('Ubuntu')
return { useRealCodexHome: false }
})
await prepareAiVaultSessionResume(
{
agent: 'claude',
sessionId: 'sess-1',
filePath: String.raw`\\wsl.localhost\Ubuntu\home\qa\session.jsonl`,
codexHome: null,
executionHostId: 'local'
},
{ prepareSessionResume }
)
expect(prepareSessionResume).toHaveBeenCalledOnce()
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
+4
View File
@@ -11,6 +11,8 @@ import { isWslUncPath } from '../../shared/wsl-paths'
import { assertLegacyAiVaultResumeAllowed } from '../ai-vault/structured-session-ownership'
import { tryEnsureStructuredAgentSessionHostForResume } from '../runtime/structured-agent-session-host-refusal'
import { copyStructuredChatConversation } from '../ai-vault/structured-chat-conversation-copy'
import { wakeNativeChatTranscriptDistro } from '../native-chat/native-chat-open-wake'
import { wslDistroForPath } from '../wsl/wsl-unc-access-gate'
export type AiVaultResumeHandlerOptions = {
ensureStructuredSessionOwnership?: () => Promise<void>
@@ -57,6 +59,8 @@ export async function prepareAiVaultSessionResume(
if (executionHost?.kind === 'ssh') {
return { useRealCodexHome: false }
}
// Resuming a session is a user act: start its stopped distro before the gated transcript reads.
await wakeNativeChatTranscriptDistro(wslDistroForPath(args.filePath))
if (!isWslUncPath(args.filePath)) {
if (options.ensureStructuredSessionOwnership) {
await (args.codexHome === null && !args.fork
@@ -0,0 +1,180 @@
import {
access,
lstat,
mkdir,
mkdtemp,
open,
readdir,
readFile,
rm,
stat,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ProcessResult } from '@orca/process-host/process-spec'
import type { WslTranscriptFsReusableProcessCall } from '../native-chat/wsl-transcript-fs-process-protocol'
const mocks = vi.hoisted(() => ({
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
listeners: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
runProcess: vi.fn<(spec: { args?: string[] }) => Promise<ProcessResult>>(),
fsCalls: new Array<string>(),
running: false,
home: ''
}))
vi.mock('electron', () => ({
ipcMain: {
handle: (channel: string, handler: (_event: unknown, args?: unknown) => unknown) =>
mocks.handlers.set(channel, handler),
on: (channel: string, listener: (_event: unknown, args?: unknown) => unknown) =>
mocks.listeners.set(channel, listener)
}
}))
vi.mock('@orca/process-host', () => ({ runProcess: mocks.runProcess }))
const GUEST_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada'
const ROOT = '\\\\wsl.localhost\\Ubuntu\\'
const TRANSCRIPT = `${GUEST_HOME}\\.claude\\projects\\-repo\\sess-wsl.jsonl`
// The distro's 9P share, backed by a temp dir: any touch of it starts the distro, as on Windows.
function onShare(path: string): string {
mocks.fsCalls.push(path)
mocks.running = true
return path === ROOT ? mocks.home : join(mocks.home, ...path.slice(GUEST_HOME.length).split('\\'))
}
vi.mock('../native-chat/wsl-transcript-fs-process-dispatch', async (importOriginal) => ({
...(await importOriginal<object>()),
runWslTranscriptFsProcess: async (request: WslTranscriptFsReusableProcessCall) => {
const path = onShare(request.path)
switch (request.operation) {
case 'access':
await access(path)
return true
case 'stat':
return stat(path)
case 'lstat':
return lstat(path)
case 'readdir':
return readdir(path, { withFileTypes: true })
case 'readfile':
return readFile(path, request.encoding)
}
},
openWslTranscriptFsProcess: async (path: string) => open(onShare(path), 'r')
}))
import { _resetWslCachesForTests } from '../wsl'
import { _resetWslBackgroundDistroGateForTests } from '../wsl-background-distro-gate'
import { resetWslTranscriptFsGateForTests } from '../native-chat/wsl-transcript-fs-gate'
import { readNativeChatTranscriptTail } from '../native-chat/transcript-watch'
import {
clearNativeChatSubscriptions,
clearNativeChatTranscriptCache,
registerNativeChatHandlers
} from './native-chat'
let originalPlatform: NodeJS.Platform
beforeEach(async () => {
originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mocks.home = await mkdtemp(join(tmpdir(), 'orca-wsl-open-wake-'))
const dir = join(mocks.home, '.claude', 'projects', '-repo')
await mkdir(dir, { recursive: true })
await writeFile(
join(dir, 'sess-wsl.jsonl'),
`${JSON.stringify({
type: 'user',
uuid: 'u-1',
timestamp: '2026-06-01T10:00:00.000Z',
message: { role: 'user', content: 'Hi from WSL' }
})}\n`
)
mocks.running = false
mocks.fsCalls = []
mocks.runProcess.mockReset().mockImplementation(async (spec) => ({
code: 0,
signal: null,
stdout: spec.args?.includes('--running') && mocks.running ? 'Ubuntu\n' : '',
stderr: '',
timedOut: false
}))
clearNativeChatTranscriptCache()
mocks.handlers.clear()
registerNativeChatHandlers()
})
afterEach(async () => {
clearNativeChatSubscriptions()
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
_resetWslCachesForTests()
_resetWslBackgroundDistroGateForTests()
resetWslTranscriptFsGateForTests()
await rm(mocks.home, { recursive: true, force: true })
})
// verify-18 review: opening a chat whose transcript is in a stopped distro is a user act.
describe('a user-opened chat on a stopped WSL distro', () => {
it('starts the distro and shows its history', async () => {
const result = await mocks.handlers.get('nativeChat:readSession')!(
{},
{ agent: 'claude', sessionId: 'sess-wsl', transcriptPath: TRANSCRIPT }
)
expect(mocks.fsCalls[0]).toBe(ROOT)
expect(result).toMatchObject({ messages: [expect.objectContaining({ role: 'user' })] })
})
it('starts the distro when a chat pane subscribes, and paints its history', async () => {
const events = new EventEmitter()
const send = vi.fn<(channel: string, payload: unknown) => void>()
const sender = {
id: 1,
isDestroyed: () => false,
on: (event: string, callback: () => void) => void events.on(event, callback),
removeListener: (event: string, callback: () => void) =>
void events.removeListener(event, callback),
send
}
mocks.listeners.get('nativeChat:subscribe')!(
{ sender },
{
subscriptionId: 'pane-1',
agent: 'claude',
sessionId: 'sess-wsl',
transcriptPath: TRANSCRIPT
}
)
await vi.waitFor(() =>
expect(send).toHaveBeenCalledWith(
'nativeChat:appended',
expect.objectContaining({
frame: expect.objectContaining({
type: 'snapshot',
messages: [expect.objectContaining({ role: 'user' })]
})
})
)
)
expect(mocks.fsCalls[0]).toBe(ROOT)
})
it('still refuses the same read when nobody opened the chat', async () => {
const result = await readNativeChatTranscriptTail({
agent: 'claude',
sessionId: 'sess-wsl',
transcriptPath: TRANSCRIPT,
limit: 20
})
expect(mocks.fsCalls).toEqual([])
expect(mocks.running).toBe(false)
expect(result).not.toHaveProperty('messages')
})
})
+11 -12
View File
@@ -14,9 +14,8 @@ import {
type SubscribeNativeChatTranscriptArgs
} from '../native-chat/transcript-watch'
import { abortWhenRendererGone } from './renderer-lifetime-abort'
import { agentHookServer } from '../agent-hooks/server'
import { nativeChatTranscriptPathOnExecutionHost } from '../native-chat/ssh-transcript-path'
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import { openNativeChatTranscript } from '../native-chat/native-chat-open-wake'
export { clearNativeChatTranscriptCache }
@@ -34,14 +33,13 @@ export type NativeChatReadSessionArgs = {
async function readSession(args: NativeChatReadSessionArgs): Promise<ReadTranscriptResult> {
const { agent, sessionId } = args
const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW
// Showing or paging a chat's history is a user act on its WSL distro.
const { transcriptPath, waking } = openNativeChatTranscript(sessionId, args.transcriptPath)
await waking
return readNativeChatTranscriptTail({
agent,
sessionId,
transcriptPath: nativeChatTranscriptPathOnExecutionHost(
agentHookServer.getStatusSnapshot(),
sessionId,
args.transcriptPath
),
transcriptPath,
limit,
sourceOrder: true
})
@@ -188,14 +186,12 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg
const canPublish = (): boolean =>
!sender.isDestroyed() && !rendererSignal.aborted && !pending.controller.signal.aborted
// Opening a chat is a user act on its WSL distro; the watch that follows stays background.
const opened = openNativeChatTranscript(sessionId, transcriptPath)
const subscribeArgs: SubscribeNativeChatTranscriptArgs = {
agent,
sessionId,
transcriptPath: nativeChatTranscriptPathOnExecutionHost(
agentHookServer.getStatusSnapshot(),
sessionId,
transcriptPath
),
transcriptPath: opened.transcriptPath,
initialLimit: limit,
sourceOrder: true,
onTranscriptPending: () => {
@@ -257,6 +253,9 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg
}
let subscription: NativeChatTranscriptSubscription
try {
if (opened.waking) {
await opened.waking
}
subscription = await subscribeNativeChatTranscript(subscribeArgs, pending.controller.signal)
} catch {
takePendingSubscription(sender.id, subscriptionId, pending)
@@ -6,6 +6,15 @@ const wslMocks = vi.hoisted(() => ({
resolveWslGuestRealDirAsync: vi.fn()
}))
// These cases cover root and path policy; the E9 gate has its own suite.
vi.mock('./wsl-transcript-running-admission', () => ({ admitWslTranscriptPath: async () => {} }))
vi.mock('../wsl/wsl-home-unless-stopped', async () => {
const wsl = await import('../wsl')
return {
wslHomeUnlessStopped: (distro: string) => wsl.getWslHomeAsync(distro),
listRunningWslHomeDirsUnlessStopped: () => wsl.listRunningWslHomeDirsAsync()
}
})
vi.mock('../wsl', () => ({
listRunningWslHomeDirsAsync: wslMocks.listRunningWslHomeDirsAsync,
resolveWslGuestRealDirAsync: wslMocks.resolveWslGuestRealDirAsync
@@ -6,7 +6,12 @@ import {
wslHomeSessionDirs,
type SessionHomeAgent
} from '../ai-vault/session-scanner-roots'
import { getWslHomeAsync, listRunningWslHomeDirsAsync, resolveWslGuestRealDirAsync } from '../wsl'
import { resolveWslGuestRealDirAsync } from '../wsl'
import {
listRunningWslHomeDirsUnlessStopped,
wslHomeUnlessStopped
} from '../wsl/wsl-home-unless-stopped'
import { admitWslTranscriptPath } from './wsl-transcript-running-admission'
import {
filterPathsToRunningWslDistrosAsync,
filterPathsToWslDistros
@@ -48,7 +53,9 @@ async function snapshotHomeDirs(snapshot: WslTranscriptResolutionSnapshot): Prom
if (snapshot.homeDirs) {
return snapshot.homeDirs
}
const homes = await Promise.all(snapshot.runningDistros.map((distro) => getWslHomeAsync(distro)))
const homes = await Promise.all(
snapshot.runningDistros.map((distro) => wslHomeUnlessStopped(distro))
)
return homes.filter((home): home is string => home !== null)
}
@@ -107,7 +114,16 @@ export function hostAdditionalCodexHomePaths(): string[] {
}
async function defaultListWslHomeDirs(): Promise<string[]> {
return listRunningWslHomeDirsAsync()
return listRunningWslHomeDirsUnlessStopped()
}
// `realpath` runs through `wsl.exe --exec`, which would boot a stopped distro.
async function resolveGuestRealDirUnlessStopped(
distro: string,
guestDir: string
): Promise<string | null> {
await admitWslTranscriptPath(toWindowsWslPath('/', distro))
return resolveWslGuestRealDirAsync(distro, guestDir)
}
function resolveWslHomeDirs(load?: () => Promise<string[]>): Promise<string[]> {
@@ -151,7 +167,7 @@ async function guestSymlinkTarget(
const key = `${distro}\0${dir}`
let entry = guestRealDirs.get(key)
if (!entry || Date.now() >= entry.expiresAt) {
const resolve = deps.resolveGuestRealDir ?? resolveWslGuestRealDirAsync
const resolve = deps.resolveGuestRealDir ?? resolveGuestRealDirUnlessStopped
entry = {
realDir: resolve(distro, dir).catch(() => null),
expiresAt: Date.now() + GUEST_REAL_DIR_TTL_MS
@@ -0,0 +1,68 @@
import type { AgentStatusIpcPayload } from '../../shared/agent-status-types'
import {
isWslHookRelayConnectionId,
WSL_HOOK_RELAY_CONNECTION_PREFIX
} from '../../shared/wsl-hook-relay-contract'
import { toWindowsWslPath } from '../../shared/wsl-paths'
import {
blockedWslDistroForPath,
runAsWslUserAct,
wslDistroForPath
} from '../wsl/wsl-unc-access-gate'
import { wslGatedAccess } from './wsl-transcript-fs-access'
import { WSL_TRANSCRIPT_RUNNING_LEASE_MS } from './wsl-transcript-running-admission'
import { agentHookServer } from '../agent-hooks/server'
import { nativeChatTranscriptPathOnExecutionHost } from './ssh-transcript-path'
type HookRow = Pick<AgentStatusIpcPayload, 'connectionId' | 'providerSession'>
/** The distro an opened chat's transcript lives in: its UNC path, else the WSL relay that reported it. */
export function nativeChatTranscriptDistro(
statusRows: readonly HookRow[],
sessionId: string,
transcriptPath: string | undefined
): string | null {
const fromPath = transcriptPath ? wslDistroForPath(transcriptPath) : null
if (fromPath || process.platform !== 'win32') {
return fromPath
}
const relay = statusRows.find(
(row) => row.providerSession?.id === sessionId && isWslHookRelayConnectionId(row.connectionId)
)?.connectionId
return relay ? relay.slice(WSL_HOOK_RELAY_CONNECTION_PREFIX.length) || null : null
}
/**
* Opening or paging a chat is a user act: start its stopped distro so the gated transcript reads
* that follow can run (E9). Later watch and poll reads stay background and never start it.
*/
export async function wakeNativeChatTranscriptDistro(distro: string | null): Promise<void> {
if (
!distro ||
!(await blockedWslDistroForPath('', distro, { leaseMs: WSL_TRANSCRIPT_RUNNING_LEASE_MS }))
) {
return
}
// The read that follows reports any failure; this touch only has to start the distro.
await runAsWslUserAct(distro, () => wslGatedAccess(toWindowsWslPath('/', distro), 'exact')).catch(
() => {}
)
}
/**
* The host transcript path of a chat the user opened, and the start of its stopped WSL distro.
* `waking` is null without a distro, so other chats open without an extra await.
*/
export function openNativeChatTranscript(
sessionId: string,
requestedPath: string | undefined
): { transcriptPath: string | undefined; waking: Promise<void> | null } {
const statusRows = agentHookServer.getStatusSnapshot()
const transcriptPath = nativeChatTranscriptPathOnExecutionHost(
statusRows,
sessionId,
requestedPath
)
const distro = nativeChatTranscriptDistro(statusRows, sessionId, transcriptPath)
return { transcriptPath, waking: distro ? wakeNativeChatTranscriptDistro(distro) : null }
}
@@ -29,6 +29,15 @@ vi.mock('./wsl-transcript-fs-gate', async (importOriginal) => ({
vi.mock('../ai-vault/session-scanner-discovery', () => ({
walkSessionFiles: mocks.walk
}))
// These cases cover root and path policy; the E9 gate has its own suite.
vi.mock('./wsl-transcript-running-admission', () => ({ admitWslTranscriptPath: async () => {} }))
vi.mock('../wsl/wsl-home-unless-stopped', async () => {
const wsl = await import('../wsl')
return {
wslHomeUnlessStopped: (distro: string) => wsl.getWslHomeAsync(distro),
listRunningWslHomeDirsUnlessStopped: () => wsl.listRunningWslHomeDirsAsync()
}
})
vi.mock('../wsl', () => ({
getWslHomeAsync: vi.fn(async () => '\\\\wsl.localhost\\Ubuntu\\home\\ada'),
listRunningWslDistrosAsync: vi.fn(async () => ['Ubuntu']),
@@ -9,6 +9,15 @@ const ROLLOUT_UNC =
'\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\orca\\codex-runtime-home\\home\\sessions\\2026\\07\\24\\rollout-wsl-sess.jsonl'
const DEBIAN_ROLLOUT_UNC = ROLLOUT_UNC.replace('Ubuntu', 'Debian')
// These cases cover root and path policy; the E9 gate has its own suite.
vi.mock('./wsl-transcript-running-admission', () => ({ admitWslTranscriptPath: async () => {} }))
vi.mock('../wsl/wsl-home-unless-stopped', async () => {
const wsl = await import('../wsl')
return {
wslHomeUnlessStopped: (distro: string) => wsl.getWslHomeAsync(distro),
listRunningWslHomeDirsUnlessStopped: () => wsl.listRunningWslHomeDirsAsync()
}
})
vi.mock('../wsl', () => ({
listWslDistrosAsync: vi.fn(async () => ['Ubuntu', 'Debian']),
listRunningWslDistrosAsync: vi.fn(async () => ['Ubuntu', 'Debian']),
@@ -24,6 +24,15 @@ vi.mock('./transcript-watch-engine', () => ({
getActiveNativeChatWatcherCount: vi.fn(() => 0),
installTranscriptWatcher: mocks.install
}))
// These cases cover root and path policy; the E9 gate has its own suite.
vi.mock('./wsl-transcript-running-admission', () => ({ admitWslTranscriptPath: async () => {} }))
vi.mock('../wsl/wsl-home-unless-stopped', async () => {
const wsl = await import('../wsl')
return {
wslHomeUnlessStopped: (distro: string) => wsl.getWslHomeAsync(distro),
listRunningWslHomeDirsUnlessStopped: () => wsl.listRunningWslHomeDirsAsync()
}
})
vi.mock('../wsl', () => ({
getWslHomeAsync: mocks.getWslHomeAsync,
listRunningWslDistrosAsync: mocks.listRunningWslDistrosAsync,
@@ -14,6 +14,7 @@ import {
} from './wsl-transcript-fs-process-dispatch'
import type { WslTranscriptFsReusableProcessCall } from './wsl-transcript-fs-process-protocol'
import { wslTranscriptFsLaneKey } from './wsl-transcript-fs-route'
import { admitWslTranscriptPath } from './wsl-transcript-running-admission'
import {
readSshTranscript,
statSshTranscript,
@@ -41,14 +42,16 @@ function runReusableFsOperation<T>(
localTask: () => Promise<T>
): Promise<T> {
return isWslUncPath(request.path)
? runWslTranscriptFsTask(
{ operation: request.operation, path: request.path, priority, signal },
(taskSignal) =>
runWslTranscriptFsProcess<T>(
request,
taskSignal,
wslTranscriptFsLaneKey(request.path, priority)
)
? admitWslTranscriptPath(request.path).then(() =>
runWslTranscriptFsTask(
{ operation: request.operation, path: request.path, priority, signal },
(taskSignal) =>
runWslTranscriptFsProcess<T>(
request,
taskSignal,
wslTranscriptFsLaneKey(request.path, priority)
)
)
)
: localTask()
}
@@ -123,6 +126,15 @@ export function openTranscriptFile(
if (!isWslUncPath(path)) {
return open(path, 'r')
}
return openAdmittedWslTranscriptFile(path, priority, signal)
}
async function openAdmittedWslTranscriptFile(
path: string,
priority: WslTranscriptFsTaskPriority,
signal?: AbortSignal
): Promise<TranscriptFileHandle> {
await admitWslTranscriptPath(path)
return runWslTranscriptFsTask<TranscriptFileHandle>(
{
operation: 'open',
@@ -2,6 +2,7 @@ export const WSL_TRANSCRIPT_FS_SLOW_MESSAGE =
'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.'
export const WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE =
'WSL transcript discovery is temporarily unavailable because too many filesystem requests are already waiting. Try again shortly or restart Orca if the issue continues.'
const WSL_TRANSCRIPT_FS_DISTRO_NOT_RUNNING_PREFIX = 'WSL transcript files are unavailable because'
const WSL_TRANSCRIPT_FS_PROCESS_FAILURE_PREFIX =
'WSL transcript files are temporarily unavailable because the filesystem helper process failed'
@@ -38,9 +39,23 @@ export function wslTranscriptFsProcessFailureError(detail: unknown): WslTranscri
)
}
/** Reading would boot the distro (E9), or WSL couldn't say it runs; nothing touched its files. */
export function wslTranscriptFsDistroNotRunningError(
distro: string,
state: 'stopped' | 'unverifiable'
): WslTranscriptFsError {
return new WslTranscriptFsError(
'unavailable',
state === 'stopped'
? `${WSL_TRANSCRIPT_FS_DISTRO_NOT_RUNNING_PREFIX} WSL distro '${distro}' is not running. Open a terminal in it to start it.`
: `${WSL_TRANSCRIPT_FS_DISTRO_NOT_RUNNING_PREFIX} WSL didn't answer whether '${distro}' is running. Try again shortly.`
)
}
export function isWslTranscriptFsRefusalMessage(message: string): boolean {
return (
message === WSL_TRANSCRIPT_FS_SLOW_MESSAGE ||
message.startsWith(WSL_TRANSCRIPT_FS_DISTRO_NOT_RUNNING_PREFIX) ||
message === WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE ||
message.startsWith(WSL_TRANSCRIPT_FS_PROCESS_FAILURE_PREFIX)
)
@@ -0,0 +1,89 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ProcessResult } from '@orca/process-host/process-spec'
const mocks = vi.hoisted(() => ({
runProcess: vi.fn(),
runFsProcess: vi.fn(async () => [])
}))
vi.mock('@orca/process-host', () => ({ runProcess: mocks.runProcess }))
vi.mock('./wsl-transcript-fs-process-dispatch', async (importOriginal) => ({
...(await importOriginal<object>()),
runWslTranscriptFsProcess: mocks.runFsProcess
}))
import { _resetWslCachesForTests } from '../wsl'
import { _resetWslBackgroundDistroGateForTests } from '../wsl-background-distro-gate'
import { runAsWslUserAct } from '../wsl/wsl-unc-access-gate'
import { wslGatedReaddir } from './wsl-transcript-fs-access'
import { resetWslTranscriptFsGateForTests, WslTranscriptFsError } from './wsl-transcript-fs-gate'
import { WSL_TRANSCRIPT_RUNNING_LEASE_MS } from './wsl-transcript-running-admission'
const SESSIONS = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects'
function runningList(...distros: string[]): ProcessResult {
const stdout = distros.map((distro) => `${distro}\n`).join('')
return { code: 0, signal: null, stdout, stderr: '', timedOut: false }
}
const listProbes = (): number =>
mocks.runProcess.mock.calls.filter(([spec]) => spec.args?.includes('--running')).length
let originalPlatform: NodeJS.Platform
beforeEach(() => {
originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mocks.runProcess.mockReset()
mocks.runFsProcess.mockClear()
})
afterEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
_resetWslCachesForTests()
_resetWslBackgroundDistroGateForTests()
resetWslTranscriptFsGateForTests()
vi.useRealTimers()
})
describe('transcript reads never boot a stopped WSL distro (E9)', () => {
it('refuses a background read of a stopped distro without touching its files', async () => {
mocks.runProcess.mockResolvedValue(runningList())
const read = wslGatedReaddir(SESSIONS, 'scan')
await expect(read).rejects.toBeInstanceOf(WslTranscriptFsError)
await expect(read).rejects.toThrow("WSL distro 'Ubuntu' is not running")
expect(mocks.runFsProcess).not.toHaveBeenCalled()
})
it('reads a running distro', async () => {
mocks.runProcess.mockResolvedValue(runningList('Ubuntu'))
await expect(wslGatedReaddir(SESSIONS, 'scan')).resolves.toEqual([])
expect(mocks.runFsProcess).toHaveBeenCalledOnce()
})
it('re-checks a running read older than its short lease, as after a `wsl --terminate`', async () => {
vi.useFakeTimers({ toFake: ['performance', 'Date', 'setTimeout'] })
mocks.runProcess.mockResolvedValue(runningList('Ubuntu'))
await wslGatedReaddir(SESSIONS, 'scan')
// The distro is terminated; nothing tells this process. The next read must ask again.
mocks.runProcess.mockResolvedValue(runningList())
vi.advanceTimersByTime(WSL_TRANSCRIPT_RUNNING_LEASE_MS + 1)
await expect(wslGatedReaddir(SESSIONS, 'scan')).rejects.toThrow('is not running')
expect(listProbes()).toBe(2)
expect(mocks.runFsProcess).toHaveBeenCalledOnce()
})
it("lets a user act on the distro read it, as the user's own touch may start it", async () => {
mocks.runProcess.mockResolvedValue(runningList())
await expect(
runAsWslUserAct('Ubuntu', () => wslGatedReaddir(SESSIONS, 'exact'))
).resolves.toEqual([])
expect(mocks.runFsProcess).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,21 @@
import { blockedWslDistroForPath } from '../wsl/wsl-unc-access-gate'
import { wslTranscriptFsDistroNotRunningError } from './wsl-transcript-fs-error'
/**
* Nothing tells a transcript reader (main or the session scanner process) that a distro stopped,
* so a running read is trusted only this long: about one `wsl --list --running` per 5 s of reads.
*/
export const WSL_TRANSCRIPT_RUNNING_LEASE_MS = 5_000
/**
* Any 9P touch of `\\wsl.localhost\<distro>`, or a `wsl.exe --exec` there, boots a stopped distro.
* Transcript reads are background work, so they refuse unless it runs (E9); a user act passes.
*/
export async function admitWslTranscriptPath(path: string): Promise<void> {
const blocked = await blockedWslDistroForPath(path, null, {
leaseMs: WSL_TRANSCRIPT_RUNNING_LEASE_MS
})
if (blocked) {
throw wslTranscriptFsDistroNotRunningError(blocked.distro, blocked.state)
}
}
+26 -5
View File
@@ -49,14 +49,38 @@ export async function readConfirmedRunningWslDistros(
}
}
export type WslDistroGateNote = { kind: 'stopped' | 'woken'; distro: string }
const noteListeners = new Set<(note: WslDistroGateNote) => void>()
function ensureStopSubscription(): void {
unsubscribeStops ??= onRunningWslDistrosStopped((stopped) => {
for (const candidate of stopped) {
noteWslDistroMayHaveStopped(candidate)
}
})
}
/** Stop and wake notes, for a utility process (the session scanner) whose own gate never hears them. */
export function onWslDistroGateNote(listener: (note: WslDistroGateNote) => void): () => void {
ensureStopSubscription()
noteListeners.add(listener)
return () => noteListeners.delete(listener)
}
/** A link into the distro died, or a read saw it stop: later running reads must start after it settles. */
export function noteWslDistroMayHaveStopped(distro: string): void {
mayHaveStoppedAt.set(distroKey(distro), performance.now())
for (const listener of noteListeners) {
listener({ kind: 'stopped', distro })
}
}
/** A user act just touched the distro, which starts it: an older 'stopped' read no longer answers. */
export function noteWslDistroWoken(distro: string): void {
wokenAt.set(distroKey(distro), performance.now())
for (const listener of noteListeners) {
listener({ kind: 'woken', distro })
}
}
/** A wsl.exe launch starts its distro: if the last read said stopped, that read no longer answers. */
@@ -85,11 +109,7 @@ export async function settledWslRunningReadAnchor(
distro: string,
startedAfter: number | undefined
): Promise<number | undefined> {
unsubscribeStops ??= onRunningWslDistrosStopped((stopped) => {
for (const candidate of stopped) {
noteWslDistroMayHaveStopped(candidate)
}
})
ensureStopSubscription()
const floor = settledRunningFloor(distro)
const anchor = floor === -Infinity ? startedAfter : Math.max(startedAfter ?? -Infinity, floor)
// Timers truncate to whole ms on a ms clock and can wake ~2ms short; sleep until the anchor has passed.
@@ -260,4 +280,5 @@ export function _resetWslBackgroundDistroGateForTests(): void {
wokenAt.clear()
unsubscribeStops?.()
unsubscribeStops = null
noteListeners.clear()
}
+9 -1
View File
@@ -1,4 +1,4 @@
import { getWslHomeAsync } from '../wsl'
import { getWslHomeAsync, listRunningWslDistrosAsync } from '../wsl'
import { getCachedWslHome } from '../wsl-home-cache'
import { blockedWslDistroForPath } from './wsl-unc-access-gate'
@@ -9,3 +9,11 @@ export async function wslHomeUnlessStopped(distro: string): Promise<string | nul
}
return getWslHomeAsync(distro)
}
/** UNC homes of running distros, never probing one that stopped since the list (E9). */
export async function listRunningWslHomeDirsUnlessStopped(): Promise<string[]> {
const homes = await Promise.all(
(await listRunningWslDistrosAsync()).map((distro) => wslHomeUnlessStopped(distro))
)
return homes.filter((home): home is string => Boolean(home))
}
+3 -2
View File
@@ -58,14 +58,15 @@ export function wslDistroForPath(path: string, wslDistro?: string | null): strin
*/
export async function blockedWslDistroForPath(
path: string,
wslDistro?: string | null
wslDistro?: string | null,
options: { leaseMs?: number } = {}
): Promise<BlockedWslDistro | null> {
const distro = wslDistroForPath(path, wslDistro)
if (!distro || isWslUserActFor(distro)) {
return null
}
const state = await readWslDistroRunningState(distro, {
startedAfter: performance.now() - WSL_UNC_RUNNING_LEASE_MS,
startedAfter: performance.now() - (options.leaseMs ?? WSL_UNC_RUNNING_LEASE_MS),
retryUnverifiable: true
})
return state === 'running' ? null : { distro, state }