mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 00:02:05 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/fix-false-green
This commit is contained in:
@@ -12651,7 +12651,7 @@
|
||||
"large persisted mismatches route in 50-row pages with coalesced per-mailbox wakes",
|
||||
"Run pointers pin the existing mixed-version-compatible --run identity",
|
||||
"direct and Dispatch mail remain durable without unpinned synthetic terminal turns",
|
||||
"a delayed pointer submit cannot press Enter after the agent becomes working",
|
||||
"a staged pointer submits once when Codex or Claude becomes working and still withholds Enter for permission",
|
||||
"an explicit check releases its staged pointer reservation without redrive",
|
||||
"accepted pointer text is durably deduplicated before delayed Enter and provider Enter refusal",
|
||||
"a known PTY exit releases staged rows for the replacement process",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ORCHESTRATION_COMMAND_SPECS } from './orchestration'
|
||||
|
||||
describe('orchestration send command spec', () => {
|
||||
it('documents valid message types and the question reply path', () => {
|
||||
const sendSpec = ORCHESTRATION_COMMAND_SPECS.find(
|
||||
(spec) => spec.path.join(' ') === 'orchestration send'
|
||||
)
|
||||
|
||||
expect(sendSpec?.notes).toEqual(
|
||||
expect.arrayContaining([
|
||||
'Valid --type values: status, dispatch, worker_done, merge_ready, escalation, handoff, decision_gate, question, heartbeat.',
|
||||
'To answer a worker question, use orchestration reply --id <msg_id> --body <text> with the same Orca CLI executable.'
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -68,6 +68,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
'phase'
|
||||
],
|
||||
notes: [
|
||||
'Valid --type values: status, dispatch, worker_done, merge_ready, escalation, handoff, decision_gate, question, heartbeat.',
|
||||
'To answer a worker question, use orchestration reply --id <msg_id> --body <text> with the same Orca CLI executable.',
|
||||
'On Windows PowerShell, quote group addresses such as --to "@all" or --to "@worktree:<id>".',
|
||||
"worker_done and heartbeat are exact-Dispatch signals and cannot target groups; omit --to to use the Dispatch's Run mailbox.",
|
||||
'worker_done requires --outcome succeeded or --outcome failed.',
|
||||
|
||||
@@ -15,8 +15,7 @@ describe('preserveAgentAuthBeforeRestart', () => {
|
||||
codexRuntimeHome: {
|
||||
syncForCurrentSelection: vi.fn(() => {
|
||||
calls.push('codex')
|
||||
}),
|
||||
syncActiveWslSelectionsBeforeRestart: vi.fn()
|
||||
})
|
||||
},
|
||||
claudeRuntimeAuth: {
|
||||
syncForCurrentSelection: vi.fn(async () => {
|
||||
@@ -33,35 +32,13 @@ describe('preserveAgentAuthBeforeRestart', () => {
|
||||
expect(calls).toEqual(['codex', 'claude', 'flush'])
|
||||
})
|
||||
|
||||
it('runs WSL Codex preservation through the runtime service', async () => {
|
||||
const syncForCurrentSelection = vi.fn()
|
||||
const syncActiveWslSelectionsBeforeRestart = vi.fn()
|
||||
|
||||
await preserveAgentAuthBeforeRestart({
|
||||
codexRuntimeHome: {
|
||||
syncForCurrentSelection,
|
||||
syncActiveWslSelectionsBeforeRestart
|
||||
},
|
||||
store: {
|
||||
flushPendingOrThrowAsync: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
expect(syncForCurrentSelection).toHaveBeenCalledTimes(1)
|
||||
expect(syncForCurrentSelection).toHaveBeenNthCalledWith(1)
|
||||
expect(syncActiveWslSelectionsBeforeRestart).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('runs Claude preservation before WSL Codex preservation', async () => {
|
||||
it('runs Claude preservation after Codex and before the store flush', async () => {
|
||||
const calls: string[] = []
|
||||
|
||||
await preserveAgentAuthBeforeRestart({
|
||||
codexRuntimeHome: {
|
||||
syncForCurrentSelection: vi.fn(() => {
|
||||
calls.push('codex-host')
|
||||
}),
|
||||
syncActiveWslSelectionsBeforeRestart: vi.fn(() => {
|
||||
calls.push('codex-wsl')
|
||||
})
|
||||
},
|
||||
claudeRuntimeAuth: {
|
||||
@@ -76,29 +53,104 @@ describe('preserveAgentAuthBeforeRestart', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(calls).toEqual(['codex-host', 'claude', 'codex-wsl', 'flush'])
|
||||
expect(calls).toEqual(['codex-host', 'claude', 'flush'])
|
||||
})
|
||||
|
||||
it('continues after WSL Codex preservation fails', async () => {
|
||||
it('drains retained WSL Codex auth before flushing the store', async () => {
|
||||
const calls: string[] = []
|
||||
|
||||
await preserveAgentAuthBeforeRestart({
|
||||
codexRuntimeHome: {
|
||||
syncForCurrentSelection: vi.fn(() => {
|
||||
calls.push('codex-host')
|
||||
}),
|
||||
syncActiveWslSelectionsBeforeRestart: vi.fn(async () => {
|
||||
calls.push('codex-wsl')
|
||||
})
|
||||
},
|
||||
store: {
|
||||
flushPendingOrThrowAsync: vi.fn(async () => {
|
||||
calls.push('flush')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
expect(calls).toEqual(['codex-host', 'codex-wsl', 'flush'])
|
||||
})
|
||||
|
||||
it('does not release restart while the bounded WSL drain is still running', async () => {
|
||||
vi.useFakeTimers()
|
||||
let finishWslDrain!: () => void
|
||||
let settled = false
|
||||
const flushPendingOrThrowAsync = vi.fn()
|
||||
|
||||
const preservation = preserveAgentAuthBeforeRestart({
|
||||
codexRuntimeHome: {
|
||||
syncForCurrentSelection: vi.fn(),
|
||||
syncActiveWslSelectionsBeforeRestart: vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishWslDrain = resolve
|
||||
})
|
||||
)
|
||||
},
|
||||
store: { flushPendingOrThrowAsync }
|
||||
}).then(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1)
|
||||
expect(settled).toBe(false)
|
||||
|
||||
finishWslDrain()
|
||||
await preservation
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
|
||||
it('continues after the bounded WSL drain fails without logging secrets', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const flushPendingOrThrowAsync = vi.fn()
|
||||
|
||||
await preserveAgentAuthBeforeRestart({
|
||||
codexRuntimeHome: {
|
||||
syncForCurrentSelection: vi.fn(),
|
||||
syncActiveWslSelectionsBeforeRestart: vi.fn(() => {
|
||||
syncActiveWslSelectionsBeforeRestart: vi.fn(async () => {
|
||||
throw new Error('wsl-token-secret')
|
||||
})
|
||||
},
|
||||
store: {
|
||||
flushPendingOrThrowAsync
|
||||
}
|
||||
store: { flushPendingOrThrowAsync }
|
||||
})
|
||||
|
||||
expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[agent-auth-restart] Codex auth preservation failed (Error); continuing restart/update'
|
||||
)
|
||||
expect(JSON.stringify(warn.mock.calls)).not.toContain('token-secret')
|
||||
})
|
||||
|
||||
it('does not start Claude after host Codex exhausts the lifecycle budget', async () => {
|
||||
vi.useFakeTimers()
|
||||
const startedAt = Date.now()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const syncClaude = vi.fn(async () => {})
|
||||
|
||||
await preserveAgentAuthBeforeRestart({
|
||||
codexRuntimeHome: {
|
||||
syncForCurrentSelection: vi.fn(() => {
|
||||
vi.setSystemTime(startedAt + 2_000)
|
||||
})
|
||||
},
|
||||
claudeRuntimeAuth: { syncForCurrentSelection: syncClaude }
|
||||
})
|
||||
|
||||
expect(syncClaude).not.toHaveBeenCalled()
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[agent-auth-restart] Claude auth preservation exceeded 0ms; continuing restart/update'
|
||||
)
|
||||
})
|
||||
|
||||
it('flushes the store when auth services are missing', async () => {
|
||||
const flushPendingOrThrowAsync = vi.fn()
|
||||
|
||||
@@ -116,8 +168,7 @@ describe('preserveAgentAuthBeforeRestart', () => {
|
||||
codexRuntimeHome: {
|
||||
syncForCurrentSelection: vi.fn(() => {
|
||||
throw new Error('codex-token-secret')
|
||||
}),
|
||||
syncActiveWslSelectionsBeforeRestart: vi.fn()
|
||||
})
|
||||
},
|
||||
claudeRuntimeAuth: {
|
||||
syncForCurrentSelection: vi.fn(async () => {
|
||||
@@ -170,6 +221,32 @@ describe('preserveAgentAuthBeforeRestart', () => {
|
||||
expect(calls).toEqual(['claude-start', 'flush', 'claude-finish'])
|
||||
})
|
||||
|
||||
it('shares the original lifecycle timeout between Claude and store preservation', async () => {
|
||||
vi.useFakeTimers()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
let settled = false
|
||||
|
||||
const preservation = preserveAgentAuthBeforeRestart({
|
||||
claudeRuntimeAuth: {
|
||||
syncForCurrentSelection: vi.fn(() => new Promise<void>(() => {}))
|
||||
},
|
||||
store: {
|
||||
flushPendingOrThrowAsync: vi.fn(() => new Promise<void>(() => {}))
|
||||
}
|
||||
}).then(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
await preservation
|
||||
|
||||
expect(settled).toBe(true)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[agent-auth-restart] Store persistence exceeded 0ms; continuing restart/update'
|
||||
)
|
||||
})
|
||||
|
||||
it('bounds a store flush that never settles', async () => {
|
||||
vi.useFakeTimers()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
@@ -4,10 +4,8 @@ import type { Store } from './persistence'
|
||||
|
||||
const AUTH_PRESERVATION_TIMEOUT_MS = 2_000
|
||||
|
||||
type CodexRuntimeAuthSync = Pick<
|
||||
CodexRuntimeHomeService,
|
||||
'syncForCurrentSelection' | 'syncActiveWslSelectionsBeforeRestart'
|
||||
>
|
||||
type CodexRuntimeAuthSync = Pick<CodexRuntimeHomeService, 'syncForCurrentSelection'> &
|
||||
Partial<Pick<CodexRuntimeHomeService, 'syncActiveWslSelectionsBeforeRestart'>>
|
||||
type ClaudeRuntimeAuthSync = Pick<ClaudeRuntimeAuthService, 'syncForCurrentSelection'>
|
||||
type ShutdownStore = Pick<Store, 'flushPendingOrThrowAsync'>
|
||||
|
||||
@@ -28,34 +26,33 @@ export async function preserveAgentAuthBeforeRestart({
|
||||
store
|
||||
}: AgentAuthRestartPreservationOptions): Promise<void> {
|
||||
const startedAt = Date.now()
|
||||
|
||||
runCodexPreservationStep(codexRuntimeHome)
|
||||
// Why: the drain owns guest-process timeouts; a shared 2s cutoff can relaunch before promotion.
|
||||
const wslCodexPreservation = runWslCodexPreservationStep(codexRuntimeHome)
|
||||
|
||||
const remainingMs = Math.max(0, AUTH_PRESERVATION_TIMEOUT_MS - (Date.now() - startedAt))
|
||||
if (claudeRuntimeAuth && remainingMs > 0) {
|
||||
const claudeRemainingMs = remainingLifecycleTime(startedAt)
|
||||
if (claudeRuntimeAuth && claudeRemainingMs > 0) {
|
||||
await runWithinLifecycleTimeout(
|
||||
'Claude auth preservation',
|
||||
() => claudeRuntimeAuth.syncForCurrentSelection(),
|
||||
remainingMs
|
||||
claudeRemainingMs
|
||||
)
|
||||
} else if (claudeRuntimeAuth) {
|
||||
logStepTimeout('Claude auth preservation', 0)
|
||||
}
|
||||
|
||||
if (codexRuntimeHome && Date.now() - startedAt < AUTH_PRESERVATION_TIMEOUT_MS) {
|
||||
runWslCodexPreservationStep(codexRuntimeHome)
|
||||
} else if (codexRuntimeHome) {
|
||||
logStepTimeout('Codex auth preservation', 0)
|
||||
}
|
||||
const storePreservation = store
|
||||
? runWithinLifecycleTimeout(
|
||||
'Store persistence',
|
||||
() => store.flushPendingOrThrowAsync(),
|
||||
remainingLifecycleTime(startedAt)
|
||||
)
|
||||
: Promise.resolve()
|
||||
await Promise.all([wslCodexPreservation, storePreservation])
|
||||
}
|
||||
|
||||
if (store) {
|
||||
const storeRemainingMs = Math.max(0, AUTH_PRESERVATION_TIMEOUT_MS - (Date.now() - startedAt))
|
||||
await runWithinLifecycleTimeout(
|
||||
'Store persistence',
|
||||
() => store.flushPendingOrThrowAsync(),
|
||||
storeRemainingMs
|
||||
)
|
||||
}
|
||||
function remainingLifecycleTime(startedAt: number): number {
|
||||
return Math.max(0, AUTH_PRESERVATION_TIMEOUT_MS - (Date.now() - startedAt))
|
||||
}
|
||||
|
||||
function runCodexPreservationStep(codexRuntimeHome: CodexRuntimeAuthSync | null | undefined): void {
|
||||
@@ -66,11 +63,11 @@ function runCodexPreservationStep(codexRuntimeHome: CodexRuntimeAuthSync | null
|
||||
}
|
||||
}
|
||||
|
||||
function runWslCodexPreservationStep(
|
||||
async function runWslCodexPreservationStep(
|
||||
codexRuntimeHome: CodexRuntimeAuthSync | null | undefined
|
||||
): void {
|
||||
): Promise<void> {
|
||||
try {
|
||||
codexRuntimeHome?.syncActiveWslSelectionsBeforeRestart()
|
||||
await codexRuntimeHome?.syncActiveWslSelectionsBeforeRestart?.()
|
||||
} catch (error) {
|
||||
logStepFailure('Codex auth preservation', error)
|
||||
}
|
||||
@@ -89,7 +86,7 @@ async function runWithinLifecycleTimeout(
|
||||
})
|
||||
|
||||
// Why: this timeout only releases the restart/update path. It does not
|
||||
// cancel a sync that already started, and Codex sync is synchronous today.
|
||||
// cancel a sync that already started.
|
||||
const timeoutResult = new Promise<'timeout'>((resolve) => {
|
||||
timeout = setTimeout(() => resolve('timeout'), timeoutMs)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { _internals } from './legacy-wsl-runtime-auth-drain'
|
||||
import {
|
||||
NEWER_AUTH,
|
||||
RETIRED_SESSION,
|
||||
SOURCE_AUTH,
|
||||
SOURCE_CREDENTIALS,
|
||||
TARGET_AUTH,
|
||||
TORN_CREDENTIALS,
|
||||
isWindows
|
||||
} from './legacy-wsl-runtime-auth-drain-script-fixtures'
|
||||
import { runApplyScript } from './legacy-wsl-runtime-auth-drain-script-harness'
|
||||
import {
|
||||
runAbsentLegacyHomeScript,
|
||||
runRecoveryScript
|
||||
} from './legacy-wsl-runtime-auth-drain-recovery-script-harness'
|
||||
|
||||
describe.skipIf(isWindows)('legacy WSL auth drain apply script', () => {
|
||||
it('distinguishes an absent legacy home from an authless retained home', () => {
|
||||
const absent = runAbsentLegacyHomeScript({
|
||||
createLegacyHome: false,
|
||||
script: _internals.inspectLegacyAuthScript
|
||||
})
|
||||
const retained = runAbsentLegacyHomeScript({
|
||||
createLegacyHome: true,
|
||||
script: _internals.inspectLegacyAuthScript
|
||||
})
|
||||
|
||||
expect(absent.status).toBe(22)
|
||||
expect(retained.status).toBe(21)
|
||||
})
|
||||
|
||||
it('resolves an active-home-only legacy layout as retained', () => {
|
||||
const outcome = runAbsentLegacyHomeScript({
|
||||
activeHomeOnly: true,
|
||||
createLegacyHome: true,
|
||||
script: _internals.inspectLegacyAuthScript
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe(21)
|
||||
expect(outcome.markerExists).toBe(false)
|
||||
})
|
||||
|
||||
it('does not finalize while an authless legacy home still holds sessions', () => {
|
||||
const outcome = runAbsentLegacyHomeScript({
|
||||
createLegacyHome: true,
|
||||
script: _internals.finalizeAbsentAuthScript
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe(47)
|
||||
expect(outcome.markerExists).toBe(false)
|
||||
})
|
||||
|
||||
it('creates the completion-marker parent when the retired tree never existed', () => {
|
||||
const outcome = runAbsentLegacyHomeScript({
|
||||
createLegacyHome: false,
|
||||
markerParentMissing: true,
|
||||
script: _internals.finalizeAbsentAuthScript
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe(0)
|
||||
expect(outcome.markerExists).toBe(true)
|
||||
})
|
||||
|
||||
it('promotes the validated source into the account home', () => {
|
||||
const outcome = runApplyScript()
|
||||
expect(outcome.targetAuth).toBe(SOURCE_AUTH)
|
||||
})
|
||||
|
||||
it('leaves the legacy home untouched while promoting', () => {
|
||||
// One-directional: the promote step must never write back to the old home.
|
||||
expect(runApplyScript().legacyAuth).toBe(SOURCE_AUTH)
|
||||
})
|
||||
|
||||
it('refuses torn bytes and leaves the account home intact', () => {
|
||||
// Hash call 1 is the source pre-check; rotating right after it means `cp`
|
||||
// reads bytes freshness never judged. Pre-guard, those reached the target.
|
||||
const outcome = runApplyScript({ rewriteAfterHashCall: 1 })
|
||||
expect(outcome.status).toBe(42)
|
||||
expect(outcome.targetAuth).toBe(TARGET_AUTH)
|
||||
})
|
||||
|
||||
it('refuses a symlinked live source before the destructive path can retire it', () => {
|
||||
const result = runApplyScript({ deleteSource: true, sourceAuthSymlink: true })
|
||||
|
||||
expect(result.status).toBe(46)
|
||||
expect(result.legacyAuth).toBe(SOURCE_AUTH)
|
||||
expect(result.markerExists).toBe(false)
|
||||
expect(result.targetAuth).toBe(TARGET_AUTH)
|
||||
})
|
||||
|
||||
it('refuses MCP credentials that changed after host validation', () => {
|
||||
const outcome = runApplyScript({
|
||||
rewriteAfterHashCall: 2,
|
||||
rewriteBytes: TORN_CREDENTIALS,
|
||||
rewriteTarget: 'source-credentials',
|
||||
sourceCredentials: SOURCE_CREDENTIALS
|
||||
})
|
||||
expect(outcome.status).toBe(43)
|
||||
expect(outcome.targetCredentials).toBeNull()
|
||||
})
|
||||
|
||||
it('does not overwrite auth changed after the destination hash check', () => {
|
||||
const outcome = runApplyScript({
|
||||
rewriteBytes: NEWER_AUTH,
|
||||
rewriteAfterHashCall: 4,
|
||||
rewriteTarget: 'target-auth'
|
||||
})
|
||||
expect(outcome.status).toBe(39)
|
||||
expect(outcome.targetAuth).toBe(NEWER_AUTH)
|
||||
})
|
||||
|
||||
it('keeps the source pending when an unpromoted destination changes before deletion', () => {
|
||||
// Hash call 3 is the pinned destination check; the quarantine recheck must
|
||||
// observe this in-place rewrite before removing the source.
|
||||
const outcome = runApplyScript({
|
||||
deleteSource: true,
|
||||
promoteAuth: false,
|
||||
rewriteAfterHashCall: 3,
|
||||
rewriteBytes: NEWER_AUTH,
|
||||
rewriteTarget: 'target-auth'
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe(45)
|
||||
expect(outcome.legacyAuth).toBe(SOURCE_AUTH)
|
||||
expect(outcome.targetAuth).toBe(NEWER_AUTH)
|
||||
expect(outcome.markerExists).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the source pending when the destination changes after final precommit validation', () => {
|
||||
// Hash call 9 validates the independent snapshot. A rewrite immediately
|
||||
// afterward races the atomic cutover and must be detected on the old inode.
|
||||
const outcome = runApplyScript({
|
||||
deleteSource: true,
|
||||
promoteAuth: false,
|
||||
rewriteAfterHashCall: 9,
|
||||
rewriteBytes: NEWER_AUTH,
|
||||
rewriteTarget: 'target-auth'
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe(45)
|
||||
expect(outcome.legacyAuth).toBe(SOURCE_AUTH)
|
||||
expect(outcome.targetAuth).toBe(NEWER_AUTH)
|
||||
expect(outcome.markerExists).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks destination rewrites after the final locked validation', () => {
|
||||
// Hash call 12 is the installed read-only destination check. The shim's
|
||||
// attempted in-place rewrite must fail before the source is retired.
|
||||
const outcome = runApplyScript({
|
||||
deleteSource: true,
|
||||
promoteAuth: false,
|
||||
rewriteAfterHashCall: 12,
|
||||
rewriteTarget: 'target-auth'
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe(0)
|
||||
expect(outcome.legacyAuth).toBeNull()
|
||||
expect(outcome.targetAuth).toBe(TARGET_AUTH)
|
||||
expect(outcome.markerExists).toBe(true)
|
||||
})
|
||||
|
||||
it('recovers the source and destination mode after abrupt interruption', () => {
|
||||
const outcome = runApplyScript({ deleteSource: true, killAfterSourceRemoval: true })
|
||||
|
||||
expect(outcome.status).not.toBe(0)
|
||||
expect(outcome.legacyAuth).toBe(SOURCE_AUTH)
|
||||
expect(outcome.targetAuth).toBe(SOURCE_AUTH)
|
||||
expect(outcome.targetMode).toBe(0o600)
|
||||
expect(outcome.markerExists).toBe(false)
|
||||
})
|
||||
|
||||
it('can unlock the destination after interruption during atomic installation', () => {
|
||||
const outcome = runApplyScript({
|
||||
deleteSource: true,
|
||||
killAfterDestinationInstall: true
|
||||
})
|
||||
|
||||
expect(outcome.status).not.toBe(0)
|
||||
expect(outcome.legacyAuth).toBe(SOURCE_AUTH)
|
||||
expect(outcome.targetAuth).toBe(SOURCE_AUTH)
|
||||
expect(outcome.targetMode).toBe(0o600)
|
||||
expect(outcome.markerExists).toBe(false)
|
||||
})
|
||||
|
||||
it('cleans path metadata when interrupted before destination recovery is linked', () => {
|
||||
const outcome = runApplyScript({
|
||||
deleteSource: true,
|
||||
killBeforeDestinationRecoveryLink: true
|
||||
})
|
||||
|
||||
expect(outcome.status).not.toBe(0)
|
||||
expect(outcome.legacyAuth).toBe(SOURCE_AUTH)
|
||||
expect(outcome.destinationRecoveryAuth).toBeNull()
|
||||
expect(outcome.destinationRecoveryPathExists).toBe(false)
|
||||
expect(outcome.markerExists).toBe(false)
|
||||
})
|
||||
|
||||
it('restores verified source recovery instead of mutable quarantine after a crash', () => {
|
||||
const outcome = runApplyScript({
|
||||
deleteSource: true,
|
||||
killAfterSourceRemoval: true,
|
||||
rewriteQuarantineBeforeRecovery: true
|
||||
})
|
||||
|
||||
expect(outcome.status).not.toBe(0)
|
||||
expect(outcome.legacyAuth).toBe(SOURCE_AUTH)
|
||||
expect(outcome.sourceRecoveryAuth).toBeNull()
|
||||
expect(outcome.sourceQuarantineAuth).toBe(NEWER_AUTH)
|
||||
expect(outcome.markerExists).toBe(false)
|
||||
})
|
||||
|
||||
it('retains verified destination recovery when the target inode changed after a crash', () => {
|
||||
const outcome = runApplyScript({
|
||||
deleteSource: true,
|
||||
killAfterSourceRemoval: true,
|
||||
replaceTargetBeforeRecovery: true
|
||||
})
|
||||
|
||||
expect(outcome.status).not.toBe(0)
|
||||
expect(outcome.legacyAuth).toBe(SOURCE_AUTH)
|
||||
expect(outcome.targetAuth).toBe(NEWER_AUTH)
|
||||
expect(outcome.destinationRecoveryAuth).toBe(SOURCE_AUTH)
|
||||
expect(outcome.destinationRecoveryPathExists).toBe(true)
|
||||
expect(outcome.markerExists).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves a source rewrite that lands before quarantine', () => {
|
||||
const outcome = runApplyScript({
|
||||
deleteSource: true,
|
||||
promoteAuth: false,
|
||||
rewriteAfterHashCall: 6,
|
||||
rewriteBytes: NEWER_AUTH,
|
||||
rewriteTarget: 'source-auth',
|
||||
sourceSession: RETIRED_SESSION
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe(40)
|
||||
expect(outcome.legacyAuth).toBe(NEWER_AUTH)
|
||||
expect(outcome.targetSession).toBeNull()
|
||||
expect(outcome.markerExists).toBe(false)
|
||||
})
|
||||
|
||||
it('bridges retired sessions while a legacy pane still owns the source', () => {
|
||||
const outcome = runApplyScript({
|
||||
deleteSource: false,
|
||||
promoteAuth: false,
|
||||
sourceSession: RETIRED_SESSION
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe(0)
|
||||
expect(outcome.legacyAuth).toBe(SOURCE_AUTH)
|
||||
expect(outcome.targetSession).toBe(RETIRED_SESSION)
|
||||
expect(outcome.markerExists).toBe(false)
|
||||
})
|
||||
|
||||
it('rolls back retained-pane links when source ownership changes during the bridge', () => {
|
||||
const outcome = runApplyScript({
|
||||
deleteSource: false,
|
||||
promoteAuth: false,
|
||||
rewriteBytes: NEWER_AUTH,
|
||||
rewriteSourceAfterSessionLink: true,
|
||||
sourceSession: RETIRED_SESSION
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe(40)
|
||||
expect(outcome.legacyAuth).toBe(NEWER_AUTH)
|
||||
expect(outcome.targetSession).toBeNull()
|
||||
expect(outcome.markerExists).toBe(false)
|
||||
})
|
||||
|
||||
it('rolls back retained-pane links when destination ownership changes during the bridge', () => {
|
||||
const outcome = runApplyScript({
|
||||
deleteSource: false,
|
||||
promoteAuth: false,
|
||||
rewriteBytes: NEWER_AUTH,
|
||||
rewriteTargetAfterSessionLink: true,
|
||||
sourceSession: RETIRED_SESSION
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe(45)
|
||||
expect(outcome.legacyAuth).toBe(SOURCE_AUTH)
|
||||
expect(outcome.targetAuth).toBe(NEWER_AUTH)
|
||||
expect(outcome.targetSession).toBeNull()
|
||||
expect(outcome.markerExists).toBe(false)
|
||||
})
|
||||
|
||||
it('rolls back retired links when ownership changes during the bridge', () => {
|
||||
const outcome = runApplyScript({
|
||||
deleteSource: true,
|
||||
promoteAuth: false,
|
||||
rewriteBytes: NEWER_AUTH,
|
||||
rewriteSourceAfterSessionLink: true,
|
||||
sourceSession: RETIRED_SESSION
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe(40)
|
||||
expect(outcome.legacyAuth).toBe(NEWER_AUTH)
|
||||
expect(outcome.sourceRecoveryAuth).toBe(SOURCE_AUTH)
|
||||
expect(outcome.sourceQuarantineAuth).toBe(SOURCE_AUTH)
|
||||
expect(outcome.targetSession).toBeNull()
|
||||
expect(outcome.markerExists).toBe(false)
|
||||
})
|
||||
|
||||
it('rolls back when an atomic rename replaces the destination during the bridge', () => {
|
||||
const outcome = runApplyScript({
|
||||
deleteSource: true,
|
||||
promoteAuth: false,
|
||||
replaceTargetAfterSessionLink: true,
|
||||
rewriteBytes: NEWER_AUTH,
|
||||
sourceSession: RETIRED_SESSION
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe(45)
|
||||
expect(outcome.legacyAuth).toBe(SOURCE_AUTH)
|
||||
expect(outcome.targetAuth).toBe(NEWER_AUTH)
|
||||
expect(outcome.targetSession).toBeNull()
|
||||
expect(outcome.markerExists).toBe(false)
|
||||
})
|
||||
|
||||
it('retires auth after bridging sessions across guest filesystems', () => {
|
||||
const outcome = runApplyScript({
|
||||
crossFilesystemBridge: true,
|
||||
deleteSource: true,
|
||||
promoteAuth: false,
|
||||
sourceSession: RETIRED_SESSION
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe(0)
|
||||
expect(outcome.legacyAuth).toBeNull()
|
||||
expect(outcome.targetSession).toBe(RETIRED_SESSION)
|
||||
expect(outcome.markerExists).toBe(true)
|
||||
})
|
||||
|
||||
it('restores the verified recovery when an open writer mutates the quarantined inode', () => {
|
||||
const outcome = runApplyScript({
|
||||
deleteSource: true,
|
||||
promoteAuth: false,
|
||||
rewriteBytes: NEWER_AUTH,
|
||||
rewriteQuarantineAfterSessionLink: true,
|
||||
sourceSession: RETIRED_SESSION
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe(40)
|
||||
expect(outcome.legacyAuth).toBe(SOURCE_AUTH)
|
||||
expect(outcome.sourceRecoveryAuth).toBeNull()
|
||||
expect(outcome.sourceQuarantineAuth).toBe(NEWER_AUTH)
|
||||
expect(outcome.targetSession).toBeNull()
|
||||
expect(outcome.markerExists).toBe(false)
|
||||
})
|
||||
|
||||
it('rolls back a published session link after abrupt interruption', () => {
|
||||
const outcome = runApplyScript({
|
||||
deleteSource: true,
|
||||
killAfterSessionLink: true,
|
||||
sourceSession: RETIRED_SESSION
|
||||
})
|
||||
|
||||
expect(outcome.status).not.toBe(0)
|
||||
expect(outcome.legacyAuth).toBe(SOURCE_AUTH)
|
||||
expect(outcome.targetSession).toBeNull()
|
||||
expect(outcome.markerExists).toBe(false)
|
||||
})
|
||||
|
||||
it('finishes a durable session-link commit after abrupt interruption', () => {
|
||||
const outcome = runApplyScript({
|
||||
deleteSource: true,
|
||||
killDuringSessionCommit: true,
|
||||
sourceSession: RETIRED_SESSION
|
||||
})
|
||||
|
||||
expect(outcome.status).not.toBe(0)
|
||||
expect(outcome.legacyAuth).toBe(SOURCE_AUTH)
|
||||
expect(outcome.targetSession).toBe(RETIRED_SESSION)
|
||||
expect(outcome.sessionCommitMarkerExists).toBe(false)
|
||||
expect(outcome.markerExists).toBe(false)
|
||||
})
|
||||
|
||||
it('finishes durable cleanup when inspection finds a committed marker', () => {
|
||||
const outcome = runRecoveryScript({
|
||||
markerPresent: true,
|
||||
script: _internals.inspectLegacyAuthScript
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe(20)
|
||||
expect(outcome.destinationMode).toBe(0o600)
|
||||
expect(outcome.destinationRecoveryExists).toBe(false)
|
||||
expect(outcome.destinationRecoveryPathExists).toBe(false)
|
||||
expect(outcome.sourceRecoveryExists).toBe(false)
|
||||
})
|
||||
|
||||
it('finishes durable cleanup when apply finds a committed marker', () => {
|
||||
const outcome = runRecoveryScript({
|
||||
markerPresent: true,
|
||||
script: _internals.applyLegacyAuthScript
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe(0)
|
||||
expect(outcome.destinationMode).toBe(0o600)
|
||||
expect(outcome.destinationRecoveryExists).toBe(false)
|
||||
expect(outcome.destinationRecoveryPathExists).toBe(false)
|
||||
expect(outcome.sourceRecoveryExists).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses absent-source finalization while a durable recovery copy exists', () => {
|
||||
const outcome = runRecoveryScript({
|
||||
markerPresent: false,
|
||||
script: _internals.finalizeAbsentAuthScript
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe(46)
|
||||
expect(outcome.sourceAuth).toBe(SOURCE_AUTH)
|
||||
expect(outcome.destinationMode).toBe(0o600)
|
||||
expect(outcome.destinationRecoveryExists).toBe(false)
|
||||
expect(outcome.destinationRecoveryPathExists).toBe(false)
|
||||
expect(outcome.sourceRecoveryExists).toBe(false)
|
||||
expect(outcome.markerExists).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed when destination recovery has no target-path metadata', () => {
|
||||
const outcome = runRecoveryScript({
|
||||
markerPresent: false,
|
||||
pathMetadata: false,
|
||||
script: _internals.inspectLegacyAuthScript
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe(46)
|
||||
expect(outcome.destinationRecoveryExists).toBe(true)
|
||||
expect(outcome.destinationRecoveryPathExists).toBe(false)
|
||||
})
|
||||
|
||||
it('deletes a promoted source only while the destination remains intact', () => {
|
||||
const changedDestination = runApplyScript({
|
||||
deleteSource: true,
|
||||
rewriteAfterHashCall: 7,
|
||||
rewriteBytes: NEWER_AUTH,
|
||||
rewriteTarget: 'target-auth'
|
||||
})
|
||||
expect(changedDestination.status).toBe(45)
|
||||
expect(changedDestination.legacyAuth).toBe(SOURCE_AUTH)
|
||||
expect(changedDestination.markerExists).toBe(false)
|
||||
|
||||
const outcome = runApplyScript({ deleteSource: true })
|
||||
|
||||
expect(outcome.status).toBe(0)
|
||||
expect(outcome.legacyAuth).toBeNull()
|
||||
expect(outcome.targetAuth).toBe(SOURCE_AUTH)
|
||||
expect(outcome.markerExists).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses to retire the source when the destination is atomically replaced after the inode pin', () => {
|
||||
// Why this needs its own case: the pinned hard link keeps the ORIGINAL inode, so its hash
|
||||
// still matches after another writer renames a different file over the destination path.
|
||||
// Only the `-ef` inode-identity assertions can see that; hash checks cannot. Without them
|
||||
// the script proceeds and retires the source, leaving the user with bytes nobody validated.
|
||||
const outcome = runApplyScript({ replaceTargetOnHashOf: '.orca-drain-destination-' })
|
||||
|
||||
expect(outcome.status).not.toBe(0)
|
||||
// The source is the only thing that must survive an unproven destination.
|
||||
expect(outcome.legacyAuth).toBe(SOURCE_AUTH)
|
||||
expect(outcome.markerExists).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export const MARKER_PRESENT_EXIT = 20
|
||||
export const SOURCE_AUTH_ABSENT_EXIT = 21
|
||||
export const LEGACY_HOME_ABSENT_EXIT = 22
|
||||
export const LEGACY_HOME_STILL_PRESENT_EXIT = 47
|
||||
@@ -0,0 +1,110 @@
|
||||
// Why: runs the drain's recovery and absent-legacy-home guest scripts under `sh`.
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import {
|
||||
linkSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
existsSync,
|
||||
statSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
RETIRED_SESSION,
|
||||
RETIRED_SESSION_SEGMENTS,
|
||||
SOURCE_AUTH,
|
||||
TARGET_AUTH
|
||||
} from './legacy-wsl-runtime-auth-drain-script-fixtures'
|
||||
|
||||
export function runRecoveryScript(options: {
|
||||
destinationRecovery?: boolean
|
||||
markerPresent: boolean
|
||||
pathMetadata?: boolean
|
||||
script: string
|
||||
}): {
|
||||
destinationMode: number
|
||||
destinationRecoveryExists: boolean
|
||||
destinationRecoveryPathExists: boolean
|
||||
markerExists: boolean
|
||||
sourceAuth: string | null
|
||||
sourceRecoveryExists: boolean
|
||||
status: number
|
||||
} {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-drain-recovery-'))
|
||||
const legacyHome = join(root, 'legacy')
|
||||
const targetHome = join(root, 'account')
|
||||
mkdirSync(legacyHome)
|
||||
mkdirSync(targetHome)
|
||||
const markerPath = join(root, 'drain-marker.json')
|
||||
const sourceRecoveryPath = `${markerPath}.orca-drain-source`
|
||||
const destinationRecoveryPath = `${markerPath}.orca-drain-destination`
|
||||
const destinationRecoveryTargetPath = `${markerPath}.orca-drain-destination-path`
|
||||
const sourceAuthPath = join(legacyHome, 'auth.json')
|
||||
const destinationAuthPath = join(targetHome, 'auth.json')
|
||||
writeFileSync(sourceRecoveryPath, SOURCE_AUTH, { mode: 0o400 })
|
||||
writeFileSync(destinationAuthPath, TARGET_AUTH, { mode: 0o400 })
|
||||
if (options.destinationRecovery !== false) {
|
||||
linkSync(destinationAuthPath, destinationRecoveryPath)
|
||||
}
|
||||
if (options.pathMetadata !== false) {
|
||||
writeFileSync(destinationRecoveryTargetPath, `${destinationAuthPath}\0`, { mode: 0o600 })
|
||||
}
|
||||
if (options.markerPresent) {
|
||||
writeFileSync(markerPath, '{"completed":true}\n')
|
||||
}
|
||||
let status = 0
|
||||
try {
|
||||
execFileSync(
|
||||
'/bin/sh',
|
||||
['-c', options.script, 'sh', legacyHome, join(root, 'absent-active-home'), markerPath],
|
||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 20_000, windowsHide: true }
|
||||
)
|
||||
} catch (error) {
|
||||
status = (error as { status?: number }).status ?? -1
|
||||
}
|
||||
return {
|
||||
destinationMode: statSync(destinationAuthPath).mode & 0o777,
|
||||
destinationRecoveryExists: existsSync(destinationRecoveryPath),
|
||||
destinationRecoveryPathExists: existsSync(destinationRecoveryTargetPath),
|
||||
markerExists: existsSync(markerPath),
|
||||
sourceAuth: existsSync(sourceAuthPath) ? readFileSync(sourceAuthPath, 'utf8') : null,
|
||||
sourceRecoveryExists: existsSync(sourceRecoveryPath),
|
||||
status
|
||||
}
|
||||
}
|
||||
|
||||
export function runAbsentLegacyHomeScript(options: {
|
||||
activeHomeOnly?: boolean
|
||||
createLegacyHome: boolean
|
||||
markerParentMissing?: boolean
|
||||
script: string
|
||||
}): {
|
||||
markerExists: boolean
|
||||
status: number
|
||||
} {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-drain-absent-home-'))
|
||||
const legacyHome = join(root, 'legacy')
|
||||
const activeHome = join(root, 'absent-active-home')
|
||||
const markerPath = options.markerParentMissing
|
||||
? join(root, 'marker-parent', 'drain-marker.json')
|
||||
: join(root, 'drain-marker.json')
|
||||
if (options.createLegacyHome) {
|
||||
const sessionHome = options.activeHomeOnly ? activeHome : legacyHome
|
||||
mkdirSync(join(sessionHome, ...RETIRED_SESSION_SEGMENTS.slice(0, -1)), { recursive: true })
|
||||
writeFileSync(join(sessionHome, ...RETIRED_SESSION_SEGMENTS), RETIRED_SESSION)
|
||||
}
|
||||
let status = 0
|
||||
try {
|
||||
execFileSync('/bin/sh', ['-c', options.script, 'sh', legacyHome, activeHome, markerPath], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 20_000
|
||||
})
|
||||
} catch (error) {
|
||||
status = (error as { status?: number }).status ?? -1
|
||||
}
|
||||
return { markerExists: existsSync(markerPath), status }
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { _internals } from './legacy-wsl-runtime-auth-drain'
|
||||
|
||||
const SOURCE_AUTH = '{"tokens":{"expires_at":2000}}\n'
|
||||
const TARGET_AUTH = '{"tokens":{"expires_at":1000}}\n'
|
||||
const SOURCE_CREDENTIALS = '{"server":{"access_token":"source"}}\n'
|
||||
const RETIRED_SESSION = '{"session":"retired"}\n'
|
||||
const LATER_SESSION = '{"session":"later"}\n'
|
||||
const now = new Date()
|
||||
const SESSION_SEGMENTS = [
|
||||
'sessions',
|
||||
String(now.getFullYear()),
|
||||
String(now.getMonth() + 1).padStart(2, '0'),
|
||||
String(now.getDate()).padStart(2, '0'),
|
||||
'retired.jsonl'
|
||||
]
|
||||
|
||||
function sha256(contents: string): string {
|
||||
return createHash('sha256').update(contents).digest('hex')
|
||||
}
|
||||
|
||||
function runInspect(root: string, legacyHome: string, markerPath: string): number {
|
||||
try {
|
||||
execFileSync(
|
||||
'/bin/sh',
|
||||
[
|
||||
'-c',
|
||||
_internals.inspectLegacyAuthScript,
|
||||
'sh',
|
||||
legacyHome,
|
||||
join(root, 'absent-active-home'),
|
||||
markerPath
|
||||
],
|
||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 20_000 }
|
||||
)
|
||||
return 0
|
||||
} catch (error) {
|
||||
return (error as { status?: number }).status ?? -1
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('legacy WSL auth drain rollback recovery', () => {
|
||||
it('reopens a completed drain when rollback recreated the retired home', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-drain-reland-'))
|
||||
const legacyHome = join(root, 'legacy')
|
||||
const targetHome = join(root, 'account')
|
||||
const markerPath = join(root, 'drain-marker.json')
|
||||
const legacyAuthPath = join(legacyHome, 'auth.json')
|
||||
const targetAuthPath = join(targetHome, 'auth.json')
|
||||
const targetCredentialsPath = join(targetHome, '.credentials.json')
|
||||
const sourceSessionPath = join(legacyHome, ...SESSION_SEGMENTS)
|
||||
const targetSessionPath = join(targetHome, ...SESSION_SEGMENTS)
|
||||
const laterSourceSessionPath = join(sourceSessionPath, '..', 'later.jsonl')
|
||||
const laterTargetSessionPath = join(targetSessionPath, '..', 'later.jsonl')
|
||||
const oldSourceSessionPath = join(legacyHome, 'sessions', '1999', '01', '01', 'old.jsonl')
|
||||
const oldTargetSessionPath = join(targetHome, 'sessions', '1999', '01', '01', 'old.jsonl')
|
||||
const rollbackSourceSessionPath = join(sourceSessionPath, '..', 'clock-rollback.jsonl')
|
||||
const rollbackTargetSessionPath = join(targetSessionPath, '..', 'clock-rollback.jsonl')
|
||||
const restartSourceSessionPath = join(sourceSessionPath, '..', 'restart-full.jsonl')
|
||||
const restartTargetSessionPath = join(targetSessionPath, '..', 'restart-full.jsonl')
|
||||
const blockedSourceSessionPath = join(sourceSessionPath, '..', 'blocked-watermark.jsonl')
|
||||
const blockedTargetSessionPath = join(targetSessionPath, '..', 'blocked-watermark.jsonl')
|
||||
mkdirSync(join(sourceSessionPath, '..'), { recursive: true })
|
||||
mkdirSync(targetHome)
|
||||
writeFileSync(markerPath, '{"completed":true}\n')
|
||||
writeFileSync(legacyAuthPath, SOURCE_AUTH)
|
||||
writeFileSync(join(legacyHome, '.credentials.json'), SOURCE_CREDENTIALS)
|
||||
writeFileSync(sourceSessionPath, RETIRED_SESSION)
|
||||
writeFileSync(targetAuthPath, TARGET_AUTH)
|
||||
|
||||
expect(runInspect(root, legacyHome, markerPath)).toBe(0)
|
||||
const apply = (targetHash: string, promote: string, retire: string, bridge: string): void => {
|
||||
execFileSync(
|
||||
'/bin/sh',
|
||||
[
|
||||
'-c',
|
||||
_internals.applyLegacyAuthScript,
|
||||
'sh',
|
||||
legacyHome,
|
||||
join(root, 'absent-active-home'),
|
||||
markerPath,
|
||||
targetHome,
|
||||
sha256(SOURCE_AUTH),
|
||||
targetHash,
|
||||
promote,
|
||||
retire,
|
||||
sha256(SOURCE_CREDENTIALS),
|
||||
bridge
|
||||
],
|
||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 20_000 }
|
||||
)
|
||||
}
|
||||
|
||||
apply(sha256(TARGET_AUTH), '1', '0', 'recent')
|
||||
expect(existsSync(sourceSessionPath)).toBe(true)
|
||||
expect(readFileSync(targetSessionPath, 'utf8')).toBe(RETIRED_SESSION)
|
||||
|
||||
mkdirSync(join(oldSourceSessionPath, '..'), { recursive: true })
|
||||
writeFileSync(laterSourceSessionPath, LATER_SESSION)
|
||||
writeFileSync(oldSourceSessionPath, RETIRED_SESSION)
|
||||
apply(sha256(SOURCE_AUTH), '0', '0', 'recent')
|
||||
expect(readFileSync(laterTargetSessionPath, 'utf8')).toBe(LATER_SESSION)
|
||||
expect(existsSync(oldTargetSessionPath)).toBe(false)
|
||||
|
||||
const watermarkPath = `${markerPath}.orca-drain-session-watermark`
|
||||
const linkedWatermarkPath = join(root, 'linked-watermark')
|
||||
writeFileSync(linkedWatermarkPath, '1999/01/01\n')
|
||||
rmSync(watermarkPath)
|
||||
symlinkSync(linkedWatermarkPath, watermarkPath)
|
||||
apply(sha256(SOURCE_AUTH), '0', '0', 'recent')
|
||||
expect(readFileSync(oldTargetSessionPath, 'utf8')).toBe(RETIRED_SESSION)
|
||||
|
||||
rmSync(watermarkPath)
|
||||
writeFileSync(watermarkPath, '2999/12/31\n')
|
||||
writeFileSync(rollbackSourceSessionPath, LATER_SESSION)
|
||||
apply(sha256(SOURCE_AUTH), '0', '0', 'recent')
|
||||
expect(readFileSync(rollbackTargetSessionPath, 'utf8')).toBe(LATER_SESSION)
|
||||
|
||||
writeFileSync(restartSourceSessionPath, LATER_SESSION)
|
||||
apply(sha256(SOURCE_AUTH), '0', '0', 'full')
|
||||
expect(readFileSync(restartTargetSessionPath, 'utf8')).toBe(LATER_SESSION)
|
||||
|
||||
rmSync(watermarkPath)
|
||||
mkdirSync(watermarkPath)
|
||||
writeFileSync(blockedSourceSessionPath, LATER_SESSION)
|
||||
expect(() => apply(sha256(SOURCE_AUTH), '0', '0', 'full')).toThrow()
|
||||
expect(existsSync(blockedTargetSessionPath)).toBe(false)
|
||||
expect(() => apply(sha256(SOURCE_AUTH), '0', '0', 'recent')).toThrow()
|
||||
expect(existsSync(blockedTargetSessionPath)).toBe(false)
|
||||
rmSync(watermarkPath, { recursive: true })
|
||||
apply(sha256(SOURCE_AUTH), '0', '0', 'recent')
|
||||
expect(readFileSync(blockedTargetSessionPath, 'utf8')).toBe(LATER_SESSION)
|
||||
|
||||
apply(sha256(SOURCE_AUTH), '0', '1', 'full')
|
||||
|
||||
expect(existsSync(legacyAuthPath)).toBe(false)
|
||||
expect(readFileSync(targetAuthPath, 'utf8')).toBe(SOURCE_AUTH)
|
||||
expect(readFileSync(targetCredentialsPath, 'utf8')).toBe(SOURCE_CREDENTIALS)
|
||||
expect(readFileSync(targetSessionPath, 'utf8')).toBe(RETIRED_SESSION)
|
||||
expect(readFileSync(oldTargetSessionPath, 'utf8')).toBe(RETIRED_SESSION)
|
||||
expect(existsSync(markerPath)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps completion authoritative when rollback recreated auth as a symlink', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-drain-reland-symlink-'))
|
||||
const legacyHome = join(root, 'legacy')
|
||||
const markerPath = join(root, 'drain-marker.json')
|
||||
const linkedAuthPath = join(root, 'linked-auth.json')
|
||||
mkdirSync(legacyHome)
|
||||
writeFileSync(markerPath, '{"completed":true}\n')
|
||||
writeFileSync(linkedAuthPath, SOURCE_AUTH)
|
||||
symlinkSync(linkedAuthPath, join(legacyHome, 'auth.json'))
|
||||
|
||||
expect(runInspect(root, legacyHome, markerPath)).toBe(46)
|
||||
expect(existsSync(markerPath)).toBe(true)
|
||||
expect(readFileSync(linkedAuthPath, 'utf8')).toBe(SOURCE_AUTH)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
// Why: the auth/credential/session byte fixtures the real-script drain suites assert against.
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
export const isWindows = process.platform === 'win32'
|
||||
|
||||
export const SOURCE_AUTH = '{"tokens":{"expires_at":2000}}\n'
|
||||
export const TARGET_AUTH = '{"tokens":{"expires_at":1000}}\n'
|
||||
export const NEWER_AUTH = '{"tokens":{"expires_at":3000}}\n'
|
||||
// A different, well-formed auth that another writer atomically renames into place.
|
||||
export const INTRUDER_AUTH = '{"tokens":{"expires_at":9000}}\n'
|
||||
|
||||
// Codex truncates before it writes, so a read landing mid-rotation sees this.
|
||||
export const TORN_AUTH = '{"tokens":{"exp'
|
||||
export const SOURCE_CREDENTIALS = '{"server":{"access_token":"source"}}\n'
|
||||
export const TORN_CREDENTIALS = '{"server":'
|
||||
export const RETIRED_SESSION = '{"session":"retired"}\n'
|
||||
export const RETIRED_SESSION_SEGMENTS = ['sessions', '2026', '08', '26', 'retired.jsonl']
|
||||
|
||||
export function sha256(contents: string): string {
|
||||
return createHash('sha256').update(contents).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the real guest script under `sh`, with `sha256sum` shimmed so a chosen
|
||||
* hash call can rewrite the source underneath the script. That is the only way
|
||||
* to land Codex's in-place rotation inside the window the script itself opens.
|
||||
*/
|
||||
@@ -0,0 +1,275 @@
|
||||
// Why: runs the real guest apply script under `sh` with shimmed coreutils so tests can inject
|
||||
// precise interference at chosen points.
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
existsSync,
|
||||
statSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { installDrainInterferenceShims } from './legacy-wsl-runtime-auth-drain-script-interference-shims'
|
||||
import {
|
||||
INTRUDER_AUTH,
|
||||
NEWER_AUTH,
|
||||
RETIRED_SESSION_SEGMENTS,
|
||||
SOURCE_AUTH,
|
||||
TARGET_AUTH,
|
||||
TORN_AUTH,
|
||||
sha256
|
||||
} from './legacy-wsl-runtime-auth-drain-script-fixtures'
|
||||
import type {
|
||||
DrainApplyInterference,
|
||||
DrainApplyOutcome
|
||||
} from './legacy-wsl-runtime-auth-drain-script-run-types'
|
||||
import { _internals } from './legacy-wsl-runtime-auth-drain'
|
||||
|
||||
export function runApplyScript(options: DrainApplyInterference = {}): DrainApplyOutcome {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-drain-apply-'))
|
||||
const legacyHome = join(root, 'legacy')
|
||||
const targetHome = join(root, 'account')
|
||||
const binDir = join(root, 'bin')
|
||||
for (const dir of [legacyHome, targetHome, binDir]) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
const legacyAuthPath = join(legacyHome, 'auth.json')
|
||||
const targetAuthPath = join(targetHome, 'auth.json')
|
||||
const legacyCredentialsPath = join(legacyHome, '.credentials.json')
|
||||
const targetCredentialsPath = join(targetHome, '.credentials.json')
|
||||
const markerPath = join(root, 'drain-marker.json')
|
||||
writeFileSync(legacyAuthPath, SOURCE_AUTH)
|
||||
writeFileSync(targetAuthPath, TARGET_AUTH)
|
||||
if (options.sourceAuthSymlink) {
|
||||
const sourceAuthTarget = join(root, 'linked-source-auth.json')
|
||||
renameSync(legacyAuthPath, sourceAuthTarget)
|
||||
symlinkSync(sourceAuthTarget, legacyAuthPath)
|
||||
}
|
||||
if (options.sourceCredentials !== undefined) {
|
||||
writeFileSync(legacyCredentialsPath, options.sourceCredentials)
|
||||
}
|
||||
if (options.sourceSession !== undefined) {
|
||||
const sessionPath = join(legacyHome, ...RETIRED_SESSION_SEGMENTS)
|
||||
mkdirSync(join(sessionPath, '..'), { recursive: true })
|
||||
writeFileSync(sessionPath, options.sourceSession)
|
||||
}
|
||||
|
||||
const counterPath = join(root, 'hash-calls')
|
||||
writeFileSync(counterPath, '0')
|
||||
installDrainInterferenceShims(binDir, options)
|
||||
if (options.killDuringSessionCommit) {
|
||||
const rmShimPath = join(binDir, 'rm')
|
||||
writeFileSync(
|
||||
rmShimPath,
|
||||
`#!/usr/bin/env node
|
||||
const { spawnSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const args = process.argv.slice(2)
|
||||
const result = spawnSync('/bin/rm', args, { stdio: 'inherit' })
|
||||
const target = args.at(-1) ?? ''
|
||||
if (
|
||||
result.status === 0 &&
|
||||
fs.existsSync(process.env.SESSION_COMMIT_MARKER) &&
|
||||
target.includes('/account/sessions/') &&
|
||||
target.includes('.orca-bridge-')
|
||||
) {
|
||||
const parent = spawnSync('/bin/ps', ['-o', 'ppid=', '-p', String(process.ppid)], {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
process.kill(Number(parent.stdout.trim()), 'SIGKILL')
|
||||
}
|
||||
process.exit(result.status ?? 1)
|
||||
`
|
||||
)
|
||||
chmodSync(rmShimPath, 0o755)
|
||||
}
|
||||
if (
|
||||
options.crossFilesystemBridge ||
|
||||
options.killBeforeDestinationRecoveryLink ||
|
||||
options.killAfterSessionLink ||
|
||||
options.replaceTargetAfterSessionLink ||
|
||||
options.rewriteQuarantineAfterSessionLink ||
|
||||
options.rewriteSourceAfterSessionLink ||
|
||||
options.rewriteTargetAfterSessionLink
|
||||
) {
|
||||
const lnShimPath = join(binDir, 'ln')
|
||||
writeFileSync(
|
||||
lnShimPath,
|
||||
`#!/usr/bin/env node
|
||||
const { spawnSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const args = process.argv.slice(2)
|
||||
if (
|
||||
process.env.KILL_DESTINATION_RECOVERY === '1' &&
|
||||
args.at(-1)?.endsWith('.orca-drain-destination')
|
||||
) {
|
||||
process.kill(process.ppid, 'SIGKILL')
|
||||
process.exit(1)
|
||||
}
|
||||
if (
|
||||
process.env.CROSS_FILESYSTEM_BRIDGE === '1' &&
|
||||
args.at(-2)?.includes('.orca-drain-session-stage') &&
|
||||
args.at(-1)?.includes('.orca-bridge-')
|
||||
) {
|
||||
process.exit(1)
|
||||
}
|
||||
const result = spawnSync('/bin/ln', args, { stdio: 'inherit' })
|
||||
if (
|
||||
result.status === 0 &&
|
||||
args.at(-1)?.includes('/account/sessions/') &&
|
||||
args.at(-1)?.endsWith('/retired.jsonl')
|
||||
) {
|
||||
if (process.env.KILL_SESSION_LINK === '1') {
|
||||
const parent = spawnSync('/bin/ps', ['-o', 'ppid=', '-p', String(process.ppid)], {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
process.kill(Number(parent.stdout.trim()), 'SIGKILL')
|
||||
} else if (process.env.REWRITE_AFTER_SESSION_LINK === '1') {
|
||||
if (process.env.REPLACE_TARGET === '1') {
|
||||
const replacement = process.env.REWRITE_SESSION_AUTH + '.replacement'
|
||||
fs.writeFileSync(replacement, process.env.REWRITE_BYTES)
|
||||
fs.renameSync(replacement, process.env.REWRITE_SESSION_AUTH)
|
||||
} else {
|
||||
if (process.env.REWRITE_QUARANTINE === '1') {
|
||||
fs.chmodSync(process.env.REWRITE_SESSION_AUTH, 0o600)
|
||||
}
|
||||
fs.writeFileSync(process.env.REWRITE_SESSION_AUTH, process.env.REWRITE_BYTES)
|
||||
}
|
||||
}
|
||||
}
|
||||
process.exit(result.status ?? 1)
|
||||
`
|
||||
)
|
||||
chmodSync(lnShimPath, 0o755)
|
||||
}
|
||||
|
||||
let status = 0
|
||||
try {
|
||||
execFileSync(
|
||||
'/bin/sh',
|
||||
[
|
||||
'-c',
|
||||
_internals.applyLegacyAuthScript,
|
||||
'sh',
|
||||
legacyHome,
|
||||
join(root, 'absent-active-home'),
|
||||
markerPath,
|
||||
targetHome,
|
||||
sha256(SOURCE_AUTH),
|
||||
sha256(TARGET_AUTH),
|
||||
options.promoteAuth === false ? '0' : '1',
|
||||
options.deleteSource ? '1' : '0',
|
||||
options.sourceCredentials === undefined ? 'missing' : sha256(options.sourceCredentials),
|
||||
'full'
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
HASH_COUNTER: counterPath,
|
||||
REPLACE_ON_HASH_OF: options.replaceTargetOnHashOf ?? '',
|
||||
REPLACE_PATH: targetAuthPath,
|
||||
REPLACE_BYTES: INTRUDER_AUTH,
|
||||
CROSS_FILESYSTEM_BRIDGE: options.crossFilesystemBridge ? '1' : '0',
|
||||
KILL_DESTINATION: options.killAfterDestinationInstall ? '1' : '0',
|
||||
KILL_DESTINATION_RECOVERY: options.killBeforeDestinationRecoveryLink ? '1' : '0',
|
||||
KILL_SESSION_LINK: options.killAfterSessionLink ? '1' : '0',
|
||||
KILL_SOURCE: options.killAfterSourceRemoval ? '1' : '0',
|
||||
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||
SESSION_COMMIT_MARKER: `${markerPath}.orca-drain-session-commit`,
|
||||
REWRITE_AFTER: options.rewriteAfterHashCall ? String(options.rewriteAfterHashCall) : '',
|
||||
REWRITE_AFTER_SESSION_LINK:
|
||||
options.replaceTargetAfterSessionLink ||
|
||||
options.rewriteQuarantineAfterSessionLink ||
|
||||
options.rewriteSourceAfterSessionLink ||
|
||||
options.rewriteTargetAfterSessionLink
|
||||
? '1'
|
||||
: '0',
|
||||
REWRITE_BYTES: options.rewriteBytes ?? TORN_AUTH,
|
||||
REWRITE_QUARANTINE: options.rewriteQuarantineAfterSessionLink ? '1' : '0',
|
||||
REPLACE_TARGET: options.replaceTargetAfterSessionLink ? '1' : '0',
|
||||
REWRITE_SESSION_AUTH:
|
||||
options.rewriteTargetAfterSessionLink || options.replaceTargetAfterSessionLink
|
||||
? targetAuthPath
|
||||
: options.rewriteQuarantineAfterSessionLink
|
||||
? `${markerPath}.orca-drain-live-source`
|
||||
: legacyAuthPath,
|
||||
REWRITE_TARGET:
|
||||
options.rewriteTarget === 'source-credentials'
|
||||
? legacyCredentialsPath
|
||||
: options.rewriteTarget === 'target-auth'
|
||||
? targetAuthPath
|
||||
: legacyAuthPath
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 20_000
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
status = (error as { status?: number }).status ?? -1
|
||||
}
|
||||
if (
|
||||
options.killAfterDestinationInstall ||
|
||||
options.killBeforeDestinationRecoveryLink ||
|
||||
options.killAfterSessionLink ||
|
||||
options.killAfterSourceRemoval ||
|
||||
options.killDuringSessionCommit
|
||||
) {
|
||||
if (options.rewriteQuarantineBeforeRecovery) {
|
||||
const quarantinePath = `${markerPath}.orca-drain-live-source`
|
||||
chmodSync(quarantinePath, 0o600)
|
||||
writeFileSync(quarantinePath, NEWER_AUTH)
|
||||
}
|
||||
if (options.replaceTargetBeforeRecovery) {
|
||||
const replacementPath = `${targetAuthPath}.replacement`
|
||||
writeFileSync(replacementPath, NEWER_AUTH)
|
||||
renameSync(replacementPath, targetAuthPath)
|
||||
}
|
||||
try {
|
||||
execFileSync(
|
||||
'/bin/sh',
|
||||
[
|
||||
'-c',
|
||||
_internals.inspectLegacyAuthScript,
|
||||
'sh',
|
||||
legacyHome,
|
||||
join(root, 'absent-active-home'),
|
||||
markerPath
|
||||
],
|
||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 20_000, windowsHide: true }
|
||||
)
|
||||
} catch {
|
||||
// Recovery succeeds before inspect continues and emits the pending auth payload.
|
||||
}
|
||||
}
|
||||
return {
|
||||
legacyAuth: existsSync(legacyAuthPath) ? readFileSync(legacyAuthPath, 'utf8') : null,
|
||||
markerExists: existsSync(markerPath),
|
||||
status,
|
||||
targetAuth: readFileSync(targetAuthPath, 'utf8'),
|
||||
targetCredentials: existsSync(targetCredentialsPath)
|
||||
? readFileSync(targetCredentialsPath, 'utf8')
|
||||
: null,
|
||||
targetMode: statSync(targetAuthPath).mode & 0o777,
|
||||
targetSession: existsSync(join(targetHome, ...RETIRED_SESSION_SEGMENTS))
|
||||
? readFileSync(join(targetHome, ...RETIRED_SESSION_SEGMENTS), 'utf8')
|
||||
: null,
|
||||
sourceQuarantineAuth: existsSync(`${markerPath}.orca-drain-live-source`)
|
||||
? readFileSync(`${markerPath}.orca-drain-live-source`, 'utf8')
|
||||
: null,
|
||||
sourceRecoveryAuth: existsSync(`${markerPath}.orca-drain-source`)
|
||||
? readFileSync(`${markerPath}.orca-drain-source`, 'utf8')
|
||||
: null,
|
||||
destinationRecoveryAuth: existsSync(`${markerPath}.orca-drain-destination`)
|
||||
? readFileSync(`${markerPath}.orca-drain-destination`, 'utf8')
|
||||
: null,
|
||||
destinationRecoveryPathExists: existsSync(`${markerPath}.orca-drain-destination-path`),
|
||||
sessionCommitMarkerExists: existsSync(`${markerPath}.orca-drain-session-commit`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Why: installs node-backed sha256sum/mv/rm shims on PATH so the real guest script can be
|
||||
// interfered with at exact points - a hash read, an install rename, a source removal.
|
||||
import { chmodSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export function installDrainInterferenceShims(
|
||||
binDir: string,
|
||||
options: { killAfterDestinationInstall?: boolean; killAfterSourceRemoval?: boolean }
|
||||
): void {
|
||||
const shimPath = join(binDir, 'sha256sum')
|
||||
writeFileSync(
|
||||
shimPath,
|
||||
`#!/usr/bin/env node
|
||||
const { createHash } = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const file = process.argv[process.argv.length - 1]
|
||||
process.stdout.write(
|
||||
createHash('sha256').update(fs.readFileSync(file)).digest('hex') + ' ' + file + '\\n'
|
||||
)
|
||||
const calls = Number(fs.readFileSync(process.env.HASH_COUNTER, 'utf8')) + 1
|
||||
fs.writeFileSync(process.env.HASH_COUNTER, String(calls))
|
||||
if (process.env.REWRITE_AFTER && calls === Number(process.env.REWRITE_AFTER)) {
|
||||
fs.writeFileSync(process.env.REWRITE_TARGET, process.env.REWRITE_BYTES)
|
||||
}
|
||||
// Why: an atomic rename leaves the pinned hard link on the OLD inode, so its hash still
|
||||
// matches while the path now resolves elsewhere. Only an inode identity check sees that.
|
||||
if (process.env.REPLACE_ON_HASH_OF && file.includes(process.env.REPLACE_ON_HASH_OF)) {
|
||||
const staged = process.env.REPLACE_PATH + '.intruder'
|
||||
fs.writeFileSync(staged, process.env.REPLACE_BYTES)
|
||||
fs.renameSync(staged, process.env.REPLACE_PATH)
|
||||
}
|
||||
`
|
||||
)
|
||||
chmodSync(shimPath, 0o755)
|
||||
if (options.killAfterDestinationInstall || options.killAfterSourceRemoval) {
|
||||
const mvShimPath = join(binDir, 'mv')
|
||||
writeFileSync(
|
||||
mvShimPath,
|
||||
`#!/usr/bin/env node
|
||||
const { spawnSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const args = process.argv.slice(2)
|
||||
const result = spawnSync('/bin/mv', args, { stdio: 'inherit' })
|
||||
const from = args.at(-2) ?? ''
|
||||
const to = args.at(-1) ?? ''
|
||||
const sourceInstalled =
|
||||
process.env.KILL_SOURCE === '1' &&
|
||||
from.endsWith('/legacy/auth.json') &&
|
||||
to.endsWith('.orca-drain-live-source')
|
||||
const destinationInstalled =
|
||||
process.env.KILL_DESTINATION === '1' &&
|
||||
from.includes('/account/auth.json.orca-drain-snapshot-') &&
|
||||
to.endsWith('/account/auth.json')
|
||||
if (result.status === 0 && (sourceInstalled || destinationInstalled)) {
|
||||
process.kill(process.ppid, 'SIGKILL')
|
||||
}
|
||||
process.exit(result.status ?? 1)
|
||||
`
|
||||
)
|
||||
chmodSync(mvShimPath, 0o755)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Why: the interference knobs and the observable result surface of a real apply-script run,
|
||||
// kept beside the harness so neither module carries both the contract and the machinery.
|
||||
export type DrainApplyInterference = {
|
||||
crossFilesystemBridge?: boolean
|
||||
deleteSource?: boolean
|
||||
killAfterDestinationInstall?: boolean
|
||||
killBeforeDestinationRecoveryLink?: boolean
|
||||
killAfterSessionLink?: boolean
|
||||
killAfterSourceRemoval?: boolean
|
||||
killDuringSessionCommit?: boolean
|
||||
promoteAuth?: boolean
|
||||
replaceTargetAfterSessionLink?: boolean
|
||||
replaceTargetOnHashOf?: string
|
||||
replaceTargetBeforeRecovery?: boolean
|
||||
rewriteAfterHashCall?: number
|
||||
rewriteBytes?: string
|
||||
rewriteQuarantineAfterSessionLink?: boolean
|
||||
rewriteQuarantineBeforeRecovery?: boolean
|
||||
rewriteSourceAfterSessionLink?: boolean
|
||||
rewriteTargetAfterSessionLink?: boolean
|
||||
rewriteTarget?: 'source-auth' | 'source-credentials' | 'target-auth'
|
||||
sourceAuthSymlink?: boolean
|
||||
sourceSession?: string
|
||||
sourceCredentials?: string
|
||||
}
|
||||
|
||||
export type DrainApplyOutcome = {
|
||||
legacyAuth: string | null
|
||||
markerExists: boolean
|
||||
status: number
|
||||
targetAuth: string
|
||||
targetCredentials: string | null
|
||||
targetMode: number
|
||||
targetSession: string | null
|
||||
sourceQuarantineAuth: string | null
|
||||
sourceRecoveryAuth: string | null
|
||||
destinationRecoveryAuth: string | null
|
||||
destinationRecoveryPathExists: boolean
|
||||
sessionCommitMarkerExists: boolean
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import {
|
||||
LEGACY_HOME_ABSENT_EXIT,
|
||||
MARKER_PRESENT_EXIT,
|
||||
SOURCE_AUTH_ABSENT_EXIT
|
||||
} from './legacy-wsl-runtime-auth-drain-exit-codes'
|
||||
import {
|
||||
DISCARD_DESTINATION_RECOVERY_COMMAND,
|
||||
RECOVER_DESTINATION_AUTH_COMMAND,
|
||||
RESOLVE_LEGACY_HOME_SCRIPT,
|
||||
RETIRED_SESSION_BRIDGE_COMMAND,
|
||||
RETIRED_RECENT_SESSION_BRIDGE_COMMAND,
|
||||
ROLLBACK_SESSION_LINKS_FUNCTION
|
||||
} from './legacy-wsl-runtime-auth-drain-shell-commands'
|
||||
export * from './legacy-wsl-runtime-auth-drain-exit-codes'
|
||||
export { FINALIZE_ABSENT_AUTH_SCRIPT } from './legacy-wsl-runtime-auth-finalize-script'
|
||||
export const INSPECT_LEGACY_AUTH_SCRIPT = `
|
||||
set -eu
|
||||
source_recovery_auth="$3.orca-drain-source"
|
||||
source_quarantine_auth="$3.orca-drain-live-source"
|
||||
destination_recovery_auth="$3.orca-drain-destination"
|
||||
destination_recovery_path="$3.orca-drain-destination-path"
|
||||
session_link_manifest="$3.orca-drain-session-links"
|
||||
session_commit_marker="$3.orca-drain-session-commit"
|
||||
session_stage_root="$3.orca-drain-session-stage"
|
||||
${ROLLBACK_SESSION_LINKS_FUNCTION}
|
||||
${RESOLVE_LEGACY_HOME_SCRIPT}
|
||||
source_auth="$legacy_home/auth.json"
|
||||
if [ -e "$3" ] || [ -L "$3" ]; then
|
||||
[ -f "$3" ] && [ ! -L "$3" ] || exit 46
|
||||
commit_session_links || exit 46
|
||||
if [ -f "$destination_recovery_auth" ] && [ ! -L "$destination_recovery_auth" ]; then
|
||||
chmod 600 "$destination_recovery_auth"
|
||||
fi
|
||||
rm -f -- "$source_recovery_auth" "$source_quarantine_auth" "$destination_recovery_auth" "$destination_recovery_path"
|
||||
if [ ! -e "$source_auth" ] && [ ! -L "$source_auth" ]; then
|
||||
exit ${MARKER_PRESENT_EXIT}
|
||||
fi
|
||||
[ -f "$source_auth" ] && [ ! -L "$source_auth" ] || exit 46
|
||||
rm -- "$3"
|
||||
fi
|
||||
rollback_session_links
|
||||
if [ "$legacy_home_resolved" = 0 ]; then
|
||||
exit ${LEGACY_HOME_ABSENT_EXIT}
|
||||
fi
|
||||
if [ ! -e "$source_auth" ] && [ ! -L "$source_auth" ]; then
|
||||
if [ -f "$source_recovery_auth" ] && [ ! -L "$source_recovery_auth" ]; then
|
||||
mv -- "$source_recovery_auth" "$source_auth"
|
||||
chmod 600 "$source_auth"
|
||||
elif [ -f "$source_quarantine_auth" ] && [ ! -L "$source_quarantine_auth" ]; then
|
||||
mv -- "$source_quarantine_auth" "$source_auth"
|
||||
chmod 600 "$source_auth"
|
||||
elif [ -e "$source_recovery_auth" ] || [ -L "$source_recovery_auth" ]; then
|
||||
exit 46
|
||||
fi
|
||||
fi
|
||||
if [ -e "$destination_recovery_auth" ] || [ -L "$destination_recovery_auth" ]; then
|
||||
[ -f "$destination_recovery_auth" ] && [ ! -L "$destination_recovery_auth" ] || exit 46
|
||||
[ -f "$destination_recovery_path" ] && [ ! -L "$destination_recovery_path" ] || exit 46
|
||||
${RECOVER_DESTINATION_AUTH_COMMAND} || exit 46
|
||||
elif [ -e "$destination_recovery_path" ] || [ -L "$destination_recovery_path" ]; then
|
||||
[ -f "$destination_recovery_path" ] && [ ! -L "$destination_recovery_path" ] || exit 46
|
||||
rm -- "$destination_recovery_path"
|
||||
fi
|
||||
if [ ! -e "$source_auth" ] && [ ! -L "$source_auth" ]; then
|
||||
exit ${SOURCE_AUTH_ABSENT_EXIT}
|
||||
fi
|
||||
[ -f "$source_auth" ] && [ ! -L "$source_auth" ] || exit 46
|
||||
encode_file() {
|
||||
encoded=$(base64 < "$1") || return 1
|
||||
printf '%s' "$encoded" | tr -d '\n'
|
||||
}
|
||||
encode_file "$source_auth"
|
||||
printf '\n'
|
||||
source_credentials="$legacy_home/.credentials.json"
|
||||
if [ -f "$source_credentials" ] && [ ! -L "$source_credentials" ]; then
|
||||
printf 'present\n'
|
||||
encode_file "$source_credentials"
|
||||
printf '\n'
|
||||
elif [ ! -e "$source_credentials" ] && [ ! -L "$source_credentials" ]; then
|
||||
printf 'missing\n\n'
|
||||
else
|
||||
exit 44
|
||||
fi
|
||||
`
|
||||
|
||||
export const APPLY_LEGACY_AUTH_SCRIPT = `
|
||||
set -eu
|
||||
source_recovery_auth="$3.orca-drain-source"
|
||||
source_quarantine_auth="$3.orca-drain-live-source"
|
||||
destination_recovery_auth="$3.orca-drain-destination"
|
||||
destination_recovery_path="$3.orca-drain-destination-path"
|
||||
session_link_manifest="$3.orca-drain-session-links"
|
||||
session_commit_marker="$3.orca-drain-session-commit"
|
||||
session_stage_root="$3.orca-drain-session-stage"
|
||||
session_scan_watermark="$3.orca-drain-session-watermark"
|
||||
${ROLLBACK_SESSION_LINKS_FUNCTION}
|
||||
if [ -e "$3" ] || [ -L "$3" ]; then
|
||||
[ -f "$3" ] && [ ! -L "$3" ] || exit 46
|
||||
commit_session_links || exit 46
|
||||
if [ -f "$destination_recovery_auth" ] && [ ! -L "$destination_recovery_auth" ]; then
|
||||
chmod 600 "$destination_recovery_auth"
|
||||
fi
|
||||
rm -f -- "$source_recovery_auth" "$source_quarantine_auth" "$destination_recovery_auth" "$destination_recovery_path"
|
||||
exit 0
|
||||
fi
|
||||
${RESOLVE_LEGACY_HOME_SCRIPT}
|
||||
target_home=$(readlink -f -- "$4") || exit 33
|
||||
[ "$legacy_home" != "$target_home" ] || exit 34
|
||||
source_auth="$legacy_home/auth.json"
|
||||
target_auth="$target_home/auth.json"
|
||||
if [ ! -e "$source_auth" ] && [ ! -L "$source_auth" ]; then exit 35; fi
|
||||
[ -f "$source_auth" ] && [ ! -L "$source_auth" ] || exit 46
|
||||
if [ ! -e "$target_auth" ] && [ ! -L "$target_auth" ]; then exit 36; fi
|
||||
[ -f "$target_auth" ] && [ ! -L "$target_auth" ] || exit 46
|
||||
hash_file() { sha256sum -- "$1" | cut -d ' ' -f 1; }
|
||||
[ "$(hash_file "$source_auth")" = "$5" ] || exit 37
|
||||
[ "$(hash_file "$target_auth")" = "$6" ] || exit 38
|
||||
if [ -e "$source_quarantine_auth" ] || [ -L "$source_quarantine_auth" ]; then
|
||||
[ -f "$source_quarantine_auth" ] && [ ! -L "$source_quarantine_auth" ] || exit 46
|
||||
rm -- "$source_quarantine_auth"
|
||||
fi
|
||||
if [ -e "$destination_recovery_auth" ] || [ -L "$destination_recovery_auth" ] || [ -e "$destination_recovery_path" ] || [ -L "$destination_recovery_path" ]; then
|
||||
[ -f "$destination_recovery_auth" ] && [ ! -L "$destination_recovery_auth" ] || exit 46
|
||||
[ -f "$destination_recovery_path" ] && [ ! -L "$destination_recovery_path" ] || exit 46
|
||||
${DISCARD_DESTINATION_RECOVERY_COMMAND} || exit 46
|
||||
fi
|
||||
umask 077
|
||||
temporary_auth="$target_auth.orca-drain-$$"
|
||||
temporary_credentials="$target_home/.credentials.json.orca-drain-$$"
|
||||
temporary_previous_auth="$target_auth.orca-drain-previous-$$"
|
||||
temporary_destination_auth="$target_auth.orca-drain-destination-$$"
|
||||
temporary_source_auth="$source_auth.orca-drain-source-$$"
|
||||
temporary_destination_snapshot="$target_auth.orca-drain-snapshot-$$"
|
||||
temporary_destination_path="$3.orca-drain-destination-path-$$"
|
||||
temporary_source_snapshot="$3.orca-drain-source-$$"
|
||||
temporary_marker="$3.orca-drain-$$"
|
||||
temporary_session_scan_watermark="$session_scan_watermark.$$"
|
||||
drain_marker="$3"
|
||||
expected_source_hash="$5"
|
||||
cleanup() {
|
||||
if [ ! -f "$drain_marker" ]; then
|
||||
rollback_session_links
|
||||
else
|
||||
commit_session_links || :
|
||||
fi
|
||||
if [ ! -f "$drain_marker" ] && [ ! -e "$source_auth" ] && [ ! -L "$source_auth" ]; then
|
||||
if [ -f "$source_recovery_auth" ] && [ ! -L "$source_recovery_auth" ] && [ "$(hash_file "$source_recovery_auth")" = "$expected_source_hash" ]; then
|
||||
mv -- "$source_recovery_auth" "$source_auth" || :
|
||||
chmod 600 "$source_auth" || :
|
||||
elif [ -f "$source_quarantine_auth" ] && [ ! -L "$source_quarantine_auth" ] && [ "$(hash_file "$source_quarantine_auth")" = "$expected_source_hash" ]; then
|
||||
mv -- "$source_quarantine_auth" "$source_auth" || :
|
||||
chmod 600 "$source_auth" || :
|
||||
fi
|
||||
elif [ ! -f "$drain_marker" ]; then
|
||||
# A late writer owns the recreated path; retain verified recovery artifacts for retry.
|
||||
:
|
||||
elif [ -f "$drain_marker" ]; then
|
||||
rm -f -- "$source_recovery_auth" "$source_quarantine_auth" "$destination_recovery_path"
|
||||
fi
|
||||
if [ -f "$drain_marker" ]; then
|
||||
if [ -f "$destination_recovery_auth" ] && [ ! -L "$destination_recovery_auth" ]; then
|
||||
chmod 600 "$destination_recovery_auth" || :
|
||||
fi
|
||||
rm -f -- "$destination_recovery_auth" "$destination_recovery_path"
|
||||
elif [ -e "$destination_recovery_auth" ] || [ -L "$destination_recovery_auth" ]; then
|
||||
if [ -f "$destination_recovery_auth" ] && [ ! -L "$destination_recovery_auth" ] && [ -f "$destination_recovery_path" ] && [ ! -L "$destination_recovery_path" ]; then
|
||||
${RECOVER_DESTINATION_AUTH_COMMAND} || :
|
||||
fi
|
||||
elif [ -f "$destination_recovery_path" ] && [ ! -L "$destination_recovery_path" ]; then
|
||||
rm -f -- "$destination_recovery_path"
|
||||
fi
|
||||
if [ -f "$target_auth" ]; then
|
||||
chmod 600 "$target_auth" || :
|
||||
fi
|
||||
rm -f -- "$temporary_auth" "$temporary_credentials" "$temporary_previous_auth" "$temporary_destination_auth" "$temporary_source_auth" "$temporary_destination_snapshot" "$temporary_destination_path" "$temporary_source_snapshot" "$temporary_marker" "$temporary_session_scan_watermark"
|
||||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
if [ "$8" != 1 ]; then
|
||||
session_scan_start=''; session_scan_day=$(date +%Y/%m/%d) || exit 46
|
||||
if [ -f "$session_scan_watermark" ] && [ ! -L "$session_scan_watermark" ]; then
|
||||
[ "\${10}" != recent ] || IFS= read -r session_scan_start < "$session_scan_watermark" || session_scan_start=''
|
||||
elif [ -e "$session_scan_watermark" ] && [ ! -L "$session_scan_watermark" ]; then exit 46
|
||||
fi
|
||||
fi
|
||||
source_credentials="$legacy_home/.credentials.json"
|
||||
target_credentials="$target_home/.credentials.json"
|
||||
if [ -f "$source_credentials" ] && [ ! -e "$target_credentials" ] && [ ! -L "$target_credentials" ]; then
|
||||
[ "$9" != missing ] || exit 43
|
||||
[ "$(hash_file "$source_credentials")" = "$9" ] || exit 43
|
||||
cp -- "$source_credentials" "$temporary_credentials"
|
||||
chmod 600 "$temporary_credentials"
|
||||
[ "$(hash_file "$temporary_credentials")" = "$9" ] || exit 43
|
||||
[ "$(hash_file "$source_credentials")" = "$9" ] || exit 43
|
||||
mv -n -- "$temporary_credentials" "$target_credentials"
|
||||
elif [ "$9" = missing ] && [ ! -e "$target_credentials" ] && [ ! -L "$target_credentials" ]; then
|
||||
[ ! -e "$source_credentials" ] && [ ! -L "$source_credentials" ] || exit 43
|
||||
fi
|
||||
if [ "$7" = 1 ]; then
|
||||
cp -- "$source_auth" "$temporary_auth"
|
||||
chmod 600 "$temporary_auth"
|
||||
# Codex rewrites auth.json in place, so this copy is a second read: verify the
|
||||
# bytes being promoted, not the ones freshness was judged on.
|
||||
[ "$(hash_file "$temporary_auth")" = "$5" ] || exit 42
|
||||
[ "$(hash_file "$target_auth")" = "$6" ] || exit 39
|
||||
# The hard link keeps the destination inode observable without creating a
|
||||
# missing-path crash window. In-place writers update both names.
|
||||
ln -- "$target_auth" "$temporary_previous_auth"
|
||||
[ "$(hash_file "$temporary_previous_auth")" = "$6" ] || exit 39
|
||||
mv -f -- "$temporary_auth" "$target_auth"
|
||||
if [ "$(hash_file "$temporary_previous_auth")" != "$6" ]; then
|
||||
mv -f -- "$temporary_previous_auth" "$target_auth"
|
||||
exit 39
|
||||
fi
|
||||
rm -- "$temporary_previous_auth"
|
||||
fi
|
||||
if [ "$8" != 1 ]; then
|
||||
expected_target_hash="$6"
|
||||
[ "$7" != 1 ] || expected_target_hash="$5"
|
||||
# Keep both live auth inodes observable while links are staged, then prove
|
||||
# the paths still name those identities before publishing the bridge.
|
||||
ln -- "$source_auth" "$temporary_source_auth"
|
||||
ln -- "$target_auth" "$temporary_destination_auth"
|
||||
[ "$(hash_file "$temporary_source_auth")" = "$5" ] || exit 40
|
||||
[ "$(hash_file "$temporary_destination_auth")" = "$expected_target_hash" ] || exit 45
|
||||
[ "$source_auth" -ef "$temporary_source_auth" ] || exit 40
|
||||
[ "$target_auth" -ef "$temporary_destination_auth" ] || exit 45
|
||||
if [ "\${10}" = full ]; then
|
||||
${RETIRED_SESSION_BRIDGE_COMMAND}
|
||||
elif [ "\${10}" = recent ]; then
|
||||
case "$session_scan_start" in
|
||||
????/??/??) ${RETIRED_RECENT_SESSION_BRIDGE_COMMAND} ;;
|
||||
*) ${RETIRED_SESSION_BRIDGE_COMMAND} ;;
|
||||
esac
|
||||
else
|
||||
exit 46
|
||||
fi
|
||||
[ "$(hash_file "$temporary_source_auth")" = "$5" ] || exit 40
|
||||
[ "$(hash_file "$temporary_destination_auth")" = "$expected_target_hash" ] || exit 45
|
||||
[ "$source_auth" -ef "$temporary_source_auth" ] || exit 40
|
||||
[ "$target_auth" -ef "$temporary_destination_auth" ] || exit 45
|
||||
rm -- "$temporary_source_auth" "$temporary_destination_auth"
|
||||
commit_session_links
|
||||
printf '%s\n' "$session_scan_day" > "$temporary_session_scan_watermark"
|
||||
chmod 600 "$temporary_session_scan_watermark"
|
||||
mv -f -- "$temporary_session_scan_watermark" "$session_scan_watermark"
|
||||
fi
|
||||
if [ "$8" = 1 ]; then
|
||||
expected_target_hash="$6"
|
||||
[ "$7" != 1 ] || expected_target_hash="$5"
|
||||
# Pin the live inode and stage independent snapshots before retiring the source.
|
||||
ln -- "$target_auth" "$temporary_destination_auth"
|
||||
[ "$(hash_file "$temporary_destination_auth")" = "$expected_target_hash" ] || exit 45
|
||||
[ "$target_auth" -ef "$temporary_destination_auth" ] || exit 45
|
||||
cp -- "$target_auth" "$temporary_destination_snapshot"
|
||||
chmod 400 "$temporary_destination_snapshot"
|
||||
[ "$(hash_file "$temporary_destination_snapshot")" = "$expected_target_hash" ] || exit 45
|
||||
cp -- "$source_auth" "$temporary_source_snapshot"
|
||||
chmod 400 "$temporary_source_snapshot"
|
||||
[ "$(hash_file "$temporary_source_snapshot")" = "$5" ] || exit 40
|
||||
[ "$(hash_file "$source_auth")" = "$5" ] || exit 40
|
||||
mv -f -- "$temporary_source_snapshot" "$source_recovery_auth"
|
||||
[ "$(hash_file "$source_recovery_auth")" = "$5" ] || exit 40
|
||||
printf '%s\\0' "$target_auth" > "$temporary_destination_path"
|
||||
chmod 600 "$temporary_destination_path"
|
||||
mv -f -- "$temporary_destination_path" "$destination_recovery_path"
|
||||
ln -- "$temporary_destination_snapshot" "$destination_recovery_auth"
|
||||
[ "$temporary_destination_snapshot" -ef "$destination_recovery_auth" ] || exit 45
|
||||
[ "$(hash_file "$destination_recovery_auth")" = "$expected_target_hash" ] || exit 45
|
||||
[ "$(hash_file "$temporary_destination_auth")" = "$expected_target_hash" ] || exit 45
|
||||
[ "$target_auth" -ef "$temporary_destination_auth" ] || exit 45
|
||||
[ "$(hash_file "$temporary_destination_snapshot")" = "$expected_target_hash" ] || exit 45
|
||||
# The atomic replacement detaches the destination path from any writer that
|
||||
# already opened the old inode; read-only mode blocks new writers until commit.
|
||||
mv -f -- "$temporary_destination_snapshot" "$target_auth"
|
||||
if [ "$(hash_file "$temporary_destination_auth")" != "$expected_target_hash" ]; then
|
||||
mv -f -- "$temporary_destination_auth" "$target_auth"
|
||||
exit 45
|
||||
fi
|
||||
[ "$(hash_file "$target_auth")" = "$expected_target_hash" ] || exit 45
|
||||
[ "$target_auth" -ef "$destination_recovery_auth" ] || exit 45
|
||||
[ "$(hash_file "$destination_recovery_auth")" = "$expected_target_hash" ] || exit 45
|
||||
[ "$(hash_file "$source_auth")" = "$5" ] || exit 40
|
||||
mv -- "$source_auth" "$source_quarantine_auth"
|
||||
chmod 400 "$source_quarantine_auth"
|
||||
[ "$(hash_file "$source_quarantine_auth")" = "$5" ] || exit 40
|
||||
${RETIRED_SESSION_BRIDGE_COMMAND}
|
||||
[ ! -e "$source_auth" ] && [ ! -L "$source_auth" ] || exit 40
|
||||
[ "$(hash_file "$source_quarantine_auth")" = "$5" ] || exit 40
|
||||
[ "$(hash_file "$target_auth")" = "$expected_target_hash" ] || exit 45
|
||||
[ "$target_auth" -ef "$destination_recovery_auth" ] || exit 45
|
||||
[ "$(hash_file "$destination_recovery_auth")" = "$expected_target_hash" ] || exit 45
|
||||
commit_session_links
|
||||
rm -- "$temporary_destination_auth"
|
||||
printf '%s\n' '{"completed":true}' > "$temporary_marker"
|
||||
chmod 600 "$temporary_marker"
|
||||
mv -f -- "$temporary_marker" "$3"
|
||||
chmod 600 "$destination_recovery_auth"
|
||||
rm -- "$source_recovery_auth" "$source_quarantine_auth" "$destination_recovery_auth" "$destination_recovery_path"
|
||||
fi
|
||||
`
|
||||
@@ -0,0 +1,90 @@
|
||||
import { quotePosixShell } from '../../shared/wsl-login-shell-command'
|
||||
import { WSL_CODEX_SESSION_BRIDGE_SCRIPT } from '../codex/wsl-codex-session-bridge-script'
|
||||
|
||||
export const RETIRED_SESSION_BRIDGE_COMMAND = `bash -c ${quotePosixShell(WSL_CODEX_SESSION_BRIDGE_SCRIPT)} bash "$legacy_home/sessions" "$target_home/sessions" "$session_link_manifest" "$session_stage_root"`
|
||||
export const RETIRED_RECENT_SESSION_BRIDGE_COMMAND = `${RETIRED_SESSION_BRIDGE_COMMAND} recent "$session_scan_start" "$session_scan_day"`
|
||||
const ROLLBACK_SESSION_LINKS_COMMAND = `bash -c ${quotePosixShell(`while IFS= read -r -d '' staged_file && IFS= read -r -d '' target_file; do
|
||||
if [ -e "$target_file" ] || [ -L "$target_file" ]; then
|
||||
[ -f "$staged_file" ] && [ ! -L "$staged_file" ] && [ "$target_file" -ef "$staged_file" ] || exit 1
|
||||
rm -- "$target_file" || exit 1
|
||||
fi
|
||||
if [ -e "$staged_file" ] || [ -L "$staged_file" ]; then
|
||||
[ -f "$staged_file" ] && [ ! -L "$staged_file" ] || exit 1
|
||||
rm -- "$staged_file" || exit 1
|
||||
fi
|
||||
done < "$1"`)} bash`
|
||||
|
||||
const COMMIT_SESSION_LINKS_COMMAND = `bash -c ${quotePosixShell(`while IFS= read -r -d '' staged_file && IFS= read -r -d '' target_file; do
|
||||
if [ -e "$staged_file" ] || [ -L "$staged_file" ]; then
|
||||
[ -f "$staged_file" ] && [ ! -L "$staged_file" ] || exit 1
|
||||
rm -- "$staged_file" || exit 1
|
||||
fi
|
||||
done < "$1"`)} bash`
|
||||
|
||||
export const RECOVER_DESTINATION_AUTH_COMMAND = `bash -c ${quotePosixShell(`IFS= read -r -d '' target_auth < "$1" || exit 1
|
||||
case "$target_auth" in /*) ;; *) exit 1 ;; esac
|
||||
if [ ! -e "$target_auth" ] && [ ! -L "$target_auth" ]; then
|
||||
mv -- "$2" "$target_auth" || exit 1
|
||||
chmod 600 "$target_auth" || exit 1
|
||||
rm -- "$1" || exit 1
|
||||
elif [ -f "$target_auth" ] && [ ! -L "$target_auth" ] && [ "$target_auth" -ef "$2" ]; then
|
||||
chmod 600 "$target_auth" || exit 1
|
||||
rm -- "$2" "$1" || exit 1
|
||||
else
|
||||
chmod 600 "$2" || exit 1
|
||||
fi`)} bash "$destination_recovery_path" "$destination_recovery_auth"`
|
||||
|
||||
export const DISCARD_DESTINATION_RECOVERY_COMMAND = `bash -c ${quotePosixShell(`IFS= read -r -d '' recorded_target < "$1" || exit 1
|
||||
[ "$recorded_target" = "$3" ] || exit 1
|
||||
chmod 600 "$2" "$3" || exit 1
|
||||
rm -- "$2" "$1" || exit 1`)} bash "$destination_recovery_path" "$destination_recovery_auth" "$target_auth"`
|
||||
|
||||
export const ROLLBACK_SESSION_LINKS_FUNCTION = `
|
||||
rollback_session_links() {
|
||||
if [ -e "$session_commit_marker" ] || [ -L "$session_commit_marker" ]; then
|
||||
[ -f "$session_commit_marker" ] && [ ! -L "$session_commit_marker" ] || return 1
|
||||
commit_session_links || return 1
|
||||
return 0
|
||||
fi
|
||||
if [ -f "$session_link_manifest" ] && [ ! -L "$session_link_manifest" ]; then
|
||||
${ROLLBACK_SESSION_LINKS_COMMAND} "$session_link_manifest" || return 1
|
||||
rm -- "$session_link_manifest" || return 1
|
||||
elif [ -e "$session_link_manifest" ] || [ -L "$session_link_manifest" ]; then
|
||||
return 1
|
||||
fi
|
||||
rm -rf -- "$session_stage_root" || return 1
|
||||
}
|
||||
|
||||
commit_session_links() {
|
||||
if [ ! -e "$session_commit_marker" ] && [ ! -L "$session_commit_marker" ]; then
|
||||
: > "$session_commit_marker" || return 1
|
||||
fi
|
||||
[ -f "$session_commit_marker" ] && [ ! -L "$session_commit_marker" ] || return 1
|
||||
if [ -f "$session_link_manifest" ] && [ ! -L "$session_link_manifest" ]; then
|
||||
${COMMIT_SESSION_LINKS_COMMAND} "$session_link_manifest" || return 1
|
||||
rm -- "$session_link_manifest" || return 1
|
||||
elif [ -e "$session_link_manifest" ] || [ -L "$session_link_manifest" ]; then
|
||||
return 1
|
||||
fi
|
||||
rm -rf -- "$session_stage_root" || return 1
|
||||
rm -- "$session_commit_marker" || return 1
|
||||
}
|
||||
`
|
||||
|
||||
export const RESOLVE_LEGACY_HOME_SCRIPT = `
|
||||
legacy_home="$1"
|
||||
legacy_home_resolved=0
|
||||
if [ -e "$1" ] || [ -L "$1" ]; then
|
||||
legacy_home=$(readlink -f -- "$1") || exit 30
|
||||
legacy_home_resolved=1
|
||||
fi
|
||||
if [ -e "$2" ] || [ -L "$2" ]; then
|
||||
active_home=$(readlink -f -- "$2") || exit 31
|
||||
if [ "$legacy_home_resolved" = 1 ]; then
|
||||
[ "$active_home" = "$legacy_home" ] || exit 32
|
||||
else
|
||||
legacy_home="$active_home"
|
||||
legacy_home_resolved=1
|
||||
fi
|
||||
fi
|
||||
`
|
||||
@@ -0,0 +1,307 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { runWslProcessMock } = vi.hoisted(() => ({ runWslProcessMock: vi.fn() }))
|
||||
|
||||
vi.mock('../wsl/wsl-runner', () => ({
|
||||
runWslProcess: runWslProcessMock
|
||||
}))
|
||||
|
||||
import {
|
||||
_internals,
|
||||
drainLegacyWslRuntimeAuth,
|
||||
startLegacyWslRuntimeAuthDrain
|
||||
} from './legacy-wsl-runtime-auth-drain'
|
||||
import { readWslCodexAuths } from './wsl-codex-auth-batch-reader'
|
||||
|
||||
const SOURCE_AUTH = '{"tokens":{"expires_at":2000}}\n'
|
||||
const STALE_AUTH = '{"tokens":{"expires_at":1000}}\n'
|
||||
const NEWER_AUTH = '{"tokens":{"expires_at":3000}}\n'
|
||||
|
||||
function inspection(auth: string, credentials?: string): string {
|
||||
return [
|
||||
Buffer.from(auth).toString('base64'),
|
||||
credentials === undefined ? 'missing' : 'present',
|
||||
credentials === undefined ? '' : Buffer.from(credentials).toString('base64')
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function result(code: number, stdout = '') {
|
||||
return {
|
||||
code,
|
||||
stdout,
|
||||
stderr: '',
|
||||
timedOut: false,
|
||||
environmentResolved: true
|
||||
}
|
||||
}
|
||||
|
||||
describe('legacy WSL runtime auth drain', () => {
|
||||
beforeEach(() => {
|
||||
runWslProcessMock.mockReset()
|
||||
_internals.resetDrainQueue()
|
||||
})
|
||||
|
||||
it('promotes fresher auth guest-side while a legacy pane remains', async () => {
|
||||
runWslProcessMock
|
||||
.mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH)))
|
||||
.mockResolvedValueOnce(result(0))
|
||||
const resolveDestination = vi.fn(() => ({
|
||||
authContents: STALE_AUTH,
|
||||
linuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home'
|
||||
}))
|
||||
await drainLegacyWslRuntimeAuth({
|
||||
distro: 'Ubuntu',
|
||||
guestHomeLinuxPath: '/home/alice',
|
||||
legacyPanePresent: true,
|
||||
resolveDestination
|
||||
})
|
||||
|
||||
expect(resolveDestination).toHaveBeenCalledWith(SOURCE_AUTH)
|
||||
expect(runWslProcessMock).toHaveBeenCalledTimes(2)
|
||||
expect(runWslProcessMock.mock.calls[1]?.[0].args.slice(3)).toEqual([
|
||||
'/home/alice/.local/share/orca/codex-accounts/account-1/home',
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
'1',
|
||||
'0',
|
||||
'missing',
|
||||
'full'
|
||||
])
|
||||
expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('readlink -f')
|
||||
expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('source_credentials=')
|
||||
expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('chmod 600')
|
||||
expect(runWslProcessMock.mock.calls[1]?.[0].timeoutMs).toBe(30_000)
|
||||
})
|
||||
|
||||
it('bridges sessions without changing auth when freshness cannot be proven', async () => {
|
||||
runWslProcessMock
|
||||
.mockResolvedValueOnce(result(0, inspection('{"tokens":{}}\n')))
|
||||
.mockResolvedValueOnce(result(0))
|
||||
|
||||
await drainLegacyWslRuntimeAuth({
|
||||
distro: 'Ubuntu',
|
||||
guestHomeLinuxPath: '/home/alice',
|
||||
legacyPanePresent: false,
|
||||
resolveDestination: () => ({
|
||||
authContents: '{"tokens":{}}\n',
|
||||
linuxHomePath: '/home/alice/.codex'
|
||||
})
|
||||
})
|
||||
|
||||
expect(runWslProcessMock).toHaveBeenCalledTimes(2)
|
||||
expect(runWslProcessMock.mock.calls[1]?.[0].args.slice(-4)).toEqual([
|
||||
'0',
|
||||
'0',
|
||||
'missing',
|
||||
'full'
|
||||
])
|
||||
})
|
||||
|
||||
it('refuses a source with no unique destination', async () => {
|
||||
runWslProcessMock.mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH)))
|
||||
|
||||
await drainLegacyWslRuntimeAuth({
|
||||
distro: 'Ubuntu',
|
||||
guestHomeLinuxPath: '/home/alice',
|
||||
legacyPanePresent: false,
|
||||
resolveDestination: () => null
|
||||
})
|
||||
|
||||
expect(runWslProcessMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retires stale legacy auth only after the last recorded pane exits', async () => {
|
||||
runWslProcessMock
|
||||
.mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH)))
|
||||
.mockResolvedValueOnce(result(0))
|
||||
|
||||
await drainLegacyWslRuntimeAuth({
|
||||
distro: 'Ubuntu',
|
||||
guestHomeLinuxPath: '/home/alice',
|
||||
legacyPanePresent: false,
|
||||
resolveDestination: () => ({
|
||||
authContents: NEWER_AUTH,
|
||||
linuxHomePath: '/home/alice/.codex'
|
||||
})
|
||||
})
|
||||
|
||||
expect(runWslProcessMock.mock.calls[1]?.[0].args.slice(-4)).toEqual([
|
||||
'0',
|
||||
'1',
|
||||
'missing',
|
||||
'full'
|
||||
])
|
||||
expect(runWslProcessMock.mock.calls[1]?.[0].timeoutMs).toBe(30_000)
|
||||
})
|
||||
|
||||
it('recovers a failed guest apply before resolving the drain as pending', async () => {
|
||||
runWslProcessMock
|
||||
.mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH)))
|
||||
.mockResolvedValueOnce(result(45))
|
||||
.mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH)))
|
||||
|
||||
await expect(
|
||||
drainLegacyWslRuntimeAuth({
|
||||
distro: 'Ubuntu',
|
||||
guestHomeLinuxPath: '/home/alice',
|
||||
legacyPanePresent: false,
|
||||
resolveDestination: () => ({
|
||||
authContents: NEWER_AUTH,
|
||||
linuxHomePath: '/home/alice/.codex'
|
||||
})
|
||||
})
|
||||
).resolves.toBe('pending')
|
||||
|
||||
expect(runWslProcessMock).toHaveBeenCalledTimes(3)
|
||||
expect(runWslProcessMock.mock.calls[2]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
script: _internals.inspectLegacyAuthScript,
|
||||
timeoutMs: 5_000
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an awaited launch drain when guest recovery cannot finish', async () => {
|
||||
runWslProcessMock
|
||||
.mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH)))
|
||||
.mockResolvedValueOnce(result(45))
|
||||
.mockResolvedValueOnce(result(46))
|
||||
|
||||
await expect(
|
||||
startLegacyWslRuntimeAuthDrain(
|
||||
{
|
||||
distro: 'Ubuntu',
|
||||
guestHomeLinuxPath: '/home/alice',
|
||||
legacyPanePresent: false,
|
||||
resolveDestination: () => ({
|
||||
authContents: NEWER_AUTH,
|
||||
linuxHomePath: '/home/alice/.codex'
|
||||
})
|
||||
},
|
||||
{ throwOnFailure: true }
|
||||
)
|
||||
).rejects.toThrow('Legacy WSL auth drain recover failed')
|
||||
})
|
||||
|
||||
it('keeps an absent source retryable while the legacy home remains', async () => {
|
||||
runWslProcessMock.mockResolvedValueOnce(result(21))
|
||||
|
||||
await drainLegacyWslRuntimeAuth({
|
||||
distro: 'Ubuntu',
|
||||
guestHomeLinuxPath: '/home/alice',
|
||||
legacyPanePresent: false,
|
||||
resolveDestination: vi.fn()
|
||||
})
|
||||
|
||||
expect(runWslProcessMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does nothing after the guest-side completion marker is present', async () => {
|
||||
runWslProcessMock.mockResolvedValueOnce(result(20))
|
||||
const resolveDestination = vi.fn()
|
||||
|
||||
await drainLegacyWslRuntimeAuth({
|
||||
distro: 'Ubuntu',
|
||||
guestHomeLinuxPath: '/home/alice',
|
||||
legacyPanePresent: false,
|
||||
resolveDestination
|
||||
})
|
||||
|
||||
expect(resolveDestination).not.toHaveBeenCalled()
|
||||
expect(runWslProcessMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('marks an absent legacy home complete after every legacy pane exits', async () => {
|
||||
runWslProcessMock.mockResolvedValueOnce(result(22)).mockResolvedValueOnce(result(0))
|
||||
|
||||
await drainLegacyWslRuntimeAuth({
|
||||
distro: 'Ubuntu',
|
||||
guestHomeLinuxPath: '/home/alice',
|
||||
legacyPanePresent: false,
|
||||
resolveDestination: vi.fn()
|
||||
})
|
||||
|
||||
expect(runWslProcessMock).toHaveBeenCalledTimes(2)
|
||||
expect(runWslProcessMock.mock.calls[1]?.[0].args).toEqual([
|
||||
'/home/alice/.local/share/orca/codex-runtime-home/home',
|
||||
'/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home',
|
||||
'/home/alice/.local/share/orca/codex-runtime-home/direct-home-auth-drain-v1.json'
|
||||
])
|
||||
})
|
||||
|
||||
it('coalesces concurrent drain triggers instead of queueing every poll', async () => {
|
||||
runWslProcessMock.mockImplementation(() => new Promise(() => {}))
|
||||
const options = {
|
||||
distro: 'Ubuntu',
|
||||
guestHomeLinuxPath: '/home/alice',
|
||||
legacyPanePresent: true,
|
||||
resolveDestination: () => null
|
||||
}
|
||||
|
||||
startLegacyWslRuntimeAuthDrain(options)
|
||||
startLegacyWslRuntimeAuthDrain(options)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(runWslProcessMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('bounds sequential pending launches to recent session dates', async () => {
|
||||
runWslProcessMock.mockImplementation((options: { script: string }) =>
|
||||
Promise.resolve(
|
||||
options.script === _internals.inspectLegacyAuthScript
|
||||
? result(0, inspection(SOURCE_AUTH))
|
||||
: result(0)
|
||||
)
|
||||
)
|
||||
const options = {
|
||||
distro: 'Ubuntu',
|
||||
guestHomeLinuxPath: '/home/alice',
|
||||
legacyPanePresent: true,
|
||||
resolveDestination: () => ({
|
||||
authContents: STALE_AUTH,
|
||||
linuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home'
|
||||
})
|
||||
}
|
||||
|
||||
await startLegacyWslRuntimeAuthDrain(options, { throwOnFailure: true })
|
||||
await startLegacyWslRuntimeAuthDrain(options, { throwOnFailure: true })
|
||||
await startLegacyWslRuntimeAuthDrain(
|
||||
{ ...options, legacyPanePresent: false },
|
||||
{ throwOnFailure: true }
|
||||
)
|
||||
|
||||
const applyCalls = runWslProcessMock.mock.calls.filter(
|
||||
([call]) => call.script === _internals.applyLegacyAuthScript
|
||||
)
|
||||
expect(applyCalls).toHaveLength(3)
|
||||
expect(applyCalls.map(([call]) => call.args.at(-1))).toEqual(['full', 'recent', 'full'])
|
||||
expect(applyCalls.map(([call]) => call.timeoutMs)).toEqual([30_000, 5_000, 30_000])
|
||||
})
|
||||
|
||||
it('reads every candidate home in one bounded guest process', async () => {
|
||||
runWslProcessMock.mockResolvedValueOnce(
|
||||
result(
|
||||
0,
|
||||
[`present:${Buffer.from(SOURCE_AUTH).toString('base64')}`, 'missing', 'unreadable'].join(
|
||||
'\n'
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
await expect(
|
||||
readWslCodexAuths('Ubuntu', ['/home/alice/.codex-a', '/home/alice/.codex-b', '/bad'])
|
||||
).resolves.toEqual([
|
||||
{ kind: 'present', contents: SOURCE_AUTH },
|
||||
{ kind: 'missing' },
|
||||
{ kind: 'unreadable' }
|
||||
])
|
||||
expect(runWslProcessMock).toHaveBeenCalledTimes(1)
|
||||
expect(runWslProcessMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
args: ['/home/alice/.codex-a', '/home/alice/.codex-b', '/bad'],
|
||||
maxOutputBytes: 2 * 1024 * 1024,
|
||||
timeoutMs: 5_000
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,269 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { posix as pathPosix } from 'node:path'
|
||||
import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env'
|
||||
import { WSL_SESSION_BRIDGE_TIMEOUT_MS } from '../codex/wsl-codex-session-bridge-script'
|
||||
import { runWslProcess } from '../wsl/wsl-runner'
|
||||
import { compareCodexAuthFreshness, codexAuthIsFresher } from './codex-auth-identity'
|
||||
import {
|
||||
APPLY_LEGACY_AUTH_SCRIPT,
|
||||
FINALIZE_ABSENT_AUTH_SCRIPT,
|
||||
INSPECT_LEGACY_AUTH_SCRIPT,
|
||||
LEGACY_HOME_ABSENT_EXIT,
|
||||
LEGACY_HOME_STILL_PRESENT_EXIT,
|
||||
MARKER_PRESENT_EXIT,
|
||||
SOURCE_AUTH_ABSENT_EXIT
|
||||
} from './legacy-wsl-runtime-auth-drain-scripts'
|
||||
import { decodeWslBase64Payload } from './wsl-codex-auth-batch-reader'
|
||||
|
||||
const DRAIN_MARKER_NAME = 'direct-home-auth-drain-v1.json'
|
||||
|
||||
export type LegacyWslRuntimeAuthDestination = {
|
||||
authContents: string
|
||||
linuxHomePath: string
|
||||
}
|
||||
|
||||
type LegacyWslRuntimeInspection = {
|
||||
authContents: string
|
||||
credentials: { kind: 'missing' } | { kind: 'present'; contents: string }
|
||||
}
|
||||
|
||||
type LegacyWslRuntimeAuthDrainOptions = {
|
||||
distro: string
|
||||
guestHomeLinuxPath: string
|
||||
legacyPanePresent: boolean
|
||||
resolveDestination: (
|
||||
runtimeAuthContents: string
|
||||
) => LegacyWslRuntimeAuthDestination | null | Promise<LegacyWslRuntimeAuthDestination | null>
|
||||
}
|
||||
|
||||
const drainQueueByDistro = new Map<string, Promise<void>>()
|
||||
const completedDistroKeys = new Set<string>()
|
||||
const pendingSessionBridgeRouteByDistro = new Map<string, string>()
|
||||
|
||||
export function startLegacyWslRuntimeAuthDrain(
|
||||
options: LegacyWslRuntimeAuthDrainOptions,
|
||||
startOptions: { throwOnFailure?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const key = options.distro.trim().toLowerCase()
|
||||
if (completedDistroKeys.has(key)) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
// Coalesce launch/rate-limit callers while a drain is in flight. Queuing a
|
||||
// new pass for every poll can otherwise build an unbounded promise chain
|
||||
// while a legacy pane keeps the migration pending.
|
||||
const inFlight = drainQueueByDistro.get(key)
|
||||
if (inFlight) {
|
||||
return startOptions.throwOnFailure ? inFlight : logDrainFailure(inFlight)
|
||||
}
|
||||
const next = drainLegacyWslRuntimeAuth(options).then((status) => {
|
||||
if (status === 'complete') {
|
||||
completedDistroKeys.add(key)
|
||||
}
|
||||
})
|
||||
drainQueueByDistro.set(key, next)
|
||||
const clearQueue = (): void => {
|
||||
if (drainQueueByDistro.get(key) === next) {
|
||||
drainQueueByDistro.delete(key)
|
||||
}
|
||||
}
|
||||
void next.then(clearQueue, clearQueue)
|
||||
return startOptions.throwOnFailure ? next : logDrainFailure(next)
|
||||
}
|
||||
|
||||
function logDrainFailure(task: Promise<void>): Promise<void> {
|
||||
return task.catch((error) => {
|
||||
console.warn('[codex-wsl-auth-drain] Failed to drain legacy runtime auth:', error)
|
||||
})
|
||||
}
|
||||
|
||||
export async function drainLegacyWslRuntimeAuth(
|
||||
options: LegacyWslRuntimeAuthDrainOptions
|
||||
): Promise<'complete' | 'pending'> {
|
||||
const distroKey = options.distro.trim().toLowerCase()
|
||||
const paths = resolveLegacyRuntimePaths(options.guestHomeLinuxPath)
|
||||
const inspection = await runWslProcess({
|
||||
distro: options.distro,
|
||||
loginPath: 'none',
|
||||
script: INSPECT_LEGACY_AUTH_SCRIPT,
|
||||
args: [paths.runtimeHome, paths.activeHome, paths.marker],
|
||||
timeoutMs: 5_000,
|
||||
maxOutputBytes: 2 * 1024 * 1024
|
||||
})
|
||||
if (inspection.code === MARKER_PRESENT_EXIT) {
|
||||
return 'complete'
|
||||
}
|
||||
if (inspection.code === LEGACY_HOME_ABSENT_EXIT) {
|
||||
if (!options.legacyPanePresent) {
|
||||
return finalizeAbsentLegacyAuth(options.distro, paths)
|
||||
}
|
||||
return 'pending'
|
||||
}
|
||||
if (inspection.code === SOURCE_AUTH_ABSENT_EXIT) {
|
||||
return 'pending'
|
||||
}
|
||||
assertSuccessfulDrainStep('inspect', inspection)
|
||||
|
||||
const inspected = parseLegacyRuntimeInspection(inspection.stdout)
|
||||
if (!inspected) {
|
||||
return 'pending'
|
||||
}
|
||||
const destination = await options.resolveDestination(inspected.authContents)
|
||||
if (!destination) {
|
||||
return 'pending'
|
||||
}
|
||||
const freshness = compareCodexAuthFreshness(inspected.authContents, destination.authContents)
|
||||
const promoteAuth =
|
||||
freshness !== null && codexAuthIsFresher(inspected.authContents, destination.authContents)
|
||||
const deleteSource = !options.legacyPanePresent && freshness !== null
|
||||
const sessionBridgeRoute = [
|
||||
paths.runtimeHome,
|
||||
destination.linuxHomePath,
|
||||
options.legacyPanePresent ? 'retained' : 'released'
|
||||
].join('\0')
|
||||
const bridgeAllSessions =
|
||||
deleteSource || pendingSessionBridgeRouteByDistro.get(distroKey) !== sessionBridgeRoute
|
||||
const result = await runWslProcess({
|
||||
distro: options.distro,
|
||||
loginPath: 'none',
|
||||
script: APPLY_LEGACY_AUTH_SCRIPT,
|
||||
args: [
|
||||
paths.runtimeHome,
|
||||
paths.activeHome,
|
||||
paths.marker,
|
||||
destination.linuxHomePath,
|
||||
sha256(inspected.authContents),
|
||||
sha256(destination.authContents),
|
||||
promoteAuth ? '1' : '0',
|
||||
deleteSource ? '1' : '0',
|
||||
inspected.credentials.kind === 'present' ? sha256(inspected.credentials.contents) : 'missing',
|
||||
bridgeAllSessions ? 'full' : 'recent'
|
||||
],
|
||||
timeoutMs: bridgeAllSessions ? WSL_SESSION_BRIDGE_TIMEOUT_MS : 5_000,
|
||||
maxOutputBytes: 16 * 1024
|
||||
})
|
||||
try {
|
||||
assertSuccessfulDrainStep('apply', result)
|
||||
} catch {
|
||||
return recoverAfterFailedApply(options.distro, paths)
|
||||
}
|
||||
if (!deleteSource) {
|
||||
pendingSessionBridgeRouteByDistro.set(distroKey, sessionBridgeRoute)
|
||||
}
|
||||
return deleteSource ? 'complete' : 'pending'
|
||||
}
|
||||
|
||||
async function recoverAfterFailedApply(
|
||||
distro: string,
|
||||
paths: ReturnType<typeof resolveLegacyRuntimePaths>
|
||||
): Promise<'complete' | 'pending'> {
|
||||
const recovery = await runWslProcess({
|
||||
distro,
|
||||
loginPath: 'none',
|
||||
script: INSPECT_LEGACY_AUTH_SCRIPT,
|
||||
args: [paths.runtimeHome, paths.activeHome, paths.marker],
|
||||
timeoutMs: 5_000,
|
||||
maxOutputBytes: 2 * 1024 * 1024
|
||||
})
|
||||
if (recovery.code === MARKER_PRESENT_EXIT) {
|
||||
return 'complete'
|
||||
}
|
||||
if (
|
||||
!recovery.timedOut &&
|
||||
(recovery.code === 0 ||
|
||||
recovery.code === SOURCE_AUTH_ABSENT_EXIT ||
|
||||
recovery.code === LEGACY_HOME_ABSENT_EXIT)
|
||||
) {
|
||||
return 'pending'
|
||||
}
|
||||
assertSuccessfulDrainStep('recover', recovery)
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function parseLegacyRuntimeInspection(stdout: string): LegacyWslRuntimeInspection | null {
|
||||
const [authBase64, credentialsKind, credentialsBase64] = stdout.split('\n')
|
||||
const authContents = decodeWslBase64Payload(authBase64 ?? '')
|
||||
if (authContents === null) {
|
||||
return null
|
||||
}
|
||||
if (credentialsKind === 'missing') {
|
||||
return { authContents, credentials: { kind: 'missing' } }
|
||||
}
|
||||
if (credentialsKind !== 'present') {
|
||||
return null
|
||||
}
|
||||
const credentialsContents = decodeWslBase64Payload(credentialsBase64 ?? '')
|
||||
if (!credentialsContents || !isJsonObject(credentialsContents)) {
|
||||
return null
|
||||
}
|
||||
return { authContents, credentials: { kind: 'present', contents: credentialsContents } }
|
||||
}
|
||||
|
||||
function isJsonObject(contents: string): boolean {
|
||||
try {
|
||||
const value = JSON.parse(contents) as unknown
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLegacyRuntimePaths(guestHomeLinuxPath: string): {
|
||||
activeHome: string
|
||||
marker: string
|
||||
runtimeHome: string
|
||||
} {
|
||||
const runtimeHome = wslCodexRuntimeHomeForGuestHome(guestHomeLinuxPath)
|
||||
const runtimeRoot = pathPosix.dirname(runtimeHome)
|
||||
return {
|
||||
activeHome: pathPosix.join(runtimeRoot, 'active', 'wsl', 'home'),
|
||||
marker: pathPosix.join(runtimeRoot, DRAIN_MARKER_NAME),
|
||||
runtimeHome
|
||||
}
|
||||
}
|
||||
|
||||
async function finalizeAbsentLegacyAuth(
|
||||
distro: string,
|
||||
paths: ReturnType<typeof resolveLegacyRuntimePaths>
|
||||
): Promise<'complete' | 'pending'> {
|
||||
const result = await runWslProcess({
|
||||
distro,
|
||||
loginPath: 'none',
|
||||
script: FINALIZE_ABSENT_AUTH_SCRIPT,
|
||||
args: [paths.runtimeHome, paths.activeHome, paths.marker],
|
||||
timeoutMs: 5_000,
|
||||
maxOutputBytes: 16 * 1024
|
||||
})
|
||||
if (result.code === LEGACY_HOME_STILL_PRESENT_EXIT) {
|
||||
return 'pending'
|
||||
}
|
||||
assertSuccessfulDrainStep('finalize', result)
|
||||
return 'complete'
|
||||
}
|
||||
|
||||
function assertSuccessfulDrainStep(
|
||||
step: string,
|
||||
result: { code: number | null; stderr: string; timedOut: boolean }
|
||||
): void {
|
||||
if (result.code === 0 && !result.timedOut) {
|
||||
return
|
||||
}
|
||||
const detail = result.stderr.trim()
|
||||
throw new Error(
|
||||
`Legacy WSL auth drain ${step} failed (${result.timedOut ? 'timeout' : `exit ${result.code}`})${detail ? `: ${detail}` : ''}`
|
||||
)
|
||||
}
|
||||
|
||||
function sha256(contents: string): string {
|
||||
return createHash('sha256').update(contents).digest('hex')
|
||||
}
|
||||
|
||||
export const _internals = {
|
||||
applyLegacyAuthScript: APPLY_LEGACY_AUTH_SCRIPT,
|
||||
finalizeAbsentAuthScript: FINALIZE_ABSENT_AUTH_SCRIPT,
|
||||
inspectLegacyAuthScript: INSPECT_LEGACY_AUTH_SCRIPT,
|
||||
resetDrainQueue: (): void => {
|
||||
drainQueueByDistro.clear()
|
||||
completedDistroKeys.clear()
|
||||
pendingSessionBridgeRouteByDistro.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { LEGACY_HOME_STILL_PRESENT_EXIT } from './legacy-wsl-runtime-auth-drain-exit-codes'
|
||||
import {
|
||||
RECOVER_DESTINATION_AUTH_COMMAND,
|
||||
RESOLVE_LEGACY_HOME_SCRIPT,
|
||||
ROLLBACK_SESSION_LINKS_FUNCTION
|
||||
} from './legacy-wsl-runtime-auth-drain-shell-commands'
|
||||
|
||||
export const FINALIZE_ABSENT_AUTH_SCRIPT = `
|
||||
set -eu
|
||||
source_recovery_auth="$3.orca-drain-source"
|
||||
source_quarantine_auth="$3.orca-drain-live-source"
|
||||
destination_recovery_auth="$3.orca-drain-destination"
|
||||
destination_recovery_path="$3.orca-drain-destination-path"
|
||||
session_link_manifest="$3.orca-drain-session-links"
|
||||
session_commit_marker="$3.orca-drain-session-commit"
|
||||
session_stage_root="$3.orca-drain-session-stage"
|
||||
${ROLLBACK_SESSION_LINKS_FUNCTION}
|
||||
if [ -e "$3" ] || [ -L "$3" ]; then
|
||||
[ -f "$3" ] && [ ! -L "$3" ] || exit 46
|
||||
commit_session_links || exit 46
|
||||
if [ -f "$destination_recovery_auth" ] && [ ! -L "$destination_recovery_auth" ]; then
|
||||
chmod 600 "$destination_recovery_auth"
|
||||
fi
|
||||
rm -f -- "$source_recovery_auth" "$source_quarantine_auth" "$destination_recovery_auth" "$destination_recovery_path"
|
||||
exit 0
|
||||
fi
|
||||
rollback_session_links
|
||||
${RESOLVE_LEGACY_HOME_SCRIPT}
|
||||
if [ -f "$source_recovery_auth" ] && [ ! -L "$source_recovery_auth" ]; then
|
||||
[ ! -e "$legacy_home/auth.json" ] && [ ! -L "$legacy_home/auth.json" ] || exit 41
|
||||
mv -- "$source_recovery_auth" "$legacy_home/auth.json"
|
||||
chmod 600 "$legacy_home/auth.json"
|
||||
if [ -e "$destination_recovery_auth" ] || [ -L "$destination_recovery_auth" ]; then
|
||||
[ -f "$destination_recovery_auth" ] && [ ! -L "$destination_recovery_auth" ] || exit 46
|
||||
[ -f "$destination_recovery_path" ] && [ ! -L "$destination_recovery_path" ] || exit 46
|
||||
${RECOVER_DESTINATION_AUTH_COMMAND} || exit 46
|
||||
elif [ -e "$destination_recovery_path" ] || [ -L "$destination_recovery_path" ]; then
|
||||
[ -f "$destination_recovery_path" ] && [ ! -L "$destination_recovery_path" ] || exit 46
|
||||
rm -- "$destination_recovery_path"
|
||||
fi
|
||||
exit 46
|
||||
fi
|
||||
if [ -f "$source_quarantine_auth" ] && [ ! -L "$source_quarantine_auth" ]; then
|
||||
[ ! -e "$legacy_home/auth.json" ] && [ ! -L "$legacy_home/auth.json" ] || exit 41
|
||||
mv -- "$source_quarantine_auth" "$legacy_home/auth.json"
|
||||
chmod 600 "$legacy_home/auth.json"
|
||||
if [ -e "$destination_recovery_auth" ] || [ -L "$destination_recovery_auth" ]; then
|
||||
[ -f "$destination_recovery_auth" ] && [ ! -L "$destination_recovery_auth" ] || exit 46
|
||||
[ -f "$destination_recovery_path" ] && [ ! -L "$destination_recovery_path" ] || exit 46
|
||||
${RECOVER_DESTINATION_AUTH_COMMAND} || exit 46
|
||||
elif [ -e "$destination_recovery_path" ] || [ -L "$destination_recovery_path" ]; then
|
||||
[ -f "$destination_recovery_path" ] && [ ! -L "$destination_recovery_path" ] || exit 46
|
||||
rm -- "$destination_recovery_path"
|
||||
fi
|
||||
exit 46
|
||||
fi
|
||||
[ ! -e "$source_recovery_auth" ] && [ ! -L "$source_recovery_auth" ] || exit 46
|
||||
[ ! -e "$source_quarantine_auth" ] && [ ! -L "$source_quarantine_auth" ] || exit 46
|
||||
[ ! -e "$destination_recovery_auth" ] && [ ! -L "$destination_recovery_auth" ] || exit 46
|
||||
[ ! -e "$destination_recovery_path" ] && [ ! -L "$destination_recovery_path" ] || exit 46
|
||||
[ ! -e "$legacy_home/auth.json" ] && [ ! -L "$legacy_home/auth.json" ] || exit 41
|
||||
[ "$legacy_home_resolved" = 0 ] || exit ${LEGACY_HOME_STILL_PRESENT_EXIT}
|
||||
umask 077
|
||||
marker_parent=\${3%/*}
|
||||
mkdir -p -- "$marker_parent"
|
||||
temporary_marker="$3.orca-drain-$$"
|
||||
trap 'rm -f -- "$temporary_marker"' EXIT HUP INT TERM
|
||||
printf '%s\n' '{"completed":true}' > "$temporary_marker"
|
||||
chmod 600 "$temporary_marker"
|
||||
mv -f -- "$temporary_marker" "$3"
|
||||
`
|
||||
@@ -1,5 +1,4 @@
|
||||
/* eslint-disable max-lines -- Why: keeps Codex's whole runtime-home contract in one place so account-switch semantics don't drift across launch/login/quota paths. */
|
||||
import { quotePosixShell } from '../../shared/wsl-login-shell-command'
|
||||
import {
|
||||
appendFileSync,
|
||||
copyFileSync,
|
||||
@@ -18,13 +17,13 @@ import {
|
||||
unlinkSync
|
||||
} from 'node:fs'
|
||||
import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import {
|
||||
dirname,
|
||||
extname,
|
||||
isAbsolute,
|
||||
join,
|
||||
parse,
|
||||
posix as pathPosix,
|
||||
relative,
|
||||
resolve,
|
||||
win32 as pathWin32
|
||||
@@ -33,7 +32,6 @@ import { app } from 'electron'
|
||||
import type { CodexManagedAccount } from '../../shared/managed-account-types'
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
import type { Store } from '../persistence'
|
||||
import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env'
|
||||
import {
|
||||
recoverInterruptedGuardedFileOperation,
|
||||
removeFileAtomicallyIfUnchanged,
|
||||
@@ -55,16 +53,13 @@ import {
|
||||
resolveWslCodexSessionSourceHome
|
||||
} from '../codex/codex-session-source-home'
|
||||
import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge'
|
||||
import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror'
|
||||
import { parseWslUncPath, toLinuxPath, toWindowsWslUncPath } from '../../shared/wsl-paths'
|
||||
import {
|
||||
prepareSystemConfigForFreshRuntimeMirror,
|
||||
syncSystemConfigIntoManagedCodexHome
|
||||
} from '../codex/codex-config-mirror'
|
||||
import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths'
|
||||
import {
|
||||
getCodexSelectionLaneKey,
|
||||
getWslSelectionKey,
|
||||
getSelectedCodexAccountIdForTarget,
|
||||
normalizeCodexRuntimeSelection,
|
||||
setSelectedCodexAccountIdForTarget,
|
||||
type CodexAccountSelectionTarget
|
||||
} from './runtime-selection'
|
||||
import { getDefaultWslDistro, getWslHome } from '../wsl'
|
||||
@@ -91,10 +86,16 @@ import { CodexCredentialAbsenceGrace } from './codex-credential-absence-grace'
|
||||
import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility'
|
||||
import {
|
||||
getCodexPaneAccount,
|
||||
hasRecordedLegacyWslCodexPane,
|
||||
hasRecordedLegacySharedCodexPane,
|
||||
type CodexPaneHomeRoute
|
||||
} from '../codex/codex-pane-account-registry'
|
||||
import { isShellStartupEnvProbeSupported } from '../pty/shell-startup-env'
|
||||
import {
|
||||
startLegacyWslRuntimeAuthDrain,
|
||||
type LegacyWslRuntimeAuthDestination
|
||||
} from './legacy-wsl-runtime-auth-drain'
|
||||
import { readWslCodexAuths, type WslCodexAuthRead } from './wsl-codex-auth-batch-reader'
|
||||
|
||||
type CodexSystemDefaultSnapshot = {
|
||||
authJson: string | null
|
||||
@@ -130,7 +131,6 @@ type CodexRuntimeLogoutMarkerStatus =
|
||||
| { kind: 'applies' }
|
||||
| { kind: 'system-default-changed'; systemDefaultAuthJson: string | null }
|
||||
|
||||
type CodexReadBackResult = 'unchanged' | 'persisted' | 'rejected'
|
||||
type CodexReadBackMatch =
|
||||
| {
|
||||
kind: 'matched'
|
||||
@@ -195,11 +195,6 @@ export class CodexRuntimeHomeService {
|
||||
private lastSyncedAccountId: string | null = null
|
||||
// Last auth.json Orca wrote to the runtime home; a later diff signals an out-of-band change (Codex token refresh, or external login to adopt).
|
||||
private lastWrittenAuthJson: string | null = null
|
||||
// Why: WSL terminals have per-distro runtime homes; sharing the host baseline can make stale WSL auth look newer than managed storage.
|
||||
private readonly lastWrittenWslAuthJsonByDistro = new Map<string, string | null>()
|
||||
private readonly lastSyncedWslAccountIdByDistro = new Map<string, string | null>()
|
||||
private readonly wslRuntimeHomePathByDistro = new Map<string, string>()
|
||||
private skipNextReadBackForAccountId: string | null = null
|
||||
// Why: a managed host account refreshes auth in its own home. Remember that
|
||||
// provenance so a later deselect never adopts stale shared bytes.
|
||||
private lastHostAccountUsedSelfContainedHome = false
|
||||
@@ -209,7 +204,6 @@ export class CodexRuntimeHomeService {
|
||||
private hostSystemDefaultSessionMigrationPending = false
|
||||
private pendingHostSystemDefaultSessionMigrationNeedsFullScan = false
|
||||
private pendingHostSystemDefaultSessionMigrationTarget: string | null = null
|
||||
|
||||
constructor(private readonly store: Store) {
|
||||
this.safeRecoverInterruptedRuntimeAuthOperation()
|
||||
this.safeMigrateLegacySharedAuth()
|
||||
@@ -244,11 +238,10 @@ export class CodexRuntimeHomeService {
|
||||
): string | null {
|
||||
if (target?.runtime === 'wsl') {
|
||||
const wslTarget = this.resolveWslDefaultTarget(target)
|
||||
const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget)
|
||||
this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, syncedRuntimeHomePath)
|
||||
const runtimeHomePath = syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget)
|
||||
this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath)
|
||||
return runtimeHomePath
|
||||
const homePath = this.getWslCodexHomePathForSelection(wslTarget)
|
||||
this.startLegacyWslAuthDrain(wslTarget)
|
||||
this.finishWslLaunchPreparation(wslTarget, homePath)
|
||||
return homePath
|
||||
}
|
||||
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
|
||||
if (selfContainedAccount) {
|
||||
@@ -281,6 +274,23 @@ export class CodexRuntimeHomeService {
|
||||
return this.getRuntimeHomePath()
|
||||
}
|
||||
|
||||
async prepareForCodexLaunchAsync(
|
||||
target?: CodexAccountSelectionTarget,
|
||||
launchEnv?: NodeJS.ProcessEnv,
|
||||
options?: { unavailableManagedHomePath?: string }
|
||||
): Promise<string | null> {
|
||||
if (target?.runtime !== 'wsl') {
|
||||
return this.prepareForCodexLaunch(target, launchEnv, options)
|
||||
}
|
||||
const wslTarget = this.resolveWslDefaultTarget(target)
|
||||
const homePath = this.getWslCodexHomePathForSelection(wslTarget)
|
||||
// Why: the retired home may hold the freshest credential, so the first
|
||||
// direct-home Codex spawn must wait for its bounded guest transaction.
|
||||
await this.startLegacyWslAuthDrain(wslTarget, { throwOnFailure: true })
|
||||
this.finishWslLaunchPreparation(wslTarget, homePath)
|
||||
return homePath
|
||||
}
|
||||
|
||||
beginHostSystemDefaultSessionMigrationLaunch(
|
||||
codexHomePath: string | null,
|
||||
options: { reattached?: boolean; launchEnv?: NodeJS.ProcessEnv } = {}
|
||||
@@ -375,9 +385,18 @@ export class CodexRuntimeHomeService {
|
||||
}
|
||||
|
||||
private getManagedHostAccountHomesForSessionDiscovery(): string[] {
|
||||
return this.getManagedAccountHomesForSessionDiscovery().filter(
|
||||
(home) => parseWslUncPath(home) === null
|
||||
)
|
||||
const settings = this.store.getSettings()
|
||||
const homes: string[] = []
|
||||
for (const account of settings.codexManagedAccounts) {
|
||||
if (this.getWslManagedHomePath(account)) {
|
||||
continue
|
||||
}
|
||||
const trustedHome = this.getTrustedSelfContainedManagedHomePath(account)
|
||||
if (trustedHome) {
|
||||
homes.push(trustedHome)
|
||||
}
|
||||
}
|
||||
return homes
|
||||
}
|
||||
|
||||
private prepareSelfContainedManagedHomeForLaunch(
|
||||
@@ -570,15 +589,14 @@ export class CodexRuntimeHomeService {
|
||||
const systemCodexHomePath =
|
||||
resolveWslCodexSessionSourceHome(this.store.getSettings(), distro) ??
|
||||
this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro })
|
||||
if (!systemCodexHomePath || systemCodexHomePath === runtimeHomePath) {
|
||||
return
|
||||
if (systemCodexHomePath && systemCodexHomePath !== runtimeHomePath) {
|
||||
// Why: WSL history must be hardlinked inside the distro; host-side links can't bridge Windows and WSL filesystems in a resume-visible way.
|
||||
void startWslCodexSessionBridgeInBackground({
|
||||
distro,
|
||||
systemCodexHomePath,
|
||||
managedCodexHomePath: runtimeHomePath
|
||||
})
|
||||
}
|
||||
// Why: WSL history must be hardlinked inside the distro; host-side links can't bridge Windows and WSL filesystems in a resume-visible way.
|
||||
void startWslCodexSessionBridgeInBackground({
|
||||
distro,
|
||||
systemCodexHomePath,
|
||||
managedCodexHomePath: runtimeHomePath
|
||||
})
|
||||
}
|
||||
|
||||
getHostCodexHomePathsForSessionDiscovery(): string[] {
|
||||
@@ -724,26 +742,6 @@ export class CodexRuntimeHomeService {
|
||||
syncLegacySharedCodexConfigForRetainedPanes()
|
||||
}
|
||||
|
||||
syncActiveWslSelectionsBeforeRestart(): void {
|
||||
if (process.platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
|
||||
const settings = this.store.getSettings()
|
||||
for (const [selectedDistroKey, accountId] of Object.entries(
|
||||
normalizeCodexRuntimeSelection(settings).wsl
|
||||
)) {
|
||||
if (!accountId) {
|
||||
continue
|
||||
}
|
||||
const account = this.getActiveAccount(settings.codexManagedAccounts, accountId)
|
||||
if (!account || account.managedHomeRuntime !== 'wsl') {
|
||||
continue
|
||||
}
|
||||
this.safeReadBackActiveWslAccountBeforeRestart(account, selectedDistroKey)
|
||||
}
|
||||
}
|
||||
|
||||
private getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null {
|
||||
if (process.platform !== 'win32') {
|
||||
return null
|
||||
@@ -753,9 +751,23 @@ export class CodexRuntimeHomeService {
|
||||
return null
|
||||
}
|
||||
const home = getWslHome(distro)
|
||||
if (home && /^[A-Za-z]:[\\/]/.test(home)) {
|
||||
const linuxHome = toLinuxPath(home).trim()
|
||||
return linuxHome.startsWith('/')
|
||||
? toWindowsWslUncPath(pathPosix.join(linuxHome, '.codex'), distro)
|
||||
: null
|
||||
}
|
||||
return home ? this.joinWslPath(home, '.codex') : null
|
||||
}
|
||||
|
||||
private finishWslLaunchPreparation(
|
||||
target: CodexAccountSelectionTarget,
|
||||
homePath: string | null
|
||||
): void {
|
||||
this.syncWslConfigAndGlobalInstructionsForLaunch(target, homePath)
|
||||
this.startWslSessionBridgeForLaunch(target, homePath)
|
||||
}
|
||||
|
||||
private syncWslConfigAndGlobalInstructionsForLaunch(
|
||||
target: CodexAccountSelectionTarget,
|
||||
runtimeHomePath: string | null
|
||||
@@ -790,10 +802,9 @@ export class CodexRuntimeHomeService {
|
||||
prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): CodexRateLimitHomeResolution {
|
||||
if (target?.runtime === 'wsl') {
|
||||
const wslTarget = this.resolveWslDefaultTarget(target)
|
||||
const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget)
|
||||
return {
|
||||
kind: 'ready',
|
||||
codexHomePath: syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget)
|
||||
codexHomePath: this.getPreparedWslRateLimitHomePath(wslTarget)
|
||||
}
|
||||
}
|
||||
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
|
||||
@@ -833,7 +844,7 @@ export class CodexRuntimeHomeService {
|
||||
launchEnv?: NodeJS.ProcessEnv
|
||||
): void {
|
||||
if (target?.runtime === 'wsl') {
|
||||
this.syncWslRuntimeForCurrentSelection(target)
|
||||
this.startLegacyWslAuthDrain(this.resolveWslDefaultTarget(target))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -880,7 +891,6 @@ export class CodexRuntimeHomeService {
|
||||
// distro-local runtime home, so the host mirror only drops its baseline.
|
||||
this.lastSyncedAccountId = null
|
||||
this.lastWrittenAuthJson = null
|
||||
this.skipNextReadBackForAccountId = null
|
||||
return
|
||||
}
|
||||
if (normalizeCodexRuntimeSelection(settings).host) {
|
||||
@@ -922,71 +932,13 @@ export class CodexRuntimeHomeService {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: re-auth/add-account write fresh managed tokens, so skip the next read-back to avoid clobbering them with stale runtime tokens.
|
||||
// Why: re-auth/add-account writes fresh host tokens, invalidating the shared mirror baseline.
|
||||
clearLastWrittenAuthJson(
|
||||
accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host
|
||||
): void {
|
||||
if (accountId === normalizeCodexRuntimeSelection(this.store.getSettings()).host) {
|
||||
this.lastWrittenAuthJson = null
|
||||
}
|
||||
this.skipNextReadBackForAccountId = accountId
|
||||
}
|
||||
|
||||
private readBackRefreshedTokensFromPath(
|
||||
runtimeAuthPath: string,
|
||||
options: {
|
||||
updateLastWrittenAuthJson: boolean
|
||||
lastWrittenAuthJson?: string | null
|
||||
setLastWrittenAuthJson?: (contents: string) => void
|
||||
expectedAccountId?: string
|
||||
}
|
||||
): CodexReadBackResult {
|
||||
try {
|
||||
if (!existsSync(runtimeAuthPath)) {
|
||||
return 'unchanged'
|
||||
}
|
||||
|
||||
const lastWrittenAuthJson =
|
||||
options.lastWrittenAuthJson === undefined
|
||||
? this.lastWrittenAuthJson
|
||||
: options.lastWrittenAuthJson
|
||||
const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8')
|
||||
if (lastWrittenAuthJson !== null && runtimeContents === lastWrittenAuthJson) {
|
||||
return 'unchanged'
|
||||
}
|
||||
|
||||
const match = this.findManagedAccountForRuntimeAuth(
|
||||
runtimeContents,
|
||||
options.expectedAccountId
|
||||
)
|
||||
if (match.kind !== 'matched') {
|
||||
if (match.kind === 'ambiguous') {
|
||||
console.warn('[codex-runtime-home] Refusing ambiguous Codex auth read-back')
|
||||
}
|
||||
return 'rejected'
|
||||
}
|
||||
// Why: after restart there's no last-written baseline, so identity alone can't prove runtime auth is newer than managed storage.
|
||||
if (
|
||||
lastWrittenAuthJson === null &&
|
||||
!this.runtimeAuthIsFresher(runtimeContents, match.managedAuthContents)
|
||||
) {
|
||||
return 'rejected'
|
||||
}
|
||||
|
||||
writeFileAtomically(match.managedAuthPath, runtimeContents, { mode: 0o600 })
|
||||
if (options.updateLastWrittenAuthJson) {
|
||||
if (options.setLastWrittenAuthJson) {
|
||||
options.setLastWrittenAuthJson(runtimeContents)
|
||||
} else {
|
||||
this.lastWrittenAuthJson = runtimeContents
|
||||
}
|
||||
}
|
||||
return 'persisted'
|
||||
} catch (error) {
|
||||
// Why: read-back is best-effort; a transient fs error must not block the forward sync — worst case is one more stale-token cycle.
|
||||
console.warn('[codex-runtime-home] Failed to read back refreshed tokens:', error)
|
||||
return 'rejected'
|
||||
}
|
||||
}
|
||||
|
||||
// Why: which ~/.codex bytes the mirror was seeded from, and whether the system
|
||||
@@ -1041,233 +993,182 @@ export class CodexRuntimeHomeService {
|
||||
}
|
||||
|
||||
private getWslManagedHomePath(account: CodexManagedAccount | null): string | null {
|
||||
if (!account) {
|
||||
return null
|
||||
}
|
||||
if (account.managedHomeRuntime === 'wsl' && parseWslUncPath(account.managedHomePath)) {
|
||||
return account.managedHomePath
|
||||
}
|
||||
return parseWslUncPath(account.managedHomePath) ? account.managedHomePath : null
|
||||
return this.getWslManagedHomeIdentity(account) ? (account?.managedHomePath ?? null) : null
|
||||
}
|
||||
|
||||
private getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null {
|
||||
const distro = target.wslDistro?.trim()
|
||||
if (distro) {
|
||||
const settings = this.store.getSettings()
|
||||
const selectedAccountId = getSelectedCodexAccountIdForTarget(settings, target)
|
||||
if (selectedAccountId === null) {
|
||||
// Why: the system-default account changes outside Orca, so read its real home directly to avoid a stale cached runtime copy.
|
||||
return this.getWslSystemCodexHomePath(target)
|
||||
}
|
||||
const cachedRuntimeHomePath = this.wslRuntimeHomePathByDistro.get(distro)
|
||||
if (
|
||||
cachedRuntimeHomePath &&
|
||||
this.lastSyncedWslAccountIdByDistro.has(distro) &&
|
||||
this.lastSyncedWslAccountIdByDistro.get(distro) === selectedAccountId
|
||||
) {
|
||||
// Why: RateLimitService resolves provenance twice per poll; stay path-only so it doesn't block main on UNC reads and a wsl.exe probe.
|
||||
return cachedRuntimeHomePath
|
||||
}
|
||||
}
|
||||
return this.syncWslRuntimeForCurrentSelection(target)
|
||||
return this.getWslCodexHomePathForSelection(target)
|
||||
}
|
||||
|
||||
private syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null {
|
||||
if (process.platform !== 'win32') {
|
||||
return null
|
||||
}
|
||||
|
||||
const wslTarget = this.resolveWslDefaultTarget(target)
|
||||
private getWslCodexHomePathForSelection(target: CodexAccountSelectionTarget): string | null {
|
||||
const settings = this.store.getSettings()
|
||||
const activeAccount = this.getActiveAccount(
|
||||
const account = this.getActiveAccount(
|
||||
settings.codexManagedAccounts,
|
||||
getSelectedCodexAccountIdForTarget(settings, wslTarget)
|
||||
getSelectedCodexAccountIdForTarget(settings, target)
|
||||
)
|
||||
const distro = wslTarget.wslDistro?.trim() || activeAccount?.wslDistro || getDefaultWslDistro()
|
||||
if (!distro) {
|
||||
return null
|
||||
}
|
||||
|
||||
const runtimeHomePath = this.getWslRuntimeHomePath(distro)
|
||||
if (!runtimeHomePath) {
|
||||
return null
|
||||
}
|
||||
this.wslRuntimeHomePathByDistro.set(distro, runtimeHomePath)
|
||||
|
||||
mkdirSync(runtimeHomePath, { recursive: true })
|
||||
this.safeMigrateLegacyWslActiveHomePointer(distro, runtimeHomePath)
|
||||
this.seedWslRuntimeHome(runtimeHomePath, activeAccount, distro)
|
||||
|
||||
const runtimeAuthPath = join(runtimeHomePath, 'auth.json')
|
||||
const previousWslAccountId = this.lastSyncedWslAccountIdByDistro.get(distro) ?? null
|
||||
if (previousWslAccountId) {
|
||||
if (this.skipNextReadBackForAccountId === previousWslAccountId) {
|
||||
this.skipNextReadBackForAccountId = null
|
||||
} else {
|
||||
const previousWslAccount = this.getActiveAccount(
|
||||
settings.codexManagedAccounts,
|
||||
previousWslAccountId
|
||||
)
|
||||
if (previousWslAccount) {
|
||||
this.readBackRefreshedTokensFromPath(runtimeAuthPath, {
|
||||
updateLastWrittenAuthJson: true,
|
||||
lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null,
|
||||
setLastWrittenAuthJson: (contents) => {
|
||||
this.lastWrittenWslAuthJsonByDistro.set(distro, contents)
|
||||
},
|
||||
expectedAccountId: previousWslAccount.id
|
||||
})
|
||||
}
|
||||
if (account) {
|
||||
const targetDistro = this.resolveWslDefaultTarget(target).wslDistro?.trim()
|
||||
const accountHome = this.getWslLaunchCodexHomePath(account, targetDistro)
|
||||
if (accountHome) {
|
||||
return accountHome
|
||||
}
|
||||
}
|
||||
|
||||
const activeAuthPath = activeAccount ? join(activeAccount.managedHomePath, 'auth.json') : null
|
||||
if (activeAccount && activeAuthPath && existsSync(activeAuthPath)) {
|
||||
const activeAuth = readFileSync(activeAuthPath, 'utf-8')
|
||||
this.writeRuntimeAuthAtPath(runtimeAuthPath, activeAuth)
|
||||
this.lastWrittenWslAuthJsonByDistro.set(distro, activeAuth)
|
||||
this.lastSyncedWslAccountIdByDistro.set(distro, activeAccount.id)
|
||||
return runtimeHomePath
|
||||
}
|
||||
if (activeAccount && activeAuthPath) {
|
||||
console.warn(
|
||||
'[codex-runtime-home] Active WSL managed account is missing auth.json, restoring system default'
|
||||
)
|
||||
this.store.updateSettings({
|
||||
activeCodexManagedAccountId: settings.activeCodexManagedAccountId,
|
||||
activeCodexManagedAccountIdsByRuntime: setSelectedCodexAccountIdForTarget(
|
||||
normalizeCodexRuntimeSelection(settings),
|
||||
null,
|
||||
wslTarget
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const systemAuthPath = this.getWslSystemCodexAuthPath({ runtime: 'wsl', wslDistro: distro })
|
||||
if (systemAuthPath && existsSync(systemAuthPath)) {
|
||||
const systemAuth = readFileSync(systemAuthPath, 'utf-8')
|
||||
const mirroredSystemDefaultAuth = this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null
|
||||
const runtimeAuth = existsSync(runtimeAuthPath)
|
||||
? readFileSync(runtimeAuthPath, 'utf-8')
|
||||
: null
|
||||
if (
|
||||
runtimeAuth !== null &&
|
||||
runtimeAuth !== systemAuth &&
|
||||
this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, systemAuth) &&
|
||||
((mirroredSystemDefaultAuth !== null && systemAuth === mirroredSystemDefaultAuth) ||
|
||||
(mirroredSystemDefaultAuth === null &&
|
||||
this.runtimeAuthIsFresher(runtimeAuth, systemAuth)))
|
||||
) {
|
||||
// Why: WSL baselines are lost on restart, so a same-identity fresher runtime auth is a token refresh; copy it back before mirroring ~/.codex.
|
||||
this.writeRuntimeAuthAtPath(systemAuthPath, runtimeAuth)
|
||||
this.lastWrittenWslAuthJsonByDistro.set(distro, runtimeAuth)
|
||||
this.lastSyncedWslAccountIdByDistro.set(distro, null)
|
||||
return runtimeHomePath
|
||||
}
|
||||
this.writeRuntimeAuthAtPath(runtimeAuthPath, systemAuth)
|
||||
this.lastWrittenWslAuthJsonByDistro.set(distro, systemAuth)
|
||||
this.lastSyncedWslAccountIdByDistro.set(distro, null)
|
||||
return runtimeHomePath
|
||||
}
|
||||
|
||||
rmSync(runtimeAuthPath, { force: true })
|
||||
this.lastWrittenWslAuthJsonByDistro.set(distro, null)
|
||||
this.lastSyncedWslAccountIdByDistro.set(distro, null)
|
||||
return runtimeHomePath
|
||||
return this.getWslSystemCodexHomePath(target)
|
||||
}
|
||||
|
||||
private getWslRuntimeHomePath(distro: string): string | null {
|
||||
const home = getWslHome(distro)
|
||||
return home ? this.joinWslPath(home, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) : null
|
||||
}
|
||||
|
||||
private safeReadBackActiveWslAccountBeforeRestart(
|
||||
private getWslLaunchCodexHomePath(
|
||||
account: CodexManagedAccount,
|
||||
selectedDistroKey: string
|
||||
): void {
|
||||
try {
|
||||
this.readBackActiveWslAccountBeforeRestart(account, selectedDistroKey)
|
||||
} catch (error) {
|
||||
console.warn('[codex-runtime-home] Failed to preserve WSL Codex auth before restart:', error)
|
||||
targetDistro: string | undefined
|
||||
): string | null {
|
||||
const wslHome = this.getWslManagedHomeIdentity(account)
|
||||
if (!wslHome) {
|
||||
return null
|
||||
}
|
||||
const accountDistro = wslHome.distro
|
||||
if (targetDistro && accountDistro.toLowerCase() !== targetDistro.toLowerCase()) {
|
||||
return null
|
||||
}
|
||||
if (/^[A-Za-z]:[\\/]/.test(account.managedHomePath)) {
|
||||
return toWindowsWslUncPath(wslHome.linuxHomePath, accountDistro)
|
||||
}
|
||||
return account.managedHomePath || toWindowsWslUncPath(wslHome.linuxHomePath, accountDistro)
|
||||
}
|
||||
|
||||
private readBackActiveWslAccountBeforeRestart(
|
||||
account: CodexManagedAccount,
|
||||
selectedDistroKey: string
|
||||
): void {
|
||||
const distro =
|
||||
selectedDistroKey === getWslSelectionKey(null)
|
||||
? account.wslDistro?.trim()
|
||||
: selectedDistroKey.trim() || account.wslDistro?.trim()
|
||||
private getWslManagedHomeIdentity(
|
||||
account: CodexManagedAccount | null
|
||||
): { distro: string; linuxHomePath: string } | null {
|
||||
if (!account) {
|
||||
return null
|
||||
}
|
||||
const distro = account.wslDistro?.trim()
|
||||
const linuxHomePath = account.wslLinuxHomePath?.trim()
|
||||
if (account.managedHomeRuntime === 'wsl' && distro && linuxHomePath?.startsWith('/')) {
|
||||
return { distro, linuxHomePath }
|
||||
}
|
||||
const legacyHome = parseWslUncPath(account.managedHomePath)
|
||||
return legacyHome ? { distro: legacyHome.distro, linuxHomePath: legacyHome.linuxPath } : null
|
||||
}
|
||||
|
||||
private startLegacyWslAuthDrain(
|
||||
target: CodexAccountSelectionTarget,
|
||||
options: { throwOnFailure?: boolean } = {}
|
||||
): Promise<void> {
|
||||
if (process.platform !== 'win32') {
|
||||
return Promise.resolve()
|
||||
}
|
||||
const distro = target.wslDistro?.trim() || getDefaultWslDistro()
|
||||
if (!distro) {
|
||||
return
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const runtimeHomePath = this.wslRuntimeHomePathByDistro.get(distro)
|
||||
if (!runtimeHomePath) {
|
||||
return
|
||||
const guestHome = getWslHome(distro)
|
||||
const guestHomeLinuxPath = guestHome ? toLinuxPath(guestHome).trim() : ''
|
||||
if (!guestHomeLinuxPath.startsWith('/')) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
this.readBackRefreshedTokensFromPath(join(runtimeHomePath, 'auth.json'), {
|
||||
updateLastWrittenAuthJson: true,
|
||||
lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null,
|
||||
setLastWrittenAuthJson: (contents) => {
|
||||
this.lastWrittenWslAuthJsonByDistro.set(distro, contents)
|
||||
},
|
||||
expectedAccountId: account.id
|
||||
})
|
||||
}
|
||||
|
||||
private safeMigrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void {
|
||||
let legacyPanePresent = true
|
||||
try {
|
||||
this.migrateLegacyWslActiveHomePointer(distro, runtimeHomePath)
|
||||
legacyPanePresent = hasRecordedLegacyWslCodexPane(getCodexSelectionLaneKey(target))
|
||||
} catch (error) {
|
||||
console.warn('[codex-runtime-home] Failed to migrate legacy WSL active Codex home:', error)
|
||||
// Why: unknown pane liveness must preserve the source, but promotion can
|
||||
// still keep the direct home from launching stale auth.
|
||||
console.warn('[codex-wsl-auth-drain] Pane registry unavailable; preserving source:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private migrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void {
|
||||
const runtimeWsl = parseWslUncPath(runtimeHomePath)
|
||||
if (!runtimeWsl?.linuxPath.endsWith('/codex-runtime-home/home')) {
|
||||
return
|
||||
}
|
||||
const activeLinuxPath = runtimeWsl.linuxPath.replace(
|
||||
/\/codex-runtime-home\/home$/,
|
||||
'/codex-runtime-home/active/wsl/home'
|
||||
)
|
||||
const nextLinuxPath = `${activeLinuxPath}.next-${process.pid}-${Date.now()}`
|
||||
const activeLinuxParentPath = this.dirnameLinuxPath(activeLinuxPath)
|
||||
// Why: login-shell cleanup turns `exit 0` into status 1, so fall through.
|
||||
execFileSync(
|
||||
'wsl.exe',
|
||||
[
|
||||
'-d',
|
||||
return startLegacyWslRuntimeAuthDrain(
|
||||
{
|
||||
distro,
|
||||
'--exec',
|
||||
'bash',
|
||||
'-lc',
|
||||
[
|
||||
'set -e',
|
||||
`if [ ! -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`,
|
||||
`elif [ -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`,
|
||||
'else',
|
||||
`mkdir -p ${quotePosixShell(activeLinuxParentPath)}`,
|
||||
`rm -rf -- ${quotePosixShell(nextLinuxPath)}`,
|
||||
`ln -s -- ${quotePosixShell(runtimeWsl.linuxPath)} ${quotePosixShell(nextLinuxPath)}`,
|
||||
`mv -Tf -- ${quotePosixShell(nextLinuxPath)} ${quotePosixShell(activeLinuxPath)}`,
|
||||
'fi'
|
||||
].join('\n')
|
||||
],
|
||||
// wsl.exe is console-subsystem: without this a GUI-launched Orca flashes
|
||||
// a conhost and steals foreground for up to the timeout (#10488).
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, windowsHide: true }
|
||||
guestHomeLinuxPath,
|
||||
legacyPanePresent,
|
||||
resolveDestination: (runtimeAuthContents) =>
|
||||
this.resolveLegacyWslAuthDestination(distro, runtimeAuthContents)
|
||||
},
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
private dirnameLinuxPath(value: string): string {
|
||||
const index = value.lastIndexOf('/')
|
||||
return index > 0 ? value.slice(0, index) : '/'
|
||||
/** Preserve refreshed auth from retained legacy WSL panes before restart. */
|
||||
async syncActiveWslSelectionsBeforeRestart(): Promise<void> {
|
||||
if (process.platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
const settings = this.store.getSettings()
|
||||
const drains: Promise<void>[] = []
|
||||
for (const [selectedDistroKey, accountId] of Object.entries(
|
||||
normalizeCodexRuntimeSelection(settings).wsl
|
||||
)) {
|
||||
if (!accountId) {
|
||||
continue
|
||||
}
|
||||
const account = this.getActiveAccount(settings.codexManagedAccounts, accountId)
|
||||
if (!account || account.managedHomeRuntime !== 'wsl') {
|
||||
continue
|
||||
}
|
||||
const distro =
|
||||
selectedDistroKey === getWslSelectionKey(null)
|
||||
? account.wslDistro?.trim() || null
|
||||
: selectedDistroKey.trim() || null
|
||||
if (distro) {
|
||||
drains.push(this.startLegacyWslAuthDrain({ runtime: 'wsl', wslDistro: distro }))
|
||||
}
|
||||
}
|
||||
await Promise.all(drains)
|
||||
}
|
||||
|
||||
private async resolveLegacyWslAuthDestination(
|
||||
distro: string,
|
||||
runtimeAuthContents: string
|
||||
): Promise<LegacyWslRuntimeAuthDestination | null> {
|
||||
const accountHomes = this.store.getSettings().codexManagedAccounts.flatMap((account) => {
|
||||
const wslHome = this.getWslManagedHomeIdentity(account)
|
||||
return wslHome?.distro.toLowerCase() === distro.toLowerCase()
|
||||
? [{ account, linuxPath: wslHome.linuxHomePath }]
|
||||
: []
|
||||
})
|
||||
const accounts = accountHomes.map(({ account }) => account)
|
||||
const systemHome = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro })
|
||||
const parsedSystemHome = systemHome ? parseWslUncPath(systemHome) : null
|
||||
let reads: WslCodexAuthRead[]
|
||||
try {
|
||||
reads = await readWslCodexAuths(distro, [
|
||||
...accountHomes.map(({ linuxPath }) => linuxPath),
|
||||
...(parsedSystemHome ? [parsedSystemHome.linuxPath] : [])
|
||||
])
|
||||
} catch {
|
||||
reads = accountHomes.map(() => ({ kind: 'unreadable' }))
|
||||
if (parsedSystemHome) {
|
||||
reads.push({ kind: 'unreadable' })
|
||||
}
|
||||
}
|
||||
const authReads = new Map<string, WslCodexAuthRead>(
|
||||
accountHomes.map(({ account }, index) => [account.id, reads[index] ?? { kind: 'unreadable' }])
|
||||
)
|
||||
const match = this.findManagedAccountForRuntimeAuth(runtimeAuthContents, undefined, {
|
||||
accounts,
|
||||
authReads
|
||||
})
|
||||
if (match.kind === 'ambiguous') {
|
||||
return null
|
||||
}
|
||||
if (match.kind === 'matched') {
|
||||
const accountHome = accountHomes.find(({ account }) => account.id === match.account.id)
|
||||
if (!accountHome) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
authContents: match.managedAuthContents,
|
||||
linuxHomePath: accountHome.linuxPath
|
||||
}
|
||||
}
|
||||
|
||||
if (!systemHome || !parsedSystemHome) {
|
||||
return null
|
||||
}
|
||||
const systemAuth = reads[accountHomes.length] ?? { kind: 'unreadable' }
|
||||
if (systemAuth.kind !== 'present') {
|
||||
return null
|
||||
}
|
||||
return this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemAuth.contents)
|
||||
? { authContents: systemAuth.contents, linuxHomePath: parsedSystemHome.linuxPath }
|
||||
: null
|
||||
}
|
||||
|
||||
private joinWslPath(basePath: string, ...segments: string[]): string {
|
||||
@@ -1286,40 +1187,13 @@ export class CodexRuntimeHomeService {
|
||||
return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target
|
||||
}
|
||||
|
||||
private getWslSystemCodexAuthPath(target: CodexAccountSelectionTarget): string | null {
|
||||
const home = this.getWslSystemCodexHomePath(target)
|
||||
return home ? this.joinWslPath(home, 'auth.json') : null
|
||||
}
|
||||
|
||||
private seedWslRuntimeHome(
|
||||
runtimeHomePath: string,
|
||||
activeAccount: CodexManagedAccount | null,
|
||||
distro: string
|
||||
): void {
|
||||
const runtimeConfigPath = join(runtimeHomePath, 'config.toml')
|
||||
if (existsSync(runtimeConfigPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
const candidateHomes = [
|
||||
activeAccount?.managedHomePath,
|
||||
this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro })
|
||||
].filter((value): value is string => Boolean(value))
|
||||
for (const homePath of candidateHomes) {
|
||||
const configPath = join(homePath, 'config.toml')
|
||||
if (existsSync(configPath)) {
|
||||
writeFileAtomically(
|
||||
runtimeConfigPath,
|
||||
prepareWslRuntimeSeedConfig(readFileSync(configPath, 'utf-8'), homePath)
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findManagedAccountForRuntimeAuth(
|
||||
runtimeAuthContents: string,
|
||||
expectedAccountId?: string
|
||||
expectedAccountId?: string,
|
||||
options?: {
|
||||
accounts: readonly CodexManagedAccount[]
|
||||
authReads: ReadonlyMap<string, WslCodexAuthRead>
|
||||
}
|
||||
): CodexReadBackMatch {
|
||||
const matches: {
|
||||
account: CodexManagedAccount
|
||||
@@ -1327,18 +1201,17 @@ export class CodexRuntimeHomeService {
|
||||
managedAuthContents: string
|
||||
}[] = []
|
||||
let unreadableHomeCouldOwnRuntimeAuth = false
|
||||
for (const account of this.store.getSettings().codexManagedAccounts) {
|
||||
for (const account of options?.accounts ?? this.store.getSettings().codexManagedAccounts) {
|
||||
if (expectedAccountId && account.id !== expectedAccountId) {
|
||||
continue
|
||||
}
|
||||
const managedAuthPath = join(account.managedHomePath, 'auth.json')
|
||||
if (!existsSync(managedAuthPath)) {
|
||||
let managedAuthContents: string
|
||||
const suppliedRead = options?.authReads.get(account.id)
|
||||
if (suppliedRead?.kind === 'missing') {
|
||||
continue
|
||||
}
|
||||
let managedAuthContents: string
|
||||
try {
|
||||
managedAuthContents = readFileSync(managedAuthPath, 'utf-8')
|
||||
} catch {
|
||||
if (suppliedRead?.kind === 'unreadable') {
|
||||
// Why: an unreadable home can never be compared, but letting the read
|
||||
// throw abandons the scan for every other account — dropping a refresh
|
||||
// the runtime home holds for one of them. Only its record can rule it
|
||||
@@ -1351,6 +1224,24 @@ export class CodexRuntimeHomeService {
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (suppliedRead?.kind === 'present') {
|
||||
managedAuthContents = suppliedRead.contents
|
||||
} else {
|
||||
if (!existsSync(managedAuthPath)) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
managedAuthContents = readFileSync(managedAuthPath, 'utf-8')
|
||||
} catch {
|
||||
if (
|
||||
!expectedAccountId &&
|
||||
codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account)
|
||||
) {
|
||||
unreadableHomeCouldOwnRuntimeAuth = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (codexAuthMatchesManagedAccount(runtimeAuthContents, account, managedAuthContents)) {
|
||||
matches.push({ account, managedAuthPath, managedAuthContents })
|
||||
}
|
||||
@@ -1372,10 +1263,6 @@ export class CodexRuntimeHomeService {
|
||||
return codexAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemDefaultAuthContents)
|
||||
}
|
||||
|
||||
private runtimeAuthIsFresher(runtimeAuthContents: string, managedAuthContents: string): boolean {
|
||||
return codexAuthIsFresher(runtimeAuthContents, managedAuthContents)
|
||||
}
|
||||
|
||||
private safeMigrateLegacySharedAuth(): void {
|
||||
const settings = this.store.getSettings()
|
||||
try {
|
||||
@@ -2082,15 +1969,6 @@ export class CodexRuntimeHomeService {
|
||||
return true
|
||||
}
|
||||
|
||||
private writeRuntimeAuthAtPath(authPath: string, contents: string): void {
|
||||
if (this.fileContentsEqual(authPath, contents)) {
|
||||
this.ensureOwnerOnlyMode(authPath)
|
||||
return
|
||||
}
|
||||
mkdirSync(dirname(authPath), { recursive: true })
|
||||
writeFileAtomically(authPath, contents, { mode: 0o600 })
|
||||
}
|
||||
|
||||
/**
|
||||
* `true`/`false` only when the bytes were actually read; `null` when the file
|
||||
* could not be read at all. The old `catch { return false }` reported "these
|
||||
@@ -2397,14 +2275,3 @@ export class CodexRuntimeHomeService {
|
||||
rmSync(this.getSystemDefaultSnapshotPath(), { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
// Why: Codex reads this config inside WSL, so relative path settings must anchor to the Linux-side home (verbatim copy breaks load, os error 2).
|
||||
export function prepareWslRuntimeSeedConfig(
|
||||
configContents: string,
|
||||
sourceHomePath: string
|
||||
): string {
|
||||
return prepareSystemConfigForFreshRuntimeMirror(
|
||||
configContents,
|
||||
parseWslUncPath(sourceHomePath)?.linuxPath ?? sourceHomePath
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type * as CodexConfigMirror from '../codex/codex-config-mirror'
|
||||
import type * as CodexHomePaths from '../codex/codex-home-paths'
|
||||
import type * as CodexPaneAccountRegistry from '../codex/codex-pane-account-registry'
|
||||
import type * as LegacyWslRuntimeAuthDrain from './legacy-wsl-runtime-auth-drain'
|
||||
import type * as WslCodexAuthBatchReader from './wsl-codex-auth-batch-reader'
|
||||
import { createSettings } from './runtime-home-settings-test-fixtures'
|
||||
import {
|
||||
createCodexAuthJson,
|
||||
@@ -87,18 +92,16 @@ describe('CodexRuntimeHomeService', () => {
|
||||
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"host-system"}\n')
|
||||
expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath())
|
||||
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
|
||||
wslRuntimeHomePath
|
||||
)
|
||||
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(
|
||||
'{"account":"wsl"}\n'
|
||||
wslManagedHomePath
|
||||
)
|
||||
expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false)
|
||||
expect(service.prepareForRateLimitFetch()).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: getRuntimeCodexHomePath()
|
||||
})
|
||||
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: wslRuntimeHomePath
|
||||
codexHomePath: wslManagedHomePath
|
||||
})
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
@@ -107,7 +110,7 @@ describe('CodexRuntimeHomeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('clears a selected WSL managed account when auth.json is missing', async () => {
|
||||
it('keeps a selected WSL managed home when auth.json is temporarily missing', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const wslHome = join(testState.userDataDir, 'wsl-home')
|
||||
@@ -161,13 +164,11 @@ describe('CodexRuntimeHomeService', () => {
|
||||
)
|
||||
|
||||
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
|
||||
wslRuntimeHomePath
|
||||
managedHomePath
|
||||
)
|
||||
expect(store.updateSettings).toHaveBeenCalledWith({
|
||||
activeCodexManagedAccountId: null,
|
||||
activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } }
|
||||
})
|
||||
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth)
|
||||
expect(store.updateSettings).not.toHaveBeenCalled()
|
||||
expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false)
|
||||
expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth)
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
@@ -175,7 +176,7 @@ describe('CodexRuntimeHomeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('seeds the WSL runtime config with rewritten paths and no system hook trust', async () => {
|
||||
it('launches WSL system default against its existing config without a runtime seed', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const wslHome = join(testState.userDataDir, 'wsl-home')
|
||||
@@ -214,21 +215,18 @@ describe('CodexRuntimeHomeService', () => {
|
||||
)
|
||||
|
||||
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
|
||||
wslRuntimeHomePath
|
||||
systemCodexHomePath
|
||||
)
|
||||
const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml')
|
||||
const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8')
|
||||
expect(runtimeConfig).toContain(
|
||||
`model_instructions_file = '${join(systemCodexHomePath, 'instructions.md')}'`
|
||||
)
|
||||
expect(runtimeConfig).toContain('[projects."/home/alice/repo"]')
|
||||
expect(runtimeConfig).not.toContain('[hooks.state.')
|
||||
expect(existsSync(runtimeConfigPath)).toBe(false)
|
||||
const systemConfigPath = join(systemCodexHomePath, 'config.toml')
|
||||
const systemConfig = readFileSync(systemConfigPath, 'utf-8')
|
||||
expect(systemConfig).toContain('model_instructions_file = "instructions.md"')
|
||||
expect(systemConfig).toContain('[hooks.state.')
|
||||
|
||||
// Why: WSL runtime configs are seeded once; Codex writes trust into them
|
||||
// afterwards, so a relaunch must not clobber the seeded file.
|
||||
writeFileSync(runtimeConfigPath, `${runtimeConfig}\n[projects."/tmp/x"]\n`, 'utf-8')
|
||||
writeFileSync(systemConfigPath, `${systemConfig}\n[projects."/tmp/x"]\n`, 'utf-8')
|
||||
service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
expect(readFileSync(runtimeConfigPath, 'utf-8')).toContain('[projects."/tmp/x"]')
|
||||
expect(readFileSync(systemConfigPath, 'utf-8')).toContain('[projects."/tmp/x"]')
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
@@ -236,26 +234,7 @@ describe('CodexRuntimeHomeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('anchors WSL seed rewrites to the Linux-side home parsed from the UNC source', async () => {
|
||||
const { prepareWslRuntimeSeedConfig } = await import('./runtime-home-service')
|
||||
|
||||
// Why: real UNC sources cannot back live fs operations in tests, so pin
|
||||
// the UNC -> Linux-side anchor translation on the extracted seed function.
|
||||
expect(
|
||||
prepareWslRuntimeSeedConfig(
|
||||
'model_instructions_file = "instructions.md"\n',
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex'
|
||||
)
|
||||
).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'")
|
||||
expect(
|
||||
prepareWslRuntimeSeedConfig(
|
||||
'model_instructions_file = "instructions.md"\n',
|
||||
'\\\\wsl$\\Ubuntu\\home\\alice\\.codex'
|
||||
)
|
||||
).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'")
|
||||
})
|
||||
|
||||
it('switches WSL accounts by rewriting one stable WSL runtime home', async () => {
|
||||
it('switches WSL accounts by selecting each account home directly', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const wslHome = join(testState.userDataDir, 'wsl-home')
|
||||
@@ -317,16 +296,17 @@ describe('CodexRuntimeHomeService', () => {
|
||||
'home'
|
||||
)
|
||||
|
||||
expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath)
|
||||
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth)
|
||||
expect(service.prepareForCodexLaunch(target)).toBe(firstManagedHomePath)
|
||||
expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false)
|
||||
|
||||
store.updateSettings({
|
||||
activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-2' } }
|
||||
})
|
||||
service.syncForCurrentSelection(target)
|
||||
|
||||
expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath)
|
||||
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth)
|
||||
expect(service.prepareForCodexLaunch(target)).toBe(secondManagedHomePath)
|
||||
expect(readFileSync(join(firstManagedHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth)
|
||||
expect(readFileSync(join(secondManagedHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth)
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
@@ -334,7 +314,125 @@ describe('CodexRuntimeHomeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not use host auth baseline to accept stale WSL runtime auth', async () => {
|
||||
it('waits for the legacy drain before completing direct-home launch preparation', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const wslHome = join(testState.userDataDir, 'wsl-home')
|
||||
vi.doMock('../wsl', () => ({
|
||||
getDefaultWslDistro: () => 'Ubuntu',
|
||||
getWslHome: () => wslHome
|
||||
}))
|
||||
let finishDrain: (() => void) | undefined
|
||||
const startLegacyWslRuntimeAuthDrain = vi.fn(
|
||||
(_options: unknown, _startOptions?: { throwOnFailure?: boolean }) =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishDrain = resolve
|
||||
})
|
||||
)
|
||||
vi.doMock('./legacy-wsl-runtime-auth-drain', () => ({ startLegacyWslRuntimeAuthDrain }))
|
||||
const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve())
|
||||
vi.doMock('../codex/wsl-codex-session-bridge', () => ({
|
||||
startWslCodexSessionBridgeInBackground
|
||||
}))
|
||||
vi.doMock('../codex/codex-home-paths', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof CodexHomePaths>()),
|
||||
syncCodexGlobalInstructionsIntoManagedHome: vi.fn()
|
||||
}))
|
||||
vi.doMock('../codex/codex-config-mirror', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof CodexConfigMirror>()),
|
||||
syncSystemConfigIntoManagedCodexHome: vi.fn()
|
||||
}))
|
||||
const managedHomePath = createManagedAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
createCodexAuthJson('wsl@example.com', 'acct-wsl', 'managed-token')
|
||||
)
|
||||
const store = createStore(
|
||||
createSettings({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
email: 'wsl@example.com',
|
||||
managedHomePath,
|
||||
managedHomeRuntime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home',
|
||||
providerAccountId: 'acct-wsl',
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: 'acct-wsl',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
}
|
||||
],
|
||||
activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-1' } }
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
const launch = service.prepareForCodexLaunchAsync({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(startLegacyWslRuntimeAuthDrain).toHaveBeenCalledTimes(1)
|
||||
expect(startLegacyWslRuntimeAuthDrain.mock.calls[0]?.[1]).toEqual({ throwOnFailure: true })
|
||||
expect(startWslCodexSessionBridgeInBackground).not.toHaveBeenCalled()
|
||||
|
||||
finishDrain?.()
|
||||
await expect(launch).resolves.toBe(managedHomePath)
|
||||
expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.doUnmock('../codex/codex-config-mirror')
|
||||
vi.doUnmock('../codex/codex-home-paths')
|
||||
vi.doUnmock('../codex/wsl-codex-session-bridge')
|
||||
vi.doUnmock('./legacy-wsl-runtime-auth-drain')
|
||||
vi.doUnmock('../wsl')
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves legacy auth while draining when pane attribution is unavailable', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const wslHome = join(testState.userDataDir, 'wsl-home')
|
||||
vi.doMock('../wsl', () => ({
|
||||
getDefaultWslDistro: () => 'Ubuntu',
|
||||
getWslHome: () => wslHome
|
||||
}))
|
||||
vi.doMock('../codex/codex-pane-account-registry', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof CodexPaneAccountRegistry>()),
|
||||
hasRecordedLegacyWslCodexPane: () => {
|
||||
throw new Error('registry unavailable')
|
||||
}
|
||||
}))
|
||||
const startLegacyWslRuntimeAuthDrain = vi.fn(() => Promise.resolve())
|
||||
vi.doMock('./legacy-wsl-runtime-auth-drain', () => ({ startLegacyWslRuntimeAuthDrain }))
|
||||
const store = createStore(createSettings())
|
||||
|
||||
try {
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
await service.prepareForCodexLaunchAsync({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
|
||||
expect(startLegacyWslRuntimeAuthDrain).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ legacyPanePresent: true }),
|
||||
{ throwOnFailure: true }
|
||||
)
|
||||
} finally {
|
||||
vi.doUnmock('./legacy-wsl-runtime-auth-drain')
|
||||
vi.doUnmock('../codex/codex-pane-account-registry')
|
||||
vi.doUnmock('../wsl')
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores stale retired runtime auth when launching a managed WSL account', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const wslHome = join(testState.userDataDir, 'wsl-home')
|
||||
@@ -342,7 +440,6 @@ describe('CodexRuntimeHomeService', () => {
|
||||
getDefaultWslDistro: () => 'Ubuntu',
|
||||
getWslHome: () => wslHome
|
||||
}))
|
||||
const hostAuth = createCodexAuthJson('host@example.com', 'acct-host', 'host-token')
|
||||
const wslManagedAuth = createCodexAuthJson(
|
||||
'wsl@example.com',
|
||||
'acct-wsl',
|
||||
@@ -355,7 +452,6 @@ describe('CodexRuntimeHomeService', () => {
|
||||
'runtime-stale',
|
||||
1_000
|
||||
)
|
||||
const hostManagedHomePath = createManagedAuth(testState.userDataDir, 'host-account', hostAuth)
|
||||
const wslManagedHomePath = createManagedAuth(
|
||||
testState.userDataDir,
|
||||
'wsl-account',
|
||||
@@ -374,17 +470,6 @@ describe('CodexRuntimeHomeService', () => {
|
||||
const store = createStore(
|
||||
createSettings({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'host-account',
|
||||
email: 'host@example.com',
|
||||
managedHomePath: hostManagedHomePath,
|
||||
providerAccountId: 'acct-host',
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: 'acct-host',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
},
|
||||
{
|
||||
id: 'wsl-account',
|
||||
email: 'wsl@example.com',
|
||||
@@ -395,14 +480,13 @@ describe('CodexRuntimeHomeService', () => {
|
||||
providerAccountId: 'acct-wsl',
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: 'acct-wsl',
|
||||
createdAt: 2,
|
||||
updatedAt: 2,
|
||||
lastAuthenticatedAt: 2
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
}
|
||||
],
|
||||
activeCodexManagedAccountId: 'host-account',
|
||||
activeCodexManagedAccountIdsByRuntime: {
|
||||
host: 'host-account',
|
||||
host: null,
|
||||
wsl: { Ubuntu: 'wsl-account' }
|
||||
}
|
||||
})
|
||||
@@ -413,10 +497,10 @@ describe('CodexRuntimeHomeService', () => {
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
|
||||
wslRuntimeHomePath
|
||||
wslManagedHomePath
|
||||
)
|
||||
expect(readFileSync(join(wslManagedHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth)
|
||||
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth)
|
||||
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(staleWslRuntimeAuth)
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
@@ -424,104 +508,74 @@ describe('CodexRuntimeHomeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not clobber fresh WSL tokens after clearLastWrittenAuthJson', async () => {
|
||||
it('launches and drains a mounted-drive WSL account through its distro path', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const wslHome = join(testState.userDataDir, 'wsl-home')
|
||||
vi.doMock('../wsl', () => ({
|
||||
getDefaultWslDistro: () => 'Ubuntu',
|
||||
getWslHome: () => wslHome
|
||||
}))
|
||||
const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' }
|
||||
const originalAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'original', 1_000)
|
||||
const staleRuntimeAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'stale', 1_500)
|
||||
const reauthedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'reauthed', 2_000)
|
||||
const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth)
|
||||
const managedAuthPath = join(managedHomePath, 'auth.json')
|
||||
const wslRuntimeHomePath = join(
|
||||
wslHome,
|
||||
'.local',
|
||||
'share',
|
||||
'orca',
|
||||
'codex-runtime-home',
|
||||
'home'
|
||||
)
|
||||
const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json')
|
||||
const store = createStore(
|
||||
createSettings({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
email: 'wsl@example.com',
|
||||
managedHomePath,
|
||||
managedHomeRuntime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home',
|
||||
providerAccountId: 'acct-wsl',
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: 'acct-wsl',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
}
|
||||
],
|
||||
activeCodexManagedAccountIdsByRuntime: {
|
||||
host: null,
|
||||
wsl: { Ubuntu: 'account-1' }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath)
|
||||
writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8')
|
||||
writeFileSync(managedAuthPath, reauthedAuth, 'utf-8')
|
||||
|
||||
service.clearLastWrittenAuthJson('account-1')
|
||||
service.syncForCurrentSelection(target)
|
||||
|
||||
expect(readFileSync(managedAuthPath, 'utf-8')).toBe(reauthedAuth)
|
||||
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(reauthedAuth)
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('reads active WSL token refreshes back before restart using the selected distro', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const wslHome = join(testState.userDataDir, 'wsl-home')
|
||||
vi.doMock('../wsl', () => ({
|
||||
getDefaultWslDistro: () => 'Ubuntu',
|
||||
getWslHome: () => wslHome
|
||||
}))
|
||||
const managedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'managed', 1_000)
|
||||
const refreshedAuth = createCodexAuthJson(
|
||||
'wsl@example.com',
|
||||
'acct-wsl',
|
||||
'runtime-refreshed',
|
||||
const managedAuth = createCodexAuthJson(
|
||||
'drive@example.com',
|
||||
'acct-drive',
|
||||
'drive-refresh',
|
||||
2_000
|
||||
)
|
||||
const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth)
|
||||
const managedAuthPath = join(managedHomePath, 'auth.json')
|
||||
const linuxHomePath = '/mnt/c/Users/alice/orca/codex-accounts/drive-account/home'
|
||||
vi.doMock('../wsl', () => ({
|
||||
getDefaultWslDistro: () => 'Ubuntu',
|
||||
getWslHome: () => 'C:\\Users\\alice'
|
||||
}))
|
||||
vi.doMock('./wsl-codex-auth-batch-reader', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WslCodexAuthBatchReader>()),
|
||||
readWslCodexAuths: vi.fn(async (_distro: string, homes: string[]) =>
|
||||
homes.map((home) =>
|
||||
home === linuxHomePath
|
||||
? { kind: 'present' as const, contents: managedAuth }
|
||||
: { kind: 'missing' as const }
|
||||
)
|
||||
)
|
||||
}))
|
||||
let drainGuestHome: string | null = null
|
||||
let drainDestination: { authContents: string; linuxHomePath: string } | null = null
|
||||
const drainTasks: Promise<void>[] = []
|
||||
vi.doMock('./legacy-wsl-runtime-auth-drain', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof LegacyWslRuntimeAuthDrain>()),
|
||||
startLegacyWslRuntimeAuthDrain: (
|
||||
options: Parameters<typeof LegacyWslRuntimeAuthDrain.startLegacyWslRuntimeAuthDrain>[0]
|
||||
) => {
|
||||
drainGuestHome = options.guestHomeLinuxPath
|
||||
const task = Promise.resolve(options.resolveDestination(managedAuth)).then(
|
||||
(destination) => {
|
||||
drainDestination = destination
|
||||
}
|
||||
)
|
||||
drainTasks.push(task)
|
||||
return task
|
||||
}
|
||||
}))
|
||||
const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve())
|
||||
vi.doMock('../codex/wsl-codex-session-bridge', () => ({
|
||||
startWslCodexSessionBridgeInBackground,
|
||||
syncWslCodexSessionsIntoManagedHome: vi.fn(() => Promise.resolve())
|
||||
}))
|
||||
vi.doMock('../codex/codex-home-paths', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof CodexHomePaths>()),
|
||||
syncCodexGlobalInstructionsIntoManagedHome: vi.fn()
|
||||
}))
|
||||
vi.doMock('../codex/codex-config-mirror', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof CodexConfigMirror>()),
|
||||
syncSystemConfigIntoManagedCodexHome: vi.fn()
|
||||
}))
|
||||
const store = createStore(
|
||||
createSettings({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
email: 'wsl@example.com',
|
||||
managedHomePath,
|
||||
id: 'drive-account',
|
||||
email: 'drive@example.com',
|
||||
managedHomePath: 'C:\\Users\\alice\\orca\\codex-accounts\\drive-account\\home',
|
||||
managedHomeRuntime: 'wsl',
|
||||
wslDistro: null,
|
||||
wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home',
|
||||
providerAccountId: 'acct-wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
wslLinuxHomePath: linuxHomePath,
|
||||
providerAccountId: 'acct-drive',
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: 'acct-wsl',
|
||||
workspaceAccountId: 'acct-drive',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
@@ -529,7 +583,7 @@ describe('CodexRuntimeHomeService', () => {
|
||||
],
|
||||
activeCodexManagedAccountIdsByRuntime: {
|
||||
host: null,
|
||||
wsl: { Ubuntu: 'account-1' }
|
||||
wsl: { Ubuntu: 'drive-account' }
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -537,25 +591,106 @@ describe('CodexRuntimeHomeService', () => {
|
||||
try {
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' }
|
||||
const wslRuntimeHomePath = join(
|
||||
wslHome,
|
||||
'.local',
|
||||
'share',
|
||||
'orca',
|
||||
'codex-runtime-home',
|
||||
'home'
|
||||
|
||||
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
|
||||
'\\\\wsl.localhost\\Ubuntu\\mnt\\c\\Users\\alice\\orca\\codex-accounts\\drive-account\\home'
|
||||
)
|
||||
const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json')
|
||||
await Promise.all(drainTasks)
|
||||
|
||||
expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath)
|
||||
writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8')
|
||||
|
||||
service.syncActiveWslSelectionsBeforeRestart()
|
||||
|
||||
expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth)
|
||||
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth)
|
||||
expect(drainGuestHome).toBe('/mnt/c/Users/alice')
|
||||
expect(drainDestination).toEqual({ authContents: managedAuth, linuxHomePath })
|
||||
expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({
|
||||
distro: 'Ubuntu',
|
||||
systemCodexHomePath: '\\\\wsl.localhost\\Ubuntu\\mnt\\c\\Users\\alice\\.codex',
|
||||
managedCodexHomePath:
|
||||
'\\\\wsl.localhost\\Ubuntu\\mnt\\c\\Users\\alice\\orca\\codex-accounts\\drive-account\\home'
|
||||
})
|
||||
} finally {
|
||||
vi.doUnmock('../codex/codex-config-mirror')
|
||||
vi.doUnmock('../codex/codex-home-paths')
|
||||
vi.doUnmock('../codex/wsl-codex-session-bridge')
|
||||
vi.doUnmock('./legacy-wsl-runtime-auth-drain')
|
||||
vi.doUnmock('./wsl-codex-auth-batch-reader')
|
||||
vi.doUnmock('../wsl')
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves a mounted-drive WSL system home as the legacy drain destination', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const systemAuth = createCodexAuthJson(
|
||||
'system@example.com',
|
||||
'acct-system',
|
||||
'system-refresh',
|
||||
2_000
|
||||
)
|
||||
vi.doMock('../wsl', () => ({
|
||||
getDefaultWslDistro: () => 'Ubuntu',
|
||||
getWslHome: () => 'C:\\Users\\alice'
|
||||
}))
|
||||
vi.doMock('./wsl-codex-auth-batch-reader', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WslCodexAuthBatchReader>()),
|
||||
readWslCodexAuths: vi.fn(async (_distro: string, homes: string[]) =>
|
||||
homes.map((home) =>
|
||||
home === '/mnt/c/Users/alice/.codex'
|
||||
? { kind: 'present' as const, contents: systemAuth }
|
||||
: { kind: 'missing' as const }
|
||||
)
|
||||
)
|
||||
}))
|
||||
let drainDestination: { authContents: string; linuxHomePath: string } | null = null
|
||||
const drainTasks: Promise<void>[] = []
|
||||
vi.doMock('./legacy-wsl-runtime-auth-drain', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof LegacyWslRuntimeAuthDrain>()),
|
||||
startLegacyWslRuntimeAuthDrain: (
|
||||
options: Parameters<typeof LegacyWslRuntimeAuthDrain.startLegacyWslRuntimeAuthDrain>[0]
|
||||
) => {
|
||||
const task = Promise.resolve(options.resolveDestination(systemAuth)).then((destination) => {
|
||||
drainDestination = destination
|
||||
})
|
||||
drainTasks.push(task)
|
||||
return task
|
||||
}
|
||||
}))
|
||||
vi.doMock('../codex/wsl-codex-session-bridge', () => ({
|
||||
startWslCodexSessionBridgeInBackground: vi.fn(() => Promise.resolve())
|
||||
}))
|
||||
vi.doMock('../codex/codex-home-paths', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof CodexHomePaths>()),
|
||||
syncCodexGlobalInstructionsIntoManagedHome: vi.fn()
|
||||
}))
|
||||
vi.doMock('../codex/codex-config-mirror', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof CodexConfigMirror>()),
|
||||
syncSystemConfigIntoManagedCodexHome: vi.fn()
|
||||
}))
|
||||
const store = createStore(
|
||||
createSettings({
|
||||
activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } }
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
|
||||
'\\\\wsl.localhost\\Ubuntu\\mnt\\c\\Users\\alice\\.codex'
|
||||
)
|
||||
await Promise.all(drainTasks)
|
||||
expect(drainDestination).toEqual({
|
||||
authContents: systemAuth,
|
||||
linuxHomePath: '/mnt/c/Users/alice/.codex'
|
||||
})
|
||||
} finally {
|
||||
vi.doUnmock('../codex/codex-config-mirror')
|
||||
vi.doUnmock('../codex/codex-home-paths')
|
||||
vi.doUnmock('../codex/wsl-codex-session-bridge')
|
||||
vi.doUnmock('./legacy-wsl-runtime-auth-drain')
|
||||
vi.doUnmock('./wsl-codex-auth-batch-reader')
|
||||
vi.doUnmock('../wsl')
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type * as WslPaths from '../../shared/wsl-paths'
|
||||
import type * as CodexConfigMirror from '../codex/codex-config-mirror'
|
||||
import type * as CodexHomePaths from '../codex/codex-home-paths'
|
||||
import type * as LegacyWslRuntimeAuthDrain from './legacy-wsl-runtime-auth-drain'
|
||||
import type * as WslCodexAuthBatchReader from './wsl-codex-auth-batch-reader'
|
||||
import { createSettings } from './runtime-home-settings-test-fixtures'
|
||||
import {
|
||||
createCodexAuthJson,
|
||||
createManagedAuth,
|
||||
createStore,
|
||||
setupRuntimeHomeTest,
|
||||
@@ -34,62 +40,7 @@ describe('CodexRuntimeHomeService', () => {
|
||||
teardownRuntimeHomeTest()
|
||||
})
|
||||
|
||||
it('builds a valid WSL legacy active-home migration shell command', async () => {
|
||||
const execFileSyncMock = vi.fn()
|
||||
vi.doMock('node:child_process', () => ({ execFileSync: execFileSyncMock }))
|
||||
|
||||
try {
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(
|
||||
createStore(createSettings()) as never
|
||||
) as unknown as {
|
||||
migrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void
|
||||
}
|
||||
|
||||
service.migrateLegacyWslActiveHomePointer(
|
||||
'Ubuntu',
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
)
|
||||
|
||||
expect(execFileSyncMock).toHaveBeenCalledTimes(1)
|
||||
const firstCall = execFileSyncMock.mock.calls[0]
|
||||
expect(firstCall).toBeDefined()
|
||||
const [command, args] = firstCall as [string, string[]]
|
||||
expect(command).toBe('wsl.exe')
|
||||
expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', 'bash', '-lc'])
|
||||
expect(args).toHaveLength(6)
|
||||
|
||||
const shellCommand = args[5]
|
||||
expect(shellCommand).toContain(
|
||||
"if [ ! -e '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ] && [ ! -L '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ]; then :"
|
||||
)
|
||||
expect(shellCommand).toContain(
|
||||
"elif [ -e '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ] && [ ! -L '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ]; then :"
|
||||
)
|
||||
expect(shellCommand).toContain(
|
||||
"mkdir -p '/home/alice/.local/share/orca/codex-runtime-home/active/wsl'"
|
||||
)
|
||||
expect(shellCommand).toContain(
|
||||
"ln -s -- '/home/alice/.local/share/orca/codex-runtime-home/home' '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home.next-"
|
||||
)
|
||||
expect(shellCommand).toContain(
|
||||
"mv -Tf -- '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home.next-"
|
||||
)
|
||||
expect(shellCommand).toContain(
|
||||
"' '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home'"
|
||||
)
|
||||
expect(shellCommand).not.toContain('[! -L')
|
||||
expect(shellCommand).not.toContain('mv -Tf--')
|
||||
expect(shellCommand).not.toContain('$1')
|
||||
expect(shellCommand).not.toContain('$2')
|
||||
expect(shellCommand).not.toContain('$3')
|
||||
expect(shellCommand).not.toContain('exit 0')
|
||||
} finally {
|
||||
vi.doUnmock('node:child_process')
|
||||
}
|
||||
})
|
||||
|
||||
it('starts WSL session bridging after materializing the WSL launch home', async () => {
|
||||
it('skips WSL session bridging when system default already uses its direct home', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve())
|
||||
@@ -124,17 +75,13 @@ describe('CodexRuntimeHomeService', () => {
|
||||
)
|
||||
|
||||
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
|
||||
wslRuntimeHomePath
|
||||
wslSystemHomePath
|
||||
)
|
||||
expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1)
|
||||
expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({
|
||||
distro: 'Ubuntu',
|
||||
systemCodexHomePath: wslSystemHomePath,
|
||||
managedCodexHomePath: wslRuntimeHomePath
|
||||
})
|
||||
const runtimeAgentsPath = join(wslRuntimeHomePath, 'AGENTS.md')
|
||||
expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('# WSL instructions\n')
|
||||
expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(false)
|
||||
expect(startWslCodexSessionBridgeInBackground).not.toHaveBeenCalled()
|
||||
expect(readFileSync(join(wslSystemHomePath, 'AGENTS.md'), 'utf-8')).toBe(
|
||||
'# WSL instructions\n'
|
||||
)
|
||||
expect(existsSync(join(wslRuntimeHomePath, 'AGENTS.md'))).toBe(false)
|
||||
} finally {
|
||||
vi.doUnmock('../codex/wsl-codex-session-bridge')
|
||||
vi.doUnmock('../wsl')
|
||||
@@ -144,7 +91,7 @@ describe('CodexRuntimeHomeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('promotes WSL in-Codex setting changes on the next Codex launch', async () => {
|
||||
it('keeps WSL in-Codex setting changes in the direct system home', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
vi.doMock('../codex/wsl-codex-session-bridge', () => ({
|
||||
@@ -177,32 +124,20 @@ describe('CodexRuntimeHomeService', () => {
|
||||
'home'
|
||||
)
|
||||
|
||||
// First launch seeds the runtime config and records the per-distro baseline.
|
||||
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
|
||||
wslRuntimeHomePath
|
||||
join(wslHome, '.codex')
|
||||
)
|
||||
const baselinePath = join(wslRuntimeHomePath, '.orca-config-settings-baseline.json')
|
||||
expect(existsSync(baselinePath)).toBe(true)
|
||||
expect(existsSync(baselinePath)).toBe(false)
|
||||
|
||||
// A direct WSL Codex edit wins and is mirrored into Orca's runtime before
|
||||
// the baseline advances, so later in-Orca changes remain promotable.
|
||||
const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml')
|
||||
writeFileSync(wslSystemConfigPath, 'model = "outside-edit"\n', 'utf-8')
|
||||
service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "outside-edit"\n')
|
||||
expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"outside-edit\\""')
|
||||
|
||||
// Codex now persists a /model change inside Orca's reconciled runtime.
|
||||
writeFileSync(
|
||||
runtimeConfigPath,
|
||||
readFileSync(runtimeConfigPath, 'utf-8').replace('model = "outside-edit"', 'model = "o4"'),
|
||||
'utf-8'
|
||||
)
|
||||
expect(readFileSync(wslSystemConfigPath, 'utf-8')).toBe('model = "outside-edit"\n')
|
||||
|
||||
writeFileSync(wslSystemConfigPath, 'model = "o4"\n', 'utf-8')
|
||||
service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
expect(readFileSync(wslSystemConfigPath, 'utf-8')).toBe('model = "o4"\n')
|
||||
// Baseline advances so the promoted value is not re-promoted forever.
|
||||
expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"o4\\""')
|
||||
expect(existsSync(baselinePath)).toBe(false)
|
||||
} finally {
|
||||
vi.doUnmock('../codex/wsl-codex-session-bridge')
|
||||
vi.doUnmock('../wsl')
|
||||
@@ -236,22 +171,13 @@ describe('CodexRuntimeHomeService', () => {
|
||||
try {
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
const wslRuntimeHomePath = join(
|
||||
wslHome,
|
||||
'.local',
|
||||
'share',
|
||||
'orca',
|
||||
'codex-runtime-home',
|
||||
'home'
|
||||
)
|
||||
|
||||
service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
|
||||
expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1)
|
||||
expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({
|
||||
distro: 'Ubuntu',
|
||||
systemCodexHomePath: '/home/me/.config/codex',
|
||||
managedCodexHomePath: wslRuntimeHomePath
|
||||
managedCodexHomePath: join(wslHome, '.codex')
|
||||
})
|
||||
} finally {
|
||||
vi.doUnmock('../codex/wsl-codex-session-bridge')
|
||||
@@ -262,7 +188,7 @@ describe('CodexRuntimeHomeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('starts WSL session bridging for the distro used by the materialized runtime home', async () => {
|
||||
it('starts WSL session bridging for the selected direct account home', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve())
|
||||
@@ -270,14 +196,6 @@ describe('CodexRuntimeHomeService', () => {
|
||||
startWslCodexSessionBridgeInBackground
|
||||
}))
|
||||
const wslHome = join(testState.userDataDir, 'debian-wsl-home')
|
||||
const wslRuntimeHomePath = join(
|
||||
wslHome,
|
||||
'.local',
|
||||
'share',
|
||||
'orca',
|
||||
'codex-runtime-home',
|
||||
'home'
|
||||
)
|
||||
vi.doMock('../wsl', () => ({
|
||||
getDefaultWslDistro: () => null,
|
||||
getWslHome: (distro: string) => (distro === 'Debian' ? wslHome : null)
|
||||
@@ -287,10 +205,10 @@ describe('CodexRuntimeHomeService', () => {
|
||||
return {
|
||||
...actual,
|
||||
parseWslUncPath: (candidate: string) =>
|
||||
candidate === wslRuntimeHomePath
|
||||
candidate.includes('codex-accounts/debian-account/home')
|
||||
? {
|
||||
distro: 'Debian',
|
||||
linuxPath: '/home/alice/.local/share/orca/codex-runtime-home/home'
|
||||
linuxPath: '/home/alice/.local/share/orca/codex-accounts/debian-account/home'
|
||||
}
|
||||
: null
|
||||
}
|
||||
@@ -328,12 +246,12 @@ describe('CodexRuntimeHomeService', () => {
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: null })).toBe(
|
||||
wslRuntimeHomePath
|
||||
managedHomePath
|
||||
)
|
||||
expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({
|
||||
distro: 'Debian',
|
||||
systemCodexHomePath: join(wslHome, '.codex'),
|
||||
managedCodexHomePath: wslRuntimeHomePath
|
||||
managedCodexHomePath: managedHomePath
|
||||
})
|
||||
} finally {
|
||||
vi.doUnmock('../codex/wsl-codex-session-bridge')
|
||||
@@ -344,4 +262,251 @@ describe('CodexRuntimeHomeService', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('does not rescan retired sessions after the launch drain bridges them', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve())
|
||||
vi.doMock('../codex/wsl-codex-session-bridge', () => ({
|
||||
startWslCodexSessionBridgeInBackground
|
||||
}))
|
||||
const wslHome = join(testState.userDataDir, 'ubuntu-home')
|
||||
vi.doMock('../wsl', () => ({
|
||||
getDefaultWslDistro: () => 'Ubuntu',
|
||||
getWslHome: () => wslHome
|
||||
}))
|
||||
const managedHomePath = join(wslHome, '.local', 'share', 'orca', 'codex-accounts', 'a', 'home')
|
||||
const retiredBridgeRuns = vi.fn()
|
||||
vi.doMock('./legacy-wsl-runtime-auth-drain', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof LegacyWslRuntimeAuthDrain>()),
|
||||
startLegacyWslRuntimeAuthDrain: async (options: {
|
||||
onDestinationAuthorized?: (destination: {
|
||||
authContents: string
|
||||
linuxHomePath: string
|
||||
}) => void
|
||||
}) => {
|
||||
retiredBridgeRuns()
|
||||
options.onDestinationAuthorized?.({
|
||||
authContents: '{"account":"a"}\n',
|
||||
linuxHomePath: managedHomePath
|
||||
})
|
||||
}
|
||||
}))
|
||||
const store = createStore(
|
||||
createSettings({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'a',
|
||||
email: 'a@example.com',
|
||||
managedHomePath,
|
||||
managedHomeRuntime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
wslLinuxHomePath: managedHomePath,
|
||||
providerAccountId: 'acct-a',
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: 'acct-a',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
}
|
||||
],
|
||||
activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'a' } }
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
await service.prepareForCodexLaunchAsync({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
|
||||
expect(retiredBridgeRuns).toHaveBeenCalledTimes(1)
|
||||
expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledExactlyOnceWith({
|
||||
distro: 'Ubuntu',
|
||||
systemCodexHomePath: join(wslHome, '.codex'),
|
||||
managedCodexHomePath: managedHomePath
|
||||
})
|
||||
} finally {
|
||||
vi.doUnmock('./legacy-wsl-runtime-auth-drain')
|
||||
vi.doUnmock('../codex/wsl-codex-session-bridge')
|
||||
vi.doUnmock('../wsl')
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'links retired WSL sessions only into the auth-matched direct home',
|
||||
async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const guestHome = join(testState.userDataDir, 'ubuntu-home')
|
||||
const wslHome = `\\\\wsl.localhost\\Ubuntu${guestHome.replaceAll('/', '\\')}`
|
||||
vi.doMock('../wsl', () => ({
|
||||
getDefaultWslDistro: () => 'Ubuntu',
|
||||
getWslHome: () => wslHome
|
||||
}))
|
||||
const ownerAuth = createCodexAuthJson(
|
||||
'owner@example.com',
|
||||
'acct-owner',
|
||||
'owner-refresh',
|
||||
2_000
|
||||
)
|
||||
const selectedAuth = createCodexAuthJson(
|
||||
'selected@example.com',
|
||||
'acct-selected',
|
||||
'selected-refresh',
|
||||
2_000
|
||||
)
|
||||
const ownerHome = createManagedAuth(testState.userDataDir, 'owner', ownerAuth)
|
||||
const selectedHome = createManagedAuth(testState.userDataDir, 'selected', selectedAuth)
|
||||
vi.doMock('./wsl-codex-auth-batch-reader', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WslCodexAuthBatchReader>()),
|
||||
readWslCodexAuths: vi.fn(async (_distro: string, homes: string[]) =>
|
||||
homes.map((home) => {
|
||||
if (home === ownerHome) {
|
||||
return { kind: 'present' as const, contents: ownerAuth }
|
||||
}
|
||||
if (home === selectedHome) {
|
||||
return { kind: 'present' as const, contents: selectedAuth }
|
||||
}
|
||||
return { kind: 'missing' as const }
|
||||
})
|
||||
)
|
||||
}))
|
||||
const drainTasks: Promise<void>[] = []
|
||||
vi.doMock('../wsl/wsl-runner', () => ({
|
||||
runWslProcess: vi.fn(
|
||||
async (options: { args?: string[]; script: string; shell?: 'bash' }) => {
|
||||
try {
|
||||
const stdout = execFileSync(options.shell === 'bash' ? '/bin/bash' : '/bin/sh', [
|
||||
'-c',
|
||||
options.script,
|
||||
options.shell ?? 'sh',
|
||||
...(options.args ?? [])
|
||||
]).toString()
|
||||
return {
|
||||
code: 0,
|
||||
stdout,
|
||||
stderr: '',
|
||||
timedOut: false,
|
||||
environmentResolved: true
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
code: (error as { status?: number }).status ?? 1,
|
||||
stdout: (error as { stdout?: Buffer }).stdout?.toString() ?? '',
|
||||
stderr: (error as { stderr?: Buffer }).stderr?.toString() ?? '',
|
||||
timedOut: false,
|
||||
environmentResolved: true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}))
|
||||
vi.doMock('./legacy-wsl-runtime-auth-drain', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof LegacyWslRuntimeAuthDrain>()
|
||||
return {
|
||||
...actual,
|
||||
startLegacyWslRuntimeAuthDrain: (
|
||||
options: Parameters<typeof actual.startLegacyWslRuntimeAuthDrain>[0]
|
||||
) => {
|
||||
const task = actual.startLegacyWslRuntimeAuthDrain(options)
|
||||
drainTasks.push(task)
|
||||
return task
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.doMock('../codex/codex-home-paths', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof CodexHomePaths>()),
|
||||
syncCodexGlobalInstructionsIntoManagedHome: vi.fn()
|
||||
}))
|
||||
vi.doMock('../codex/codex-config-mirror', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof CodexConfigMirror>()),
|
||||
syncSystemConfigIntoManagedCodexHome: vi.fn()
|
||||
}))
|
||||
const retiredHome = join(guestHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home')
|
||||
const relativeSessionPath = join('sessions', '2026', '08', '26', 'retired.jsonl')
|
||||
const retiredSessionPath = join(retiredHome, relativeSessionPath)
|
||||
mkdirSync(join(retiredSessionPath, '..'), { recursive: true })
|
||||
writeFileSync(join(retiredHome, 'auth.json'), ownerAuth, 'utf-8')
|
||||
writeFileSync(retiredSessionPath, '{"session":"retired"}\n', 'utf-8')
|
||||
const blockedTargetDirectory = join(ownerHome, 'sessions', '2026')
|
||||
mkdirSync(join(blockedTargetDirectory, '..'), { recursive: true })
|
||||
writeFileSync(blockedTargetDirectory, 'not-a-directory\n', 'utf-8')
|
||||
const store = createStore(
|
||||
createSettings({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'owner',
|
||||
email: 'owner@example.com',
|
||||
managedHomePath: ownerHome,
|
||||
managedHomeRuntime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
wslLinuxHomePath: ownerHome,
|
||||
providerAccountId: 'acct-owner',
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: 'acct-owner',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
},
|
||||
{
|
||||
id: 'selected',
|
||||
email: 'selected@example.com',
|
||||
managedHomePath: selectedHome,
|
||||
managedHomeRuntime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
wslLinuxHomePath: selectedHome,
|
||||
providerAccountId: 'acct-selected',
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: 'acct-selected',
|
||||
createdAt: 2,
|
||||
updatedAt: 2,
|
||||
lastAuthenticatedAt: 2
|
||||
}
|
||||
],
|
||||
activeCodexManagedAccountIdsByRuntime: {
|
||||
host: null,
|
||||
wsl: { Ubuntu: 'selected' }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
await Promise.all(drainTasks)
|
||||
|
||||
const linkedSessionPath = join(ownerHome, relativeSessionPath)
|
||||
expect(existsSync(linkedSessionPath)).toBe(false)
|
||||
rmSync(blockedTargetDirectory)
|
||||
|
||||
service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
await Promise.all(drainTasks)
|
||||
expect(readFileSync(linkedSessionPath, 'utf-8')).toBe('{"session":"retired"}\n')
|
||||
expect(statSync(linkedSessionPath).ino).toBe(statSync(retiredSessionPath).ino)
|
||||
expect(existsSync(join(selectedHome, relativeSessionPath))).toBe(false)
|
||||
|
||||
rmSync(linkedSessionPath)
|
||||
rmSync(retiredHome, { recursive: true })
|
||||
service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
await Promise.all(drainTasks)
|
||||
expect(existsSync(linkedSessionPath)).toBe(false)
|
||||
} finally {
|
||||
vi.doUnmock('../codex/wsl-codex-session-bridge')
|
||||
vi.doUnmock('../codex/codex-config-mirror')
|
||||
vi.doUnmock('../codex/codex-home-paths')
|
||||
vi.doUnmock('./legacy-wsl-runtime-auth-drain')
|
||||
vi.doUnmock('./wsl-codex-auth-batch-reader')
|
||||
vi.doUnmock('../wsl/wsl-runner')
|
||||
vi.doUnmock('../wsl')
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -53,15 +53,6 @@ describe('CodexRuntimeHomeService', () => {
|
||||
try {
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
const syncWslRuntime = vi.spyOn(
|
||||
service as unknown as {
|
||||
syncWslRuntimeForCurrentSelection: (target: {
|
||||
runtime: 'wsl'
|
||||
wslDistro?: string | null
|
||||
}) => string | null
|
||||
},
|
||||
'syncWslRuntimeForCurrentSelection'
|
||||
)
|
||||
const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' }
|
||||
const expectedHome = join(wslHome, '.codex')
|
||||
|
||||
@@ -73,7 +64,6 @@ describe('CodexRuntimeHomeService', () => {
|
||||
kind: 'ready',
|
||||
codexHomePath: expectedHome
|
||||
})
|
||||
expect(syncWslRuntime).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
@@ -93,15 +83,6 @@ describe('CodexRuntimeHomeService', () => {
|
||||
const debianAuth = createCodexAuthJson('debian@example.com', 'acct-debian', 'debian-token')
|
||||
const ubuntuHomePath = createManagedAuth(testState.userDataDir, 'ubuntu-account', ubuntuAuth)
|
||||
const debianHomePath = createManagedAuth(testState.userDataDir, 'debian-account', debianAuth)
|
||||
const runtimeAuthPath = join(
|
||||
wslHome,
|
||||
'.local',
|
||||
'share',
|
||||
'orca',
|
||||
'codex-runtime-home',
|
||||
'home',
|
||||
'auth.json'
|
||||
)
|
||||
const store = createStore(
|
||||
createSettings({
|
||||
codexManagedAccounts: [
|
||||
@@ -147,9 +128,9 @@ describe('CodexRuntimeHomeService', () => {
|
||||
|
||||
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: null })).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home')
|
||||
codexHomePath: ubuntuHomePath
|
||||
})
|
||||
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(ubuntuAuth)
|
||||
expect(readFileSync(join(ubuntuHomePath, 'auth.json'), 'utf-8')).toBe(ubuntuAuth)
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
@@ -228,7 +209,7 @@ describe('CodexRuntimeHomeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('reads WSL system-default token refreshes back to WSL system auth', async () => {
|
||||
it('keeps WSL system-default token refreshes in its direct home', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const wslHome = join(testState.userDataDir, 'wsl-home')
|
||||
@@ -257,21 +238,11 @@ describe('CodexRuntimeHomeService', () => {
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' }
|
||||
const wslRuntimeHomePath = join(
|
||||
wslHome,
|
||||
'.local',
|
||||
'share',
|
||||
'orca',
|
||||
'codex-runtime-home',
|
||||
'home'
|
||||
)
|
||||
expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath)
|
||||
writeFileSync(join(systemCodexHomePath, 'auth.json'), refreshedAuth, 'utf-8')
|
||||
|
||||
expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath)
|
||||
writeFileSync(join(wslRuntimeHomePath, 'auth.json'), refreshedAuth, 'utf-8')
|
||||
|
||||
expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath)
|
||||
expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath)
|
||||
expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth)
|
||||
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth)
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
@@ -279,7 +250,7 @@ describe('CodexRuntimeHomeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves WSL system-default token refreshes after app restart', async () => {
|
||||
it('does not overwrite direct WSL system auth from the retired runtime on restart', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const wslHome = join(testState.userDataDir, 'wsl-home')
|
||||
@@ -319,8 +290,8 @@ describe('CodexRuntimeHomeService', () => {
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' }
|
||||
|
||||
expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath)
|
||||
expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth)
|
||||
expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath)
|
||||
expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth)
|
||||
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth)
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
@@ -362,7 +333,7 @@ describe('CodexRuntimeHomeService', () => {
|
||||
|
||||
expect(syncConfig).toHaveBeenCalledWith({
|
||||
runtimeHomePath: join(testState.userDataDir, 'runtime-home'),
|
||||
systemHomePath: 'C:\\Users\\alice/.codex',
|
||||
systemHomePath: '\\\\wsl.localhost\\Ubuntu\\mnt\\c\\Users\\alice\\.codex',
|
||||
systemConfigDir: '/mnt/c/Users/alice/.codex'
|
||||
})
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { runWslProcess } from '../wsl/wsl-runner'
|
||||
|
||||
export type WslCodexAuthRead =
|
||||
| { kind: 'missing' | 'unreadable' }
|
||||
| { kind: 'present'; contents: string }
|
||||
|
||||
export async function readWslCodexAuths(
|
||||
distro: string,
|
||||
linuxHomePaths: readonly string[]
|
||||
): Promise<WslCodexAuthRead[]> {
|
||||
if (linuxHomePaths.length === 0) {
|
||||
return []
|
||||
}
|
||||
const result = await runWslProcess({
|
||||
distro,
|
||||
loginPath: 'none',
|
||||
script: READ_AUTHS_SCRIPT,
|
||||
args: linuxHomePaths,
|
||||
timeoutMs: 5_000,
|
||||
maxOutputBytes: 2 * 1024 * 1024
|
||||
})
|
||||
if (result.code !== 0 || result.timedOut) {
|
||||
return linuxHomePaths.map(() => ({ kind: 'unreadable' }))
|
||||
}
|
||||
const rows = result.stdout.split('\n')
|
||||
return linuxHomePaths.map((_, index) => parseAuthReadRow(rows[index] ?? ''))
|
||||
}
|
||||
|
||||
export function decodeWslBase64Payload(encoded: string): string | null {
|
||||
try {
|
||||
const decoded = Buffer.from(encoded, 'base64')
|
||||
const canonical = decoded.toString('base64').replace(/=+$/, '')
|
||||
return canonical === encoded.replace(/=+$/, '') ? decoded.toString('utf8') : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseAuthReadRow(row: string): WslCodexAuthRead {
|
||||
if (row === 'missing' || row === 'unreadable') {
|
||||
return { kind: row }
|
||||
}
|
||||
if (!row.startsWith('present:')) {
|
||||
return { kind: 'unreadable' }
|
||||
}
|
||||
const contents = decodeWslBase64Payload(row.slice('present:'.length))
|
||||
return contents === null ? { kind: 'unreadable' } : { kind: 'present', contents }
|
||||
}
|
||||
|
||||
const READ_AUTHS_SCRIPT = `
|
||||
set -eu
|
||||
for home_path in "$@"; do
|
||||
auth_path="$home_path/auth.json"
|
||||
if [ ! -f "$auth_path" ]; then
|
||||
if [ ! -e "$auth_path" ] && [ ! -L "$auth_path" ]; then
|
||||
printf 'missing\n'
|
||||
else
|
||||
printf 'unreadable\n'
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
if encoded=$(base64 < "$auth_path"); then
|
||||
printf 'present:'
|
||||
printf '%s' "$encoded" | tr -d '\n'
|
||||
printf '\n'
|
||||
else
|
||||
printf 'unreadable\n'
|
||||
fi
|
||||
done
|
||||
`
|
||||
@@ -154,6 +154,25 @@ export class CodexHookService {
|
||||
return install
|
||||
}
|
||||
|
||||
async prepareRuntimeHomeForLaunch(
|
||||
runtimeHomePath: string | null | undefined,
|
||||
target: CodexWslRuntimeHookTarget | undefined,
|
||||
hooksEnabled: boolean
|
||||
): Promise<AgentHookInstallStatus> {
|
||||
if (hooksEnabled) {
|
||||
// Why: a managed account's launch home is its self-contained CODEX_HOME,
|
||||
// so hooks/trust must install there rather than the shared mirror.
|
||||
return (
|
||||
(await this.installForRuntimeHomeSerialized(runtimeHomePath, target)) ??
|
||||
(await this.install(runtimeHomePath ?? undefined))
|
||||
)
|
||||
}
|
||||
return (
|
||||
this.refreshRuntimeUserHooksForRuntimeHome(runtimeHomePath, target) ??
|
||||
(await this.refreshRuntimeUserHooks(runtimeHomePath ?? undefined))
|
||||
)
|
||||
}
|
||||
|
||||
refreshRuntimeUserHooksForRuntimeHome(
|
||||
runtimeHomePath: string | null | undefined,
|
||||
target?: CodexWslRuntimeHookTarget
|
||||
|
||||
@@ -26,4 +26,6 @@ export type CodexPaneAccountRecord = {
|
||||
export type CodexPaneAccountRegistryFile = {
|
||||
version: 2
|
||||
panes: Record<string, CodexPaneAccountRecord>
|
||||
/** Set after record loss until daemon inventory proves no unattributed pane remains. */
|
||||
legacyWslAttributionUnknown?: true
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export type {
|
||||
*/
|
||||
|
||||
let cachedRegistry: CodexPaneAccountRegistryFile | null = null
|
||||
let cachedRegistryIsAuthoritative = true
|
||||
|
||||
function getRegistryPath(): string {
|
||||
return join(getOrcaUserDataPath(), 'codex-pane-accounts.json')
|
||||
@@ -58,7 +59,12 @@ function readRegistryOrNull(): CodexPaneAccountRegistryFile | null {
|
||||
// Why: a corrupt registry still degrades to empty and IS cached — rebuilding
|
||||
// unparseable state is the intent, and re-reading it every call would only
|
||||
// repeat the parse failure.
|
||||
cachedRegistry = parseRegistry(parseRegistryJson(rawRegistry))
|
||||
const parsedRegistry = parseRegistryJson(rawRegistry)
|
||||
cachedRegistryIsAuthoritative = isAuthoritativeRegistry(parsedRegistry)
|
||||
cachedRegistry = parseRegistry(parsedRegistry)
|
||||
if (!cachedRegistryIsAuthoritative) {
|
||||
cachedRegistry.legacyWslAttributionUnknown = true
|
||||
}
|
||||
return cachedRegistry
|
||||
}
|
||||
|
||||
@@ -85,12 +91,30 @@ function readRegistry(): CodexPaneAccountRegistryFile {
|
||||
function readRegistryOrThrow(): CodexPaneAccountRegistryFile {
|
||||
mutations.flush()
|
||||
const registry = readRegistryOrNull()
|
||||
if (!registry) {
|
||||
if (!registry || !cachedRegistryIsAuthoritative) {
|
||||
throw new Error('Codex pane account registry could not be read')
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
function isAuthoritativeRegistry(parsed: unknown): boolean {
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return false
|
||||
}
|
||||
const panes = (parsed as Partial<CodexPaneAccountRegistryFile>).panes
|
||||
const version = (parsed as Partial<CodexPaneAccountRegistryFile>).version
|
||||
const legacyWslAttributionUnknown = (parsed as Partial<CodexPaneAccountRegistryFile>)
|
||||
.legacyWslAttributionUnknown
|
||||
return (
|
||||
version === 2 &&
|
||||
Boolean(panes) &&
|
||||
typeof panes === 'object' &&
|
||||
!Array.isArray(panes) &&
|
||||
Object.values(panes).every(isPaneAccountRecord) &&
|
||||
(legacyWslAttributionUnknown === undefined || legacyWslAttributionUnknown === true)
|
||||
)
|
||||
}
|
||||
|
||||
function parseRegistry(parsed: unknown): CodexPaneAccountRegistryFile {
|
||||
const empty: CodexPaneAccountRegistryFile = { version: 2, panes: {} }
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
@@ -100,6 +124,9 @@ function parseRegistry(parsed: unknown): CodexPaneAccountRegistryFile {
|
||||
if (!panes || typeof panes !== 'object' || Array.isArray(panes)) {
|
||||
return empty
|
||||
}
|
||||
if ((parsed as Partial<CodexPaneAccountRegistryFile>).legacyWslAttributionUnknown === true) {
|
||||
empty.legacyWslAttributionUnknown = true
|
||||
}
|
||||
for (const [ptyId, record] of Object.entries(panes)) {
|
||||
if (isPaneAccountRecord(record)) {
|
||||
empty.panes[ptyId] = {
|
||||
@@ -147,7 +174,8 @@ function isPaneAccountRecord(value: unknown): value is CodexPaneAccountRecord {
|
||||
}
|
||||
const record = value as Partial<CodexPaneAccountRecord>
|
||||
return (
|
||||
typeof record.selectionKey === 'string' &&
|
||||
(record.selectionKey === 'host' ||
|
||||
(typeof record.selectionKey === 'string' && /^wsl:.+/.test(record.selectionKey))) &&
|
||||
(record.accountId === null || typeof record.accountId === 'string')
|
||||
)
|
||||
}
|
||||
@@ -172,6 +200,7 @@ function writeRegistry(registry: CodexPaneAccountRegistryFile): boolean {
|
||||
mode: 0o600
|
||||
})
|
||||
renameSync(temporaryPath, registryPath)
|
||||
cachedRegistryIsAuthoritative = true
|
||||
return true
|
||||
} catch (error) {
|
||||
// Why: this record only powers a restart hint; losing it must never break a
|
||||
@@ -266,6 +295,41 @@ export function hasRecordedLegacySharedCodexPane(): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
/** True when a retained WSL pane may still read the retired per-distro runtime home. */
|
||||
export function hasRecordedLegacyWslCodexPane(selectionKey: string): boolean {
|
||||
const registry = readRegistryOrThrow()
|
||||
return (
|
||||
Boolean(registry.legacyWslAttributionUnknown) ||
|
||||
Object.values(registry.panes).some(
|
||||
(record) =>
|
||||
(wslSelectionKeysMatch(record.selectionKey, selectionKey) ||
|
||||
record.selectionKey === 'wsl:__default__') &&
|
||||
(record.homeRoute === undefined || record.homeRoute === 'wsl-home')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function wslSelectionKeysMatch(left: string, right: string): boolean {
|
||||
return (
|
||||
left.startsWith('wsl:') &&
|
||||
right.startsWith('wsl:') &&
|
||||
left.slice('wsl:'.length).toLowerCase() === right.slice('wsl:'.length).toLowerCase()
|
||||
)
|
||||
}
|
||||
|
||||
/** True when startup should reconcile a retained legacy WSL record with daemon inventory. */
|
||||
export function hasAnyRecordedLegacyWslCodexPane(): boolean {
|
||||
const registry = readRegistry()
|
||||
return (
|
||||
Boolean(registry.legacyWslAttributionUnknown) ||
|
||||
Object.values(registry.panes).some(
|
||||
(record) =>
|
||||
record.selectionKey.startsWith('wsl:') &&
|
||||
(record.homeRoute === undefined || record.homeRoute === 'wsl-home')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** True when startup may need to repair hooks for a retained managed host pane. */
|
||||
export function hasRecordedManagedHostCodexPane(): boolean {
|
||||
return Object.values(readRegistry().panes).some(
|
||||
@@ -290,6 +354,13 @@ export function reconcileCodexPaneAccountsWithLivePtys(livePtyIds: readonly stri
|
||||
}
|
||||
const livePtyIdSet = new Set(livePtyIds)
|
||||
let changed = false
|
||||
if (
|
||||
registry.legacyWslAttributionUnknown &&
|
||||
livePtyIds.every((ptyId) => ptyId in registry.panes)
|
||||
) {
|
||||
delete registry.legacyWslAttributionUnknown
|
||||
changed = true
|
||||
}
|
||||
for (const ptyId of Object.keys(registry.panes)) {
|
||||
if (!livePtyIdSet.has(ptyId)) {
|
||||
delete registry.panes[ptyId]
|
||||
@@ -302,6 +373,7 @@ export function reconcileCodexPaneAccountsWithLivePtys(livePtyIds: readonly stri
|
||||
export const _internals = {
|
||||
resetCache: (): void => {
|
||||
cachedRegistry = null
|
||||
cachedRegistryIsAuthoritative = true
|
||||
mutations.reset()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +202,35 @@ describe('resolveCodexPaneLaunchAccount', () => {
|
||||
).toEqual({
|
||||
selectionKey: 'wsl:Ubuntu',
|
||||
accountId: 'wsl-account',
|
||||
homeRoute: 'wsl-home'
|
||||
homeRoute: 'account-home'
|
||||
})
|
||||
})
|
||||
|
||||
it('attributes a mounted-drive WSL launch through its distro UNC spelling', () => {
|
||||
const account = managedAccount({
|
||||
id: 'drive-account',
|
||||
managedHomePath: 'C:\\Users\\u\\orca\\codex-accounts\\drive-account\\home',
|
||||
managedHomeRuntime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
wslLinuxHomePath: '/mnt/c/Users/u/orca/codex-accounts/drive-account/home'
|
||||
})
|
||||
const args = {
|
||||
launchCodexHomePath:
|
||||
'\\\\wsl.localhost\\Ubuntu\\mnt\\c\\Users\\u\\orca\\codex-accounts\\drive-account\\home',
|
||||
systemCodexHomePath: SYSTEM_HOME,
|
||||
settings: settings({ wsl: { Ubuntu: 'drive-account' }, accounts: [account] }),
|
||||
target: { runtime: 'wsl' as const, wslDistro: 'Ubuntu' }
|
||||
}
|
||||
|
||||
expect(resolveCodexPaneLaunchAccount({ ...args, pinnedByResume: false })).toEqual({
|
||||
selectionKey: 'wsl:Ubuntu',
|
||||
accountId: 'drive-account',
|
||||
homeRoute: 'account-home'
|
||||
})
|
||||
expect(resolveCodexPaneLaunchAccount({ ...args, pinnedByResume: true })).toEqual({
|
||||
selectionKey: 'wsl:Ubuntu',
|
||||
accountId: 'drive-account',
|
||||
homeRoute: 'account-home'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { GlobalSettings } from '../../shared/global-settings-types'
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import {
|
||||
getCodexSelectionLaneKey,
|
||||
getCodexSelectionTargetForAccount,
|
||||
@@ -80,9 +81,6 @@ function resolveCodexPaneHomeRoute(args: {
|
||||
settings: CodexPaneLaunchAccountSettings
|
||||
target: CodexAccountSelectionTarget
|
||||
}): CodexPaneHomeRoute {
|
||||
if (args.target.runtime === 'wsl') {
|
||||
return 'wsl-home'
|
||||
}
|
||||
if (
|
||||
!args.launchCodexHomePath ||
|
||||
normalizeRuntimePathForComparison(args.launchCodexHomePath) ===
|
||||
@@ -90,13 +88,19 @@ function resolveCodexPaneHomeRoute(args: {
|
||||
) {
|
||||
return 'real-home'
|
||||
}
|
||||
const launchHome = normalizeRuntimePathForComparison(args.launchCodexHomePath)
|
||||
const accountOwnsHome = args.settings.codexManagedAccounts?.some(
|
||||
(account) =>
|
||||
getCodexSelectionTargetForAccount(account).runtime === 'host' &&
|
||||
normalizeRuntimePathForComparison(account.managedHomePath) === launchHome
|
||||
const launchHomePath = args.launchCodexHomePath
|
||||
const accountOwnsHome = args.settings.codexManagedAccounts?.some((account) =>
|
||||
accountOwnsCodexHome(account, args.target, launchHomePath)
|
||||
)
|
||||
return accountOwnsHome ? 'account-home' : 'shared-home'
|
||||
if (accountOwnsHome) {
|
||||
return 'account-home'
|
||||
}
|
||||
if (args.target.runtime === 'wsl') {
|
||||
return parseWslUncPath(args.launchCodexHomePath)?.linuxPath.endsWith('/.codex')
|
||||
? 'real-home'
|
||||
: 'wsl-home'
|
||||
}
|
||||
return 'shared-home'
|
||||
}
|
||||
|
||||
/** undefined when no account owns the home; null means the system-default account. */
|
||||
@@ -110,17 +114,15 @@ function resolveCodexHomeOwnerAccountId(args: {
|
||||
if (!args.launchCodexHomePath) {
|
||||
return null
|
||||
}
|
||||
const launchHome = normalizeRuntimePathForComparison(args.launchCodexHomePath)
|
||||
if (launchHome === normalizeRuntimePathForComparison(args.systemCodexHomePath)) {
|
||||
if (
|
||||
normalizeRuntimePathForComparison(args.launchCodexHomePath) ===
|
||||
normalizeRuntimePathForComparison(args.systemCodexHomePath)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const laneKey = getCodexSelectionLaneKey(args.target)
|
||||
const owner = args.settings.codexManagedAccounts?.find(
|
||||
(account) =>
|
||||
// Why: a WSL pane resolves its account from its own per-distro lane, so a
|
||||
// host account's home must never answer for it (and vice versa).
|
||||
getCodexSelectionLaneKey(getCodexSelectionTargetForAccount(account)) === laneKey &&
|
||||
normalizeRuntimePathForComparison(account.managedHomePath) === launchHome
|
||||
const launchHomePath = args.launchCodexHomePath
|
||||
const owner = args.settings.codexManagedAccounts?.find((account) =>
|
||||
accountOwnsCodexHome(account, args.target, launchHomePath)
|
||||
)
|
||||
// Why: an unowned home cannot be named, and naming the account a pane is stuck
|
||||
// on is the prompt's whole job — so decline rather than guess. A wrong notice
|
||||
@@ -130,3 +132,34 @@ function resolveCodexHomeOwnerAccountId(args: {
|
||||
// still unnameable, so that cohort stays unreported.
|
||||
return owner ? owner.id : undefined
|
||||
}
|
||||
|
||||
function accountOwnsCodexHome(
|
||||
account: NonNullable<CodexPaneLaunchAccountSettings['codexManagedAccounts']>[number],
|
||||
target: CodexAccountSelectionTarget,
|
||||
launchHomePath: string
|
||||
): boolean {
|
||||
// Why: a WSL pane resolves its account from its own per-distro lane, so a
|
||||
// host account's home must never answer for it (and vice versa).
|
||||
if (
|
||||
getCodexSelectionLaneKey(getCodexSelectionTargetForAccount(account)) !==
|
||||
getCodexSelectionLaneKey(target)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
normalizeRuntimePathForComparison(account.managedHomePath) ===
|
||||
normalizeRuntimePathForComparison(launchHomePath)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
const launchWslHome = target.runtime === 'wsl' ? parseWslUncPath(launchHomePath) : null
|
||||
const accountDistro = account.wslDistro?.trim()
|
||||
const accountLinuxHome = account.wslLinuxHomePath?.trim()
|
||||
return Boolean(
|
||||
launchWslHome &&
|
||||
accountDistro &&
|
||||
accountLinuxHome &&
|
||||
launchWslHome.distro.toLowerCase() === accountDistro.toLowerCase() &&
|
||||
launchWslHome.linuxPath === accountLinuxHome
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
_internals,
|
||||
forgetCodexPaneAccount,
|
||||
getCodexPaneAccount,
|
||||
hasAnyRecordedLegacyWslCodexPane,
|
||||
hasRecordedLegacySharedCodexPane,
|
||||
hasRecordedLegacyWslCodexPane,
|
||||
hasRecordedManagedHostCodexPane,
|
||||
isCodexPaneHomeRouteProvenAwayFromSharedHome,
|
||||
reconcileCodexPaneAccountsWithLivePtys,
|
||||
@@ -138,6 +140,53 @@ describe('codex pane account registry', () => {
|
||||
expect(hasRecordedLegacySharedCodexPane()).toBe(true)
|
||||
})
|
||||
|
||||
it('identifies only legacy runtime-home panes on the requested WSL lane', () => {
|
||||
recordCodexPaneAccount('pty-legacy', {
|
||||
selectionKey: 'wsl:Ubuntu',
|
||||
accountId: 'account-old',
|
||||
homeRoute: 'wsl-home'
|
||||
})
|
||||
recordCodexPaneAccount('pty-direct', {
|
||||
selectionKey: 'wsl:Ubuntu',
|
||||
accountId: 'account-new',
|
||||
homeRoute: 'account-home'
|
||||
})
|
||||
recordCodexPaneAccount('pty-other-distro', {
|
||||
selectionKey: 'wsl:Debian',
|
||||
accountId: 'account-debian',
|
||||
homeRoute: 'wsl-home'
|
||||
})
|
||||
recordCodexPaneAccount('pty-default', {
|
||||
selectionKey: 'wsl:__default__',
|
||||
accountId: null,
|
||||
homeRoute: 'wsl-home'
|
||||
})
|
||||
|
||||
expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(true)
|
||||
expect(hasRecordedLegacyWslCodexPane('wsl:ubuntu')).toBe(true)
|
||||
forgetCodexPaneAccount('pty-legacy')
|
||||
expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(true)
|
||||
forgetCodexPaneAccount('pty-default')
|
||||
expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(false)
|
||||
expect(hasRecordedLegacyWslCodexPane('wsl:Debian')).toBe(true)
|
||||
})
|
||||
|
||||
it('requests daemon reconciliation for WSL-only legacy records', () => {
|
||||
recordCodexPaneAccount('pty-direct', {
|
||||
selectionKey: 'wsl:Ubuntu',
|
||||
accountId: 'account-new',
|
||||
homeRoute: 'account-home'
|
||||
})
|
||||
expect(hasAnyRecordedLegacyWslCodexPane()).toBe(false)
|
||||
|
||||
recordCodexPaneAccount('pty-legacy', {
|
||||
selectionKey: 'wsl:Ubuntu',
|
||||
accountId: 'account-old',
|
||||
homeRoute: 'wsl-home'
|
||||
})
|
||||
expect(hasAnyRecordedLegacyWslCodexPane()).toBe(true)
|
||||
})
|
||||
|
||||
it('requests startup inventory only for managed host panes', () => {
|
||||
recordCodexPaneAccount('pty-real', {
|
||||
selectionKey: 'host',
|
||||
@@ -240,6 +289,44 @@ describe('codex pane account registry', () => {
|
||||
expect(getCodexPaneAccount('pty-1')).toEqual({ selectionKey: 'host', accountId: 'account-a' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['unparseable JSON', '{ not json'],
|
||||
['a malformed pane record', '{"version":2,"panes":{"pty-1":{"selectionKey":7}}}'],
|
||||
[
|
||||
'an invalid lane key',
|
||||
'{"version":2,"panes":{"pty-1":{"selectionKey":"Ubuntu","accountId":null}}}'
|
||||
],
|
||||
['an unknown registry version', '{"version":999,"panes":{}}']
|
||||
])('refuses to authorize a destructive WSL drain from %s', (_label, contents) => {
|
||||
writeFileSync(join(userDataPath, 'codex-pane-accounts.json'), contents)
|
||||
_internals.resetCache()
|
||||
|
||||
expect(() => hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toThrow('registry could not be read')
|
||||
})
|
||||
|
||||
it('does not authorize retirement after a new pane repairs a corrupt registry', () => {
|
||||
writeFileSync(join(userDataPath, 'codex-pane-accounts.json'), '{ not json')
|
||||
_internals.resetCache()
|
||||
expect(() => hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toThrow()
|
||||
|
||||
recordCodexPaneAccount('pty-direct', {
|
||||
selectionKey: 'wsl:Ubuntu',
|
||||
accountId: 'account-new',
|
||||
homeRoute: 'account-home'
|
||||
})
|
||||
_internals.resetCache()
|
||||
|
||||
expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(true)
|
||||
|
||||
reconcileCodexPaneAccountsWithLivePtys(['pty-direct', 'pty-legacy-unknown'])
|
||||
_internals.resetCache()
|
||||
expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(true)
|
||||
|
||||
reconcileCodexPaneAccountsWithLivePtys(['pty-direct'])
|
||||
_internals.resetCache()
|
||||
expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(false)
|
||||
})
|
||||
|
||||
it('drops a malformed record without discarding its valid siblings', () => {
|
||||
writeFileSync(
|
||||
join(userDataPath, 'codex-pane-accounts.json'),
|
||||
|
||||
@@ -83,6 +83,39 @@ function expectedManagedCommand(scriptPath: string): string {
|
||||
}
|
||||
|
||||
describe('Codex WSL runtime hook install', () => {
|
||||
it('coalesces launch installs for one home without blocking independent homes', async () => {
|
||||
const service = new CodexHookService()
|
||||
const releases: (() => void)[] = []
|
||||
const started: string[] = []
|
||||
vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (runtimeHomePath) => {
|
||||
if (!runtimeHomePath) {
|
||||
throw new Error('expected a runtime home')
|
||||
}
|
||||
started.push(runtimeHomePath)
|
||||
await new Promise<void>((resolve) => releases.push(resolve))
|
||||
return {
|
||||
agent: 'codex',
|
||||
state: 'installed',
|
||||
configPath: `${runtimeHomePath}\\hooks.json`,
|
||||
managedHooksPresent: true,
|
||||
detail: null
|
||||
}
|
||||
})
|
||||
const firstHome = '\\\\wsl$\\Ubuntu\\home\\Alice\\.codex'
|
||||
const alias = firstHome.replace('\\\\wsl$', '\\\\wsl.localhost')
|
||||
const independent = firstHome.replace('\\Alice\\', '\\Bob\\')
|
||||
const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' }
|
||||
|
||||
const first = service.prepareRuntimeHomeForLaunch(firstHome, target, true)
|
||||
const second = service.prepareRuntimeHomeForLaunch(alias, target, true)
|
||||
const third = service.prepareRuntimeHomeForLaunch(independent, target, true)
|
||||
await vi.waitFor(() => expect(started).toEqual([firstHome, independent]))
|
||||
|
||||
releases.splice(0).forEach((release) => release())
|
||||
await Promise.all([first, second, third])
|
||||
expect(started).toEqual([firstHome, independent])
|
||||
})
|
||||
|
||||
it('coalesces aliases of one runtime home without blocking independent homes', async () => {
|
||||
const service = new CodexHookService()
|
||||
const releases: (() => void)[] = []
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { quotePosixShell } from '../../shared/wsl-login-shell-command'
|
||||
|
||||
export const WSL_SESSION_BRIDGE_TIMEOUT_MS = 30_000
|
||||
|
||||
const WSL_CODEX_SESSION_BRIDGE_BODY = [
|
||||
'set -u',
|
||||
'rollback_manifest=${3-}',
|
||||
'rollback_stage_root=${4-}',
|
||||
'scan_scope=${5-full}',
|
||||
'scan_start=${6-}',
|
||||
'scan_end=${7-}',
|
||||
'if [ -n "$rollback_manifest" ]; then',
|
||||
' [ -n "$rollback_stage_root" ] || exit 1',
|
||||
' mkdir -p -- "$rollback_stage_root" || exit 1',
|
||||
' : > "$rollback_manifest" || exit 1',
|
||||
'fi',
|
||||
'scanned_files=0',
|
||||
'linked_files=0',
|
||||
'bridge_failed=0',
|
||||
'if [ ! -d "$source_sessions_root" ]; then',
|
||||
` printf '{"scannedFiles":0,"linkedFiles":0}\\n'`,
|
||||
' exit 0',
|
||||
'fi',
|
||||
'file_list=$(mktemp) || exit 1',
|
||||
'day_list="$file_list.days"',
|
||||
'trap \'rm -f -- "$file_list" "$day_list"\' EXIT HUP INT TERM',
|
||||
'case "$scan_scope" in',
|
||||
` full) find "$source_sessions_root" -type f -name '*.jsonl' -print0 > "$file_list" || exit 1 ;;`,
|
||||
' recent)',
|
||||
' case "$scan_start" in ????/??/??) ;; *) exit 1 ;; esac',
|
||||
' case "$scan_end" in ????/??/??) ;; *) exit 1 ;; esac',
|
||||
' if [[ "$scan_start" > "$scan_end" ]]; then',
|
||||
` find "$source_sessions_root" -type f -name '*.jsonl' -print0 > "$file_list" || exit 1`,
|
||||
' else',
|
||||
' : > "$file_list" || exit 1',
|
||||
' find "$source_sessions_root" -mindepth 3 -maxdepth 3 -type d -print0 > "$day_list" || exit 1',
|
||||
" while IFS= read -r -d '' session_day_root; do",
|
||||
' session_day=${session_day_root#"$source_sessions_root"/}',
|
||||
' case "$session_day" in ????/??/??) ;; *) continue ;; esac',
|
||||
' [[ "$session_day" < "$scan_start" ]] && continue',
|
||||
' find "$session_day_root" -maxdepth 1 -type f -name \'*.jsonl\' -print0 >> "$file_list" || exit 1',
|
||||
' done < "$day_list"',
|
||||
' fi',
|
||||
' ;;',
|
||||
' *) exit 1 ;;',
|
||||
'esac',
|
||||
"while IFS= read -r -d '' source_file; do",
|
||||
' scanned_files=$((scanned_files + 1))',
|
||||
' relative_path=${source_file#"$source_sessions_root"/}',
|
||||
' target_file="$managed_sessions_root/$relative_path"',
|
||||
' if [ -e "$target_file" ] || [ -L "$target_file" ]; then',
|
||||
' continue',
|
||||
' fi',
|
||||
' target_dir=${target_file%/*}',
|
||||
' if ! mkdir -p -- "$target_dir"; then',
|
||||
' bridge_failed=1',
|
||||
' continue',
|
||||
' fi',
|
||||
' link_source="$source_file"',
|
||||
' if [ -n "$rollback_manifest" ]; then',
|
||||
' staged_file="$rollback_stage_root/$relative_path"',
|
||||
' staged_dir=${staged_file%/*}',
|
||||
' if ! mkdir -p -- "$staged_dir" || ! ln -- "$source_file" "$staged_file"; then',
|
||||
' bridge_failed=1',
|
||||
' continue',
|
||||
' fi',
|
||||
' target_stage="$target_file.orca-bridge-$$"',
|
||||
' if [ -e "$target_stage" ] || [ -L "$target_stage" ]; then',
|
||||
' bridge_failed=1',
|
||||
' continue',
|
||||
' fi',
|
||||
' if ! ln -- "$staged_file" "$target_stage"; then',
|
||||
' if ! cp -- "$staged_file" "$target_stage" || ! cmp -s -- "$staged_file" "$target_stage"; then',
|
||||
' rm -f -- "$target_stage"',
|
||||
' bridge_failed=1',
|
||||
' continue',
|
||||
' fi',
|
||||
' fi',
|
||||
' if ! printf \'%s\\0%s\\0\' "$target_stage" "$target_file" >> "$rollback_manifest"; then',
|
||||
' rm -f -- "$target_stage"',
|
||||
' bridge_failed=1',
|
||||
' continue',
|
||||
' fi',
|
||||
' link_source="$target_stage"',
|
||||
' fi',
|
||||
// Codex resume ignores symlinked JSONL, so create links inside the distro.
|
||||
' if ln -- "$link_source" "$target_file"; then',
|
||||
' linked_files=$((linked_files + 1))',
|
||||
' elif [ ! -e "$target_file" ] && [ ! -L "$target_file" ]; then',
|
||||
' if [ -n "$rollback_manifest" ]; then',
|
||||
' bridge_failed=1',
|
||||
' else',
|
||||
' target_stage="$target_file.orca-bridge-$$"',
|
||||
' if [ ! -e "$target_stage" ] && [ ! -L "$target_stage" ] && cp -- "$source_file" "$target_stage" && cmp -s -- "$source_file" "$target_stage" && ln -- "$target_stage" "$target_file"; then',
|
||||
' linked_files=$((linked_files + 1))',
|
||||
' else',
|
||||
' bridge_failed=1',
|
||||
' fi',
|
||||
' rm -f -- "$target_stage"',
|
||||
' fi',
|
||||
' fi',
|
||||
'done < "$file_list"',
|
||||
'[ "$bridge_failed" = 0 ] || exit 1',
|
||||
`printf '{"scannedFiles":%s,"linkedFiles":%s}\\n' "$scanned_files" "$linked_files"`
|
||||
].join('\n')
|
||||
|
||||
export const WSL_CODEX_SESSION_BRIDGE_SCRIPT = [
|
||||
'source_sessions_root="$1"',
|
||||
'managed_sessions_root="$2"',
|
||||
WSL_CODEX_SESSION_BRIDGE_BODY
|
||||
].join('\n')
|
||||
|
||||
export function buildWslCodexSessionBridgeShellCommand(paths: {
|
||||
managedSessionsRoot: string
|
||||
systemSessionsRoot: string
|
||||
}): string {
|
||||
return [
|
||||
`source_sessions_root=${quotePosixShell(paths.systemSessionsRoot)}`,
|
||||
`managed_sessions_root=${quotePosixShell(paths.managedSessionsRoot)}`,
|
||||
WSL_CODEX_SESSION_BRIDGE_BODY
|
||||
].join('\n')
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const { runWslProcessMock } = vi.hoisted(() => ({
|
||||
runWslProcessMock: vi.fn()
|
||||
@@ -60,10 +64,10 @@ describe('syncWslCodexSessionsIntoManagedHome', () => {
|
||||
"managed_sessions_root='/home/alice/.local/share/orca/codex-runtime-home/home/sessions'"
|
||||
)
|
||||
expect(shellCommand).toContain(`find "$source_sessions_root" -type f -name '*.jsonl' -print0`)
|
||||
expect(shellCommand).toContain('ln -- "$source_file" "$target_file"')
|
||||
expect(shellCommand).toContain('ln -- "$link_source" "$target_file"')
|
||||
expect(shellCommand).toContain('if [ -e "$target_file" ] || [ -L "$target_file" ]; then')
|
||||
expect(shellCommand).not.toContain('ln -s')
|
||||
expect(shellCommand).not.toContain('cp ')
|
||||
expect(shellCommand).toContain('cp --')
|
||||
expect(shellCommand).not.toContain('sqlite')
|
||||
})
|
||||
|
||||
@@ -180,4 +184,107 @@ describe('buildWslCodexSessionBridgeShellCommand', () => {
|
||||
expect(shellCommand).toContain('$source_file')
|
||||
expect(shellCommand).toContain('$((scanned_files + 1))')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'publishes a verified guest-side copy across filesystems',
|
||||
() => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-wsl-session-bridge-cross-fs-'))
|
||||
const sourceSessionsRoot = join(root, 'legacy', 'sessions')
|
||||
const managedSessionsRoot = join(root, 'managed', 'sessions')
|
||||
const binDir = join(root, 'bin')
|
||||
const relativePath = join('2026', '08', '26', 'retired.jsonl')
|
||||
const sourcePath = join(sourceSessionsRoot, relativePath)
|
||||
const targetPath = join(managedSessionsRoot, relativePath)
|
||||
mkdirSync(join(sourcePath, '..'), { recursive: true })
|
||||
mkdirSync(binDir)
|
||||
writeFileSync(sourcePath, '{"session":"retired"}\n', 'utf-8')
|
||||
const lnShimPath = join(binDir, 'ln')
|
||||
writeFileSync(
|
||||
lnShimPath,
|
||||
`#!/bin/sh
|
||||
if [ "$2" = "$BRIDGE_SOURCE" ] && [ "$3" = "$BRIDGE_TARGET" ]; then
|
||||
exit 1
|
||||
fi
|
||||
exec /bin/ln "$@"
|
||||
`
|
||||
)
|
||||
chmodSync(lnShimPath, 0o755)
|
||||
const shellCommand = buildWslCodexSessionBridgeShellCommand({
|
||||
systemSessionsRoot: sourceSessionsRoot,
|
||||
managedSessionsRoot
|
||||
})
|
||||
|
||||
try {
|
||||
execFileSync('/bin/bash', ['-c', shellCommand], {
|
||||
env: {
|
||||
...process.env,
|
||||
BRIDGE_SOURCE: sourcePath,
|
||||
BRIDGE_TARGET: targetPath,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ''}`
|
||||
}
|
||||
})
|
||||
expect(readFileSync(targetPath, 'utf-8')).toBe('{"session":"retired"}\n')
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'leaves an existing copied session to its current writer',
|
||||
() => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-wsl-session-bridge-existing-'))
|
||||
const sourceSessionsRoot = join(root, 'legacy', 'sessions')
|
||||
const managedSessionsRoot = join(root, 'managed', 'sessions')
|
||||
const relativePath = join('2026', '08', '26', 'retired.jsonl')
|
||||
const sourcePath = join(sourceSessionsRoot, relativePath)
|
||||
const targetPath = join(managedSessionsRoot, relativePath)
|
||||
mkdirSync(join(sourcePath, '..'), { recursive: true })
|
||||
mkdirSync(join(targetPath, '..'), { recursive: true })
|
||||
writeFileSync(sourcePath, '{"session":"retired"}\n{"event":"legacy"}\n', 'utf-8')
|
||||
writeFileSync(targetPath, '{"session":"retired"}\n', 'utf-8')
|
||||
const shellCommand = buildWslCodexSessionBridgeShellCommand({
|
||||
systemSessionsRoot: sourceSessionsRoot,
|
||||
managedSessionsRoot
|
||||
})
|
||||
|
||||
try {
|
||||
execFileSync('/bin/bash', ['-c', shellCommand])
|
||||
expect(readFileSync(targetPath, 'utf-8')).toBe('{"session":"retired"}\n')
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'fails in the guest shell when a missing session cannot be linked, then retries cleanly',
|
||||
() => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-wsl-session-bridge-'))
|
||||
const sourceSessionsRoot = join(root, 'legacy', 'sessions')
|
||||
const managedSessionsRoot = join(root, 'managed', 'sessions')
|
||||
const relativePath = join('2026', '08', '26', 'retired.jsonl')
|
||||
const sourcePath = join(sourceSessionsRoot, relativePath)
|
||||
const blockingPath = join(managedSessionsRoot, '2026')
|
||||
mkdirSync(join(sourcePath, '..'), { recursive: true })
|
||||
mkdirSync(managedSessionsRoot, { recursive: true })
|
||||
writeFileSync(sourcePath, '{"session":"retired"}\n', 'utf-8')
|
||||
writeFileSync(blockingPath, 'not-a-directory\n', 'utf-8')
|
||||
const shellCommand = buildWslCodexSessionBridgeShellCommand({
|
||||
systemSessionsRoot: sourceSessionsRoot,
|
||||
managedSessionsRoot
|
||||
})
|
||||
|
||||
try {
|
||||
expect(() => execFileSync('/bin/bash', ['-c', shellCommand])).toThrow()
|
||||
rmSync(blockingPath)
|
||||
execFileSync('/bin/bash', ['-c', shellCommand])
|
||||
expect(readFileSync(join(managedSessionsRoot, relativePath), 'utf-8')).toBe(
|
||||
'{"session":"retired"}\n'
|
||||
)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { posix as pathPosix } from 'node:path'
|
||||
import { quotePosixShell as quoteBashString } from '../../shared/wsl-login-shell-command'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { runWslProcess } from '../wsl/wsl-runner'
|
||||
import {
|
||||
buildWslCodexSessionBridgeShellCommand,
|
||||
WSL_SESSION_BRIDGE_TIMEOUT_MS
|
||||
} from './wsl-codex-session-bridge-script'
|
||||
|
||||
export { buildWslCodexSessionBridgeShellCommand } from './wsl-codex-session-bridge-script'
|
||||
|
||||
export type WslCodexSessionBridgeTarget = {
|
||||
distro: string
|
||||
@@ -21,8 +26,6 @@ export type WslCodexSessionBridgeSummary = {
|
||||
|
||||
const emptySummary: WslCodexSessionBridgeSummary = { scannedFiles: 0, linkedFiles: 0 }
|
||||
const backgroundWslSessionBridgeTasks = new Map<string, Promise<void>>()
|
||||
const WSL_SESSION_BRIDGE_TIMEOUT_MS = 30_000
|
||||
|
||||
export function startWslCodexSessionBridgeInBackground(
|
||||
target: WslCodexSessionBridgeTarget
|
||||
): Promise<void> {
|
||||
@@ -86,39 +89,6 @@ export function resolveWslCodexSessionBridgeLinuxPaths(
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWslCodexSessionBridgeShellCommand(
|
||||
paths: WslCodexSessionBridgeLinuxPaths
|
||||
): string {
|
||||
const shellCommand = [
|
||||
'set -u',
|
||||
`source_sessions_root=${quoteBashString(paths.systemSessionsRoot)}`,
|
||||
`managed_sessions_root=${quoteBashString(paths.managedSessionsRoot)}`,
|
||||
'scanned_files=0',
|
||||
'linked_files=0',
|
||||
'if [ ! -d "$source_sessions_root" ]; then',
|
||||
` printf '{"scannedFiles":0,"linkedFiles":0}\\n'`,
|
||||
' exit 0',
|
||||
'fi',
|
||||
"while IFS= read -r -d '' source_file; do",
|
||||
' scanned_files=$((scanned_files + 1))',
|
||||
' relative_path=${source_file#"$source_sessions_root"/}',
|
||||
' target_file="$managed_sessions_root/$relative_path"',
|
||||
' if [ -e "$target_file" ] || [ -L "$target_file" ]; then',
|
||||
' continue',
|
||||
' fi',
|
||||
' target_dir=${target_file%/*}',
|
||||
' mkdir -p -- "$target_dir" || continue',
|
||||
// Why: Codex resume ignores symlinked JSONL, so WSL links must be
|
||||
// Linux hardlinks created inside the distro filesystem.
|
||||
' if ln -- "$source_file" "$target_file"; then',
|
||||
' linked_files=$((linked_files + 1))',
|
||||
' fi',
|
||||
`done < <(find "$source_sessions_root" -type f -name '*.jsonl' -print0 2>/dev/null)`,
|
||||
`printf '{"scannedFiles":%s,"linkedFiles":%s}\\n' "$scanned_files" "$linked_files"`
|
||||
].join('\n')
|
||||
return shellCommand
|
||||
}
|
||||
|
||||
function getWslSessionBridgeTaskKey(target: WslCodexSessionBridgeTarget): string {
|
||||
return [target.distro, target.systemCodexHomePath, target.managedCodexHomePath].join('\0')
|
||||
}
|
||||
|
||||
+21
-19
@@ -69,6 +69,7 @@ import {
|
||||
import {
|
||||
type CodexPaneHomeRoute,
|
||||
getCodexPaneAccount,
|
||||
hasAnyRecordedLegacyWslCodexPane,
|
||||
hasRecordedManagedHostCodexPane,
|
||||
isCodexPaneHomeRouteProvenAwayFromSharedHome,
|
||||
reconcileCodexPaneAccountsWithLivePtys
|
||||
@@ -1116,7 +1117,8 @@ function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServices {
|
||||
macosLoginSessionWatch: process.platform === 'darwin' && !isServeMode
|
||||
})
|
||||
// Why: a retained shell keeps its launch-time Codex home even when the current routing lane changes.
|
||||
if (codexRuntimeHome && hasRecordedManagedHostCodexPane()) {
|
||||
const hasRetainedManagedHostPane = hasRecordedManagedHostCodexPane()
|
||||
if (codexRuntimeHome && (hasRetainedManagedHostPane || hasAnyRecordedLegacyWslCodexPane())) {
|
||||
const livePtyIds = await listLiveDaemonPtyIds()
|
||||
if (livePtyIds) {
|
||||
reconcileCodexPaneAccountsWithLivePtys(livePtyIds)
|
||||
@@ -1124,15 +1126,17 @@ function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServices {
|
||||
// Why (#16441): each retained home can run a codex app-server grant
|
||||
// session. Awaiting them here delayed the first window by N sessions;
|
||||
// a retained shell cannot invoke Codex before this provider serves.
|
||||
void reconcileRetainedCodexHookHomes({
|
||||
hookService: codexHookService,
|
||||
hooksEnabled:
|
||||
isAgentStatusHooksEnabled(settings) &&
|
||||
settings?.disabledTuiAgents.includes('codex') !== true,
|
||||
runtimeHomePaths: codexRuntimeHome.getRetainedHostCodexHookHomePaths(livePtyIds)
|
||||
}).catch((error: unknown) => {
|
||||
console.warn('[codex-hook-service] retained Codex home reconcile failed:', error)
|
||||
})
|
||||
if (hasRetainedManagedHostPane) {
|
||||
void reconcileRetainedCodexHookHomes({
|
||||
hookService: codexHookService,
|
||||
hooksEnabled:
|
||||
isAgentStatusHooksEnabled(settings) &&
|
||||
settings?.disabledTuiAgents.includes('codex') !== true,
|
||||
runtimeHomePaths: codexRuntimeHome.getRetainedHostCodexHookHomePaths(livePtyIds)
|
||||
}).catch((error: unknown) => {
|
||||
console.warn('[codex-hook-service] retained Codex home reconcile failed:', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// Why: retained shells can invoke Codex immediately after the startup gate.
|
||||
@@ -1230,7 +1234,7 @@ async function prepareCodexRuntimeHomeForLaunch(
|
||||
// Why: a ManagedCodexHomeTemporarilyUnavailableError must escape uncaught —
|
||||
// the fallbacks below all key off `null`, which means "system default", so
|
||||
// swallowing the refusal would launch the wrong account (#STA-4422).
|
||||
let runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv, {
|
||||
let runtimeHomePath = await codexRuntimeHome!.prepareForCodexLaunchAsync(target, launchEnv, {
|
||||
unavailableManagedHomePath: launchContext?.unavailableManagedHomePath
|
||||
})
|
||||
if (runtimeHomePath === null && !realHomeHooksPrepared) {
|
||||
@@ -1239,7 +1243,7 @@ async function prepareCodexRuntimeHomeForLaunch(
|
||||
// re-resolve if the capability gate rejects it.
|
||||
realHomeHooksPrepared = await ensureRealHomeHooksIfSelected()
|
||||
if (realHomeHooksPrepared) {
|
||||
runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv, {
|
||||
runtimeHomePath = await codexRuntimeHome!.prepareForCodexLaunchAsync(target, launchEnv, {
|
||||
unavailableManagedHomePath: launchContext?.unavailableManagedHomePath
|
||||
})
|
||||
}
|
||||
@@ -1259,13 +1263,11 @@ async function prepareCodexRuntimeHomeForLaunch(
|
||||
const hooksEnabled = isAgentStatusHooksEnabled(store?.getSettings())
|
||||
try {
|
||||
// Why: honor the persisted off switch so post-startup launches can't reinstall removed hooks.
|
||||
const status = hooksEnabled
|
||||
? ((await codexHookService.installForRuntimeHome(runtimeHomePath, hookTarget)) ??
|
||||
// Why: a managed account's launch home is its own self-contained
|
||||
// CODEX_HOME, so hooks/trust must install there, not the shared mirror.
|
||||
(await codexHookService.install(runtimeHomePath ?? undefined)))
|
||||
: (codexHookService.refreshRuntimeUserHooksForRuntimeHome(runtimeHomePath, hookTarget) ??
|
||||
(await codexHookService.refreshRuntimeUserHooks(runtimeHomePath ?? undefined)))
|
||||
const status = await codexHookService.prepareRuntimeHomeForLaunch(
|
||||
runtimeHomePath,
|
||||
hookTarget,
|
||||
hooksEnabled
|
||||
)
|
||||
if (status.state === 'error') {
|
||||
console.warn(
|
||||
`[codex-hook-service] failed to ${
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
/** Guest-relative layout of Orca's managed WSL CODEX_HOME. Must stay in sync
|
||||
* with getWslRuntimeHomePath (codex-accounts/runtime-home-service.ts), which
|
||||
* builds the UNC twin of this path. */
|
||||
/** Guest-relative layout of Orca's retired WSL CODEX_HOME, retained for migration reads. */
|
||||
export const WSL_CODEX_RUNTIME_HOME_SEGMENTS = [
|
||||
'.local',
|
||||
'share',
|
||||
|
||||
@@ -471,25 +471,54 @@ describe('orchestration notification mailbox consistency', () => {
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('does not submit or duplicate a pointer after the agent becomes working', async () => {
|
||||
vi.useFakeTimers()
|
||||
const db = createDatabase('orca-mailbox-working-before-enter-')
|
||||
const harness = createRuntime(db)
|
||||
const run = createBoundRun(db, 'Working-before-Enter Run')
|
||||
const message = insertDirectRunMessage(db, run.id, 'Actionable status')
|
||||
it.each([
|
||||
['Codex', '\x1b]0;Codex working\x07', '\x1b]0;Codex done\x07'],
|
||||
['Claude', '\x1b]0;\u280b Claude working\x07', '\x1b]0;\u2733 Claude Code\x07']
|
||||
])(
|
||||
'submits a staged pointer after %s becomes working without duplicating it',
|
||||
async (_provider, workingTitle, idleTitle) => {
|
||||
vi.useFakeTimers()
|
||||
const db = createDatabase('orca-mailbox-working-before-enter-')
|
||||
const harness = createRuntime(db)
|
||||
const run = createBoundRun(db, 'Working-before-Enter Run')
|
||||
const message = insertDirectRunMessage(db, run.id, 'Actionable status')
|
||||
|
||||
await driveToLiveIdle(harness.runtime)
|
||||
expect(pointerCount(harness.write)).toBe(1)
|
||||
harness.runtime.onPtyData(PTY_ID, '\x1b]0;Codex working\x07', 3)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await driveToLiveIdle(harness.runtime)
|
||||
expect(pointerCount(harness.write)).toBe(1)
|
||||
harness.runtime.onPtyData(PTY_ID, workingTitle, 3)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
expect(harness.write.mock.calls.filter(([, payload]) => payload === '\r')).toHaveLength(0)
|
||||
expect(db.getMessageById(message.id)?.delivered_at).toEqual(expect.any(String))
|
||||
harness.runtime.onPtyData(PTY_ID, '\x1b]0;Codex done\x07', 4)
|
||||
await Promise.resolve()
|
||||
expect(pointerCount(harness.write)).toBe(1)
|
||||
db.close()
|
||||
})
|
||||
expect(harness.write.mock.calls.filter(([, payload]) => payload === '\r')).toHaveLength(1)
|
||||
expect(db.getMessageById(message.id)?.delivered_at).toEqual(expect.any(String))
|
||||
harness.runtime.onPtyData(PTY_ID, idleTitle, 4)
|
||||
await Promise.resolve()
|
||||
expect(pointerCount(harness.write)).toBe(1)
|
||||
db.close()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['Codex', '\x1b]0;Codex permission\x07'],
|
||||
['Claude', '\x1b]0;Claude waiting for permission\x07']
|
||||
])(
|
||||
'does not submit a staged pointer after %s enters a permission state',
|
||||
async (_provider, permissionTitle) => {
|
||||
vi.useFakeTimers()
|
||||
const db = createDatabase('orca-mailbox-permission-before-enter-')
|
||||
const harness = createRuntime(db)
|
||||
const run = createBoundRun(db, 'Permission-before-Enter Run')
|
||||
const message = insertDirectRunMessage(db, run.id, 'Actionable status')
|
||||
|
||||
await driveToLiveIdle(harness.runtime)
|
||||
expect(pointerCount(harness.write)).toBe(1)
|
||||
harness.runtime.onPtyData(PTY_ID, permissionTitle, 3)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
expect(harness.write.mock.calls.filter(([, payload]) => payload === '\r')).toHaveLength(0)
|
||||
expect(db.getMessageById(message.id)?.delivered_at).toEqual(expect.any(String))
|
||||
db.close()
|
||||
}
|
||||
)
|
||||
|
||||
it('releases staged pointer state when an explicit check owns the batch', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
@@ -54,8 +54,9 @@ export function submitOrchestrationMailboxPointer<TWaiter extends OrchestrationM
|
||||
} else if (deps.mailboxOwner.resolve(currentLeaf) !== input.mailboxHandle) {
|
||||
clearAndRedrive = true
|
||||
} else if (
|
||||
currentLeaf.lastAgentStatus === 'idle' &&
|
||||
currentLeaf.lastAgentStatusObservedLive
|
||||
currentLeaf.lastAgentStatusObservedLive &&
|
||||
// Once staged, working is queue-safe; idle-only strands Orca-owned text in the composer.
|
||||
(currentLeaf.lastAgentStatus === 'idle' || currentLeaf.lastAgentStatus === 'working')
|
||||
) {
|
||||
if (
|
||||
shouldReleaseOrchestrationPointer(
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { RpcRequest } from '../core'
|
||||
import { ORCHESTRATION_METHODS } from './orchestration'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import { createOrchestrationRpcHarness } from './orchestration-rpc-test-harness'
|
||||
import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version'
|
||||
|
||||
describe('orchestration.send invalid message type', () => {
|
||||
const h = createOrchestrationRpcHarness()
|
||||
|
||||
afterEach(() => {
|
||||
h.cleanup()
|
||||
})
|
||||
|
||||
it('explains valid message types and the question reply path', async () => {
|
||||
const { runtime, ctx } = h.setup()
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS })
|
||||
const request: RpcRequest = {
|
||||
id: 'req_1',
|
||||
authToken: 'token',
|
||||
method: 'orchestration.send',
|
||||
params: { to: 'b', subject: 'hi', type: 'answer' },
|
||||
orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION
|
||||
}
|
||||
|
||||
const response = await dispatcher.dispatch(request, ctx)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'invalid_argument',
|
||||
message:
|
||||
'Invalid --type. Expected one of: status, dispatch, worker_done, merge_ready, escalation, handoff, decision_gate, question, heartbeat. To answer a worker question, use the same Orca CLI executable with orchestration reply --id <msg_id> --body <text>.'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -76,6 +76,11 @@ async function routeAllMailboxPages(
|
||||
|
||||
type DispatchMutationMessageType = 'worker_done' | 'heartbeat' | 'escalation' | 'decision_gate'
|
||||
|
||||
const SEND_MESSAGE_TYPE_ERROR = [
|
||||
`Invalid --type. Expected one of: ${MESSAGE_TYPES.join(', ')}.`,
|
||||
'To answer a worker question, use the same Orca CLI executable with orchestration reply --id <msg_id> --body <text>.'
|
||||
].join(' ')
|
||||
|
||||
function isDispatchMutationMessageType(
|
||||
type: string | undefined
|
||||
): type is DispatchMutationMessageType {
|
||||
@@ -132,17 +137,9 @@ const SendParams = z
|
||||
from: OptionalString,
|
||||
body: OptionalString,
|
||||
type: z
|
||||
.enum([
|
||||
'status',
|
||||
'dispatch',
|
||||
'worker_done',
|
||||
'merge_ready',
|
||||
'escalation',
|
||||
'handoff',
|
||||
'decision_gate',
|
||||
'question',
|
||||
'heartbeat'
|
||||
])
|
||||
.enum(MESSAGE_TYPES, {
|
||||
error: SEND_MESSAGE_TYPE_ERROR
|
||||
})
|
||||
.optional(),
|
||||
priority: z.enum(['normal', 'high', 'urgent']).optional(),
|
||||
threadId: OptionalString,
|
||||
|
||||
@@ -53,7 +53,28 @@ describe('AppImage CLI redirect', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('routes no-sandbox serve launches through the CLI', () => {
|
||||
it('keeps direct serve launches in Electron when Chromium switches are present', () => {
|
||||
expect(
|
||||
getAppImageCliArgs(
|
||||
[
|
||||
'AppRun',
|
||||
'--no-sandbox',
|
||||
'--disable-features=FedCm,DirectSockets',
|
||||
'serve',
|
||||
'--port',
|
||||
'6768'
|
||||
],
|
||||
{ APPIMAGE: '/opt/orca' },
|
||||
{
|
||||
platform: 'linux',
|
||||
isPackaged: true,
|
||||
commandNames
|
||||
}
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps clean serve launches on the CLI path for validation', () => {
|
||||
expect(
|
||||
getAppImageCliArgs(
|
||||
['AppRun', '--no-sandbox', 'serve', '--port', '6768'],
|
||||
@@ -67,6 +88,34 @@ describe('AppImage CLI redirect', () => {
|
||||
).toEqual(['serve', '--port', '6768'])
|
||||
})
|
||||
|
||||
it('handles a space-separated Chromium switch value', () => {
|
||||
expect(
|
||||
getAppImageCliArgs(
|
||||
['AppRun', '--disable-features', 'FedCm', 'serve'],
|
||||
{ APPIMAGE: '/opt/orca' },
|
||||
{
|
||||
platform: 'linux',
|
||||
isPackaged: true,
|
||||
commandNames
|
||||
}
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not broaden the Electron-owned exception to switches after serve', () => {
|
||||
expect(
|
||||
getAppImageCliArgs(
|
||||
['AppRun', 'serve', '--disable-features=FedCm'],
|
||||
{ APPIMAGE: '/opt/orca' },
|
||||
{
|
||||
platform: 'linux',
|
||||
isPackaged: true,
|
||||
commandNames
|
||||
}
|
||||
)
|
||||
).toEqual(['serve', '--disable-features=FedCm'])
|
||||
})
|
||||
|
||||
it('removes no-sandbox before forwarding CLI help', () => {
|
||||
expect(
|
||||
getAppImageCliArgs(
|
||||
@@ -81,6 +130,20 @@ describe('AppImage CLI redirect', () => {
|
||||
).toEqual(['serve', '--help'])
|
||||
})
|
||||
|
||||
it('still redirects serve help even when Chromium switches are present', () => {
|
||||
expect(
|
||||
getAppImageCliArgs(
|
||||
['AppRun', '--disable-features=FedCm', 'serve', '--help'],
|
||||
{ APPIMAGE: '/opt/orca' },
|
||||
{
|
||||
platform: 'linux',
|
||||
isPackaged: true,
|
||||
commandNames
|
||||
}
|
||||
)
|
||||
).toEqual(['--disable-features=FedCm', 'serve', '--help'])
|
||||
})
|
||||
|
||||
it('spawns the unpacked CLI entrypoint with Electron node mode', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-appimage-cli-redirect-'))
|
||||
const cliEntryPath = join(root, 'app.asar.unpacked', 'out', 'cli', 'index.js')
|
||||
@@ -118,14 +181,14 @@ describe('AppImage CLI redirect', () => {
|
||||
expect(spawnOptions?.env).not.toHaveProperty('NODE_REPL_EXTERNAL_MODULE')
|
||||
})
|
||||
|
||||
it('forwards an explicit no-sandbox choice to the serve child', async () => {
|
||||
it('keeps a clean no-sandbox serve launch on the CLI path', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-appimage-cli-redirect-'))
|
||||
const cliEntryPath = join(root, 'app.asar.unpacked', 'out', 'cli', 'index.js')
|
||||
await mkdir(join(root, 'app.asar.unpacked', 'out', 'cli'), { recursive: true })
|
||||
await writeFile(cliEntryPath, '', 'utf8')
|
||||
const spawn = vi.fn((..._args: unknown[]) => ({ status: 0 }))
|
||||
|
||||
maybeRedirectAppImageCliLaunch({
|
||||
const result = maybeRedirectAppImageCliLaunch({
|
||||
argv: ['orca-linux.AppImage', '--no-sandbox', 'serve'],
|
||||
env: { APPIMAGE: '/opt/orca/orca-linux.AppImage' },
|
||||
platform: 'linux',
|
||||
@@ -136,6 +199,7 @@ describe('AppImage CLI redirect', () => {
|
||||
spawn: spawn as never
|
||||
})
|
||||
|
||||
expect(result).toEqual({ redirected: true, status: 0 })
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'/opt/orca/orca-ide',
|
||||
[cliEntryPath, 'serve'],
|
||||
|
||||
@@ -24,7 +24,8 @@ type RedirectOptions = {
|
||||
|
||||
const HELP_FLAGS = new Set(['--help', '-h', 'help'])
|
||||
const APPIMAGE_DESKTOP_FLAGS = new Set(['--no-sandbox'])
|
||||
const CLI_FLAGS_WITH_VALUES = new Set(['--environment', '--pairing-code'])
|
||||
const ELECTRON_LAUNCH_SWITCHES = new Set(['--disable-features'])
|
||||
const CLI_FLAGS_WITH_VALUES = new Set(['--environment', '--pairing-code', '--disable-features'])
|
||||
// Why: the main tsconfig cannot import the CLI project, but AppImage direct
|
||||
// launches need a conservative allow-list before bypassing the GUI startup.
|
||||
const APPIMAGE_CLI_COMMAND_NAMES = [
|
||||
@@ -157,21 +158,44 @@ export function getAppImageCliArgs(
|
||||
|
||||
const commandNames = new Set(options.commandNames)
|
||||
const firstPositional = findFirstCommandCandidate(cliArgs)
|
||||
return firstPositional && commandNames.has(firstPositional) ? cliArgs : null
|
||||
if (!firstPositional || !commandNames.has(firstPositional)) {
|
||||
return null
|
||||
}
|
||||
// Keep serve in Electron only when an Electron launch switch is present.
|
||||
// Forwarding it to the strict Node-mode CLI parser makes an otherwise valid
|
||||
// serve launch fail, while clean serve invocations retain CLI validation.
|
||||
if (
|
||||
firstPositional === 'serve' &&
|
||||
cliArgs
|
||||
.slice(0, findFirstCommandCandidateIndex(cliArgs))
|
||||
.some((arg) => ELECTRON_LAUNCH_SWITCHES.has(flagName(arg)))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return cliArgs
|
||||
}
|
||||
|
||||
function findFirstCommandCandidate(args: string[]): string | null {
|
||||
const index = findFirstCommandCandidateIndex(args)
|
||||
return index === -1 ? null : args[index]!
|
||||
}
|
||||
|
||||
function findFirstCommandCandidateIndex(args: string[]): number {
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index]
|
||||
if (!arg.startsWith('-')) {
|
||||
return arg
|
||||
return index
|
||||
}
|
||||
const flagName = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg
|
||||
if (CLI_FLAGS_WITH_VALUES.has(flagName) && !arg.includes('=')) {
|
||||
if (CLI_FLAGS_WITH_VALUES.has(flagName(arg)) && !arg.includes('=')) {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
return null
|
||||
return -1
|
||||
}
|
||||
|
||||
function flagName(arg: string): string {
|
||||
const equalsIndex = arg.indexOf('=')
|
||||
return equalsIndex === -1 ? arg : arg.slice(0, equalsIndex)
|
||||
}
|
||||
|
||||
function buildElectronRunAsNodeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
|
||||
@@ -4,10 +4,9 @@ import { describe, expect, it } from 'vitest'
|
||||
import { getAppImageCliArgs } from './appimage-cli-redirect'
|
||||
import { argvRequestsServeMode, normalizeServeModeArgv } from './serve-mode-argv'
|
||||
|
||||
// Why: index.ts must run both CLI redirects before rewriting argv. Rewriting
|
||||
// first replaces the `serve` positional with `--serve`, so the redirect's
|
||||
// command-name lookup finds a port number instead of a command and bails —
|
||||
// silently dropping AppImage serve launches out of the CLI path (#12677).
|
||||
// Why: index.ts runs CLI redirects before rewriting argv. Direct AppImage serve
|
||||
// stays in Electron so launch switches do not cross into the strict Node-mode
|
||||
// CLI parser; other CLI commands still depend on redirect ordering (#12677).
|
||||
|
||||
const REDIRECT_OPTIONS = {
|
||||
platform: 'linux' as const,
|
||||
@@ -24,7 +23,7 @@ function rewriteAsIndexDoes(argv: string[]): string[] {
|
||||
describe('serve argv rewrite vs AppImage CLI redirect ordering', () => {
|
||||
const launchArgv = ['/opt/orca/orca-ide', '--no-sandbox', 'serve', '--port', '7777', '--json']
|
||||
|
||||
it('hands the launch argv to the CLI when the redirect runs first', () => {
|
||||
it('keeps clean serve validation on the CLI path', () => {
|
||||
expect(getAppImageCliArgs(launchArgv, MOUNTED_APPIMAGE_ENV, REDIRECT_OPTIONS)).toEqual([
|
||||
'serve',
|
||||
'--port',
|
||||
@@ -33,6 +32,11 @@ describe('serve argv rewrite vs AppImage CLI redirect ordering', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps an injected Chromium switch in Electron before argv rewriting', () => {
|
||||
const injected = [...launchArgv.slice(0, 2), '--disable-features=FedCm', ...launchArgv.slice(2)]
|
||||
expect(getAppImageCliArgs(injected, MOUNTED_APPIMAGE_ENV, REDIRECT_OPTIONS)).toBeNull()
|
||||
})
|
||||
|
||||
it('loses the redirect if the rewrite runs first', () => {
|
||||
const rewritten = rewriteAsIndexDoes(launchArgv)
|
||||
expect(rewritten).toContain('--serve')
|
||||
|
||||
@@ -107,6 +107,24 @@ describe('serve-mode-argv', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps Electron-injected Chromium switches while normalizing direct serve', () => {
|
||||
expect(
|
||||
normalizeServeModeArgv([
|
||||
'/opt/orca/orca-ide',
|
||||
'--disable-features=FedCm,DirectSockets',
|
||||
'serve',
|
||||
'--port',
|
||||
'6768'
|
||||
])
|
||||
).toEqual([
|
||||
'/opt/orca/orca-ide',
|
||||
'--disable-features=FedCm,DirectSockets',
|
||||
'--serve',
|
||||
'--serve-port',
|
||||
'6768'
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves already-normalized argv unchanged', () => {
|
||||
// Why every value flag: the CLI's own `orca serve` spawns the app with exactly this shape
|
||||
// (serveOrcaApp), and the rewrite now runs over it too — a bad mapping would drop the port here.
|
||||
|
||||
@@ -24,13 +24,14 @@ const CLI_TO_SERVE_VALUE_FLAG = new Map([
|
||||
/**
|
||||
* Flags that consume the next argv token as a value (CLI-form + Electron passthrough).
|
||||
* Residual class: a flag outside this list whose space-separated value is literally `serve` would
|
||||
* read as the subcommand. Chromium switches are `--flag=value` only, so no real launch does that.
|
||||
* read as the subcommand. Include switches that may arrive in either argv shape.
|
||||
*/
|
||||
const VALUE_TAKING_FLAGS = new Set([
|
||||
...CLI_TO_SERVE_VALUE_FLAG.keys(),
|
||||
'--serve-port',
|
||||
'--serve-pairing-address',
|
||||
'--serve-project-root',
|
||||
'--disable-features',
|
||||
'--user-data-dir',
|
||||
'--environment',
|
||||
'--pairing-code'
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
main/agent-hooks/wsl-hook-relay-launch.ts
|
||||
main/browser/wsl-browser-network-relay-launch.ts
|
||||
main/claude-accounts/claude-command-process.ts
|
||||
main/codex-accounts/runtime-home-service.ts
|
||||
main/codex-accounts/service.ts
|
||||
main/codex/codex-state-db-backfill-recovery.ts
|
||||
main/codex/codex-trust-grant-host.ts
|
||||
|
||||
@@ -46,7 +46,9 @@ src/main/browser/browser-cookie-import.ts
|
||||
src/main/browser/browser-route-egress-electron-launch.ts
|
||||
src/main/browser/browser-route-persisted-worker-electron-process.ts
|
||||
src/main/claude-accounts/keychain.ts
|
||||
src/main/codex-accounts/runtime-home-service.ts
|
||||
src/main/codex-accounts/legacy-wsl-runtime-auth-drain-recovery-script-harness.ts
|
||||
src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-harness.ts
|
||||
src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-interference-shims.ts
|
||||
src/main/codex-accounts/service.ts
|
||||
src/main/codex/codex-app-server-client.ts
|
||||
src/main/codex/codex-app-server-posix-supervisor.ts
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
isWslUncPath,
|
||||
parseWslUncPath,
|
||||
resolveWslRepoWorktreeBasePath,
|
||||
toWindowsWslPath
|
||||
toWindowsWslPath,
|
||||
toWindowsWslUncPath
|
||||
} from './wsl-paths'
|
||||
|
||||
describe('wsl path helpers', () => {
|
||||
@@ -35,6 +36,12 @@ describe('wsl path helpers', () => {
|
||||
])('converts %s without folding case-sensitive Linux paths', (linuxPath, expected) => {
|
||||
expect(toWindowsWslPath(linuxPath, 'Ubuntu')).toBe(expected)
|
||||
})
|
||||
|
||||
it('keeps mounted-drive paths on the distro UNC view when requested', () => {
|
||||
expect(toWindowsWslUncPath('/mnt/c/Users/jin', 'Ubuntu')).toBe(
|
||||
'\\\\wsl.localhost\\Ubuntu\\mnt\\c\\Users\\jin'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveWslRepoWorktreeBasePath', () => {
|
||||
|
||||
@@ -55,7 +55,12 @@ export function toWindowsWslPath(linuxPath: string, distro: string): string {
|
||||
return `${mntMatch[1].toUpperCase()}:${rest || '\\'}`
|
||||
}
|
||||
|
||||
return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}`
|
||||
return toWindowsWslUncPath(linuxPath, distro)
|
||||
}
|
||||
|
||||
/** Keep a Linux path addressable through its distro, including drvfs mounts. */
|
||||
export function toWindowsWslUncPath(linuxPath: string, distro: string): string {
|
||||
return `\\\\wsl.localhost\\${distro}${linuxPath === '/' ? '\\' : linuxPath.replace(/\//g, '\\')}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,7 +91,7 @@ export function resolveWslRepoWorktreeBasePath(repoPath: string, basePath: strin
|
||||
return basePath
|
||||
}
|
||||
const collapsed = collapsePosixDotSegments(basePath)
|
||||
return `\\\\wsl.localhost\\${repoWsl.distro}${collapsed === '/' ? '\\' : collapsed.replace(/\//g, '\\')}`
|
||||
return toWindowsWslUncPath(collapsed, repoWsl.distro)
|
||||
}
|
||||
|
||||
function collapsePosixDotSegments(absolutePosixPath: string): string {
|
||||
|
||||
Reference in New Issue
Block a user