Merge remote-tracking branch 'origin/main' into brennanb2025/floating-agent-launch

This commit is contained in:
Brennan Benson
2026-09-18 02:35:22 -04:00
36 changed files with 1377 additions and 100 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.50",
"version": "0.0.51",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -75,7 +75,7 @@
"allowBackup": false,
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
"package": "com.stably.orca.mobile",
"versionCode": 17,
"versionCode": 18,
"googleServicesFile": "./google-services.json"
},
"plugins": [
@@ -40,6 +40,9 @@ vi.mock('./keychain', () => ({
writeManagedClaudeKeychainCredentials: vi.fn(async () => {})
}))
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the doubles below implement every member the add-account login path reaches; this case drives the service only through addAccount.
const asServiceDouble = <T>(double: unknown): T => double as T
describe('ClaudeAccountService credential capture', () => {
beforeEach(() => {
setPlatform('darwin')
@@ -498,6 +501,79 @@ describe('ClaudeAccountService credential capture', () => {
}
})
it('supersedes the login a closed Settings pane abandoned instead of queueing behind it', async () => {
setPlatform('linux')
vi.resetModules()
vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null)
const children: (EventEmitter & { stdin: PassThrough; kill: ReturnType<typeof vi.fn> })[] = []
// No pid: the POSIX teardown signals -pid as a process group, which must
// never reach a real group from a test double.
const spawnMock = vi.fn(() => {
const child = Object.assign(new EventEmitter(), {
stdin: new PassThrough(),
stdout: new PassThrough(),
stderr: new PassThrough(),
kill: vi.fn()
})
children.push(child)
return child
})
vi.doMock('node:child_process', () => ({ spawn: spawnMock }))
try {
const { ClaudeAccountService } = await import('./service')
let settings = {
claudeManagedAccounts: [],
activeClaudeManagedAccountId: null,
activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} }
}
const store = {
getSettings: vi.fn(() => settings),
updateSettings: vi.fn((updates: Partial<typeof settings>) => {
settings = { ...settings, ...updates }
return settings
})
}
const runtimeAuth = {
clearLastWrittenCredentialsJson: vi.fn(),
syncForCurrentSelection: vi.fn(async () => {}),
forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {})
}
const rateLimits = {
evictInactiveClaudeCache: vi.fn(),
refreshForClaudeAccountChange: vi.fn()
}
const service = new ClaudeAccountService(
asServiceDouble(store),
asServiceDouble(rateLimits),
asServiceDouble(runtimeAuth)
)
const abandoned = service.addAccount({ runtime: 'host' })
const abandonedRejection = expect(abandoned).rejects.toThrow('Claude sign-in was cancelled.')
await vi.waitFor(() => {
expect(children.length).toBe(1)
})
// The user reopens Settings and clicks Add Account again.
const retry = service.addAccount({ runtime: 'host' })
const retryRejection = expect(retry).rejects.toThrow()
await abandonedRejection
expect(children[0].kill).toHaveBeenCalled()
// Why: the point of the fix — the second login starts now, not after the
// abandoned one's whole sign-in deadline elapses.
await vi.waitFor(() => {
expect(children.length).toBe(2)
})
children[1].emit('close', 1)
await retryRejection
} finally {
vi.doUnmock('node:child_process')
}
})
it('uses taskkill to cancel the Windows Claude login process tree', async () => {
setPlatform('win32')
vi.resetModules()
+16
View File
@@ -76,6 +76,7 @@ export class ClaudeAccountService {
}
async addAccount(target?: ClaudeAccountAddTarget): Promise<ClaudeRateLimitAccountsState> {
this.supersedePendingLogin()
return this.serializeMutation(() => this.registration.add(target))
}
@@ -87,14 +88,17 @@ export class ClaudeAccountService {
}
async reauthenticateAccount(accountId: string): Promise<ClaudeRateLimitAccountsState> {
this.supersedePendingLogin()
return this.serializeMutation(() => this.registration.reauthenticate(accountId))
}
async removeAccount(accountId: string): Promise<ClaudeRateLimitAccountsState> {
this.supersedePendingLogin()
return this.serializeMutation(() => this.selection.remove(accountId))
}
async selectAccount(accountId: string | null): Promise<ClaudeRateLimitAccountsState> {
this.supersedePendingLogin()
return this.serializeMutation(() => this.selection.select(accountId))
}
@@ -102,6 +106,7 @@ export class ClaudeAccountService {
accountId: string | null,
target?: ClaudeAccountSelectionTarget
): Promise<ClaudeRateLimitAccountsState> {
this.supersedePendingLogin()
return this.serializeMutation(() => this.selection.select(accountId, target))
}
@@ -109,6 +114,17 @@ export class ClaudeAccountService {
return this.cancelPendingClaudeLogin?.() ?? false
}
// Why before the queue, not inside it: the abandoned login owns the queue slot
// every later account action waits for. Called from the four the user drives,
// never from serializeMutation, which background work also uses.
private supersedePendingLogin(): void {
if (this.cancelPendingLogin()) {
console.info(
'[claude-accounts] Cancelled a pending Claude login superseded by a new request.'
)
}
}
getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string {
return this.runtimeAuth.getRuntimeConfigDir(target)
}
@@ -0,0 +1,27 @@
import { readFileSync } from 'node:fs'
/** The bytes of a managed home's auth.json, null when absent, undefined when unreadable. */
export function readLoginAuthSnapshot(authJsonPath: string): string | null | undefined {
try {
return readFileSync(authJsonPath, 'utf-8')
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ENOENT' || code === 'ENOTDIR') {
return null
}
// Why: codex can atomically replace auth.json while the poll runs; a later
// poll will observe the stable credential. An unreadable initial file must
// disable the shortcut rather than look like a fresh login.
return undefined
}
}
/** Whether a login has written credentials of its own since the snapshot was taken. */
export function loginAuthChanged(
initial: string | null | undefined,
current: string | null | undefined
): boolean {
// Why: metadata-only touches can happen before OAuth finishes. Requiring new
// credential bytes prevents reauthentication from being killed prematurely.
return initial !== undefined && current !== undefined && current !== null && current !== initial
}
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import { parseCodexLoginAuthUrl } from './codex-login-auth-url'
const AUTH_URL = 'https://auth.openai.com/oauth/authorize?client_id=orca&state=abc123'
describe('parseCodexLoginAuthUrl', () => {
it('reads the link codex prints under its browser notice', () => {
expect(
parseCodexLoginAuthUrl(
`Starting local login server on http://localhost:1455.\nIf your browser did not open, navigate to this URL to authenticate:\n\n${AUTH_URL}\n`
)
).toBe(AUTH_URL)
})
it('ignores the local server line, which authenticates nothing', () => {
expect(parseCodexLoginAuthUrl('Starting local login server on http://localhost:1455.\n')).toBe(
null
)
})
it('waits for a chunk boundary rather than publishing a truncated link', () => {
const truncated = `If your browser did not open, navigate to this URL to authenticate:\n\n${AUTH_URL.slice(0, 40)}`
expect(parseCodexLoginAuthUrl(truncated)).toBe(null)
expect(parseCodexLoginAuthUrl(`${truncated}${AUTH_URL.slice(40)}\n`)).toBe(AUTH_URL)
})
it('survives the escape sequences a coloured CLI writes around the link', () => {
expect(
parseCodexLoginAuthUrl(
`navigate to this URL to authenticate:\n\n${AUTH_URL}\n`
)
).toBe(AUTH_URL)
})
it('drops sentence punctuation that follows the link', () => {
expect(
parseCodexLoginAuthUrl(`navigate to this URL to authenticate: ${AUTH_URL}. Then return here.`)
).toBe(AUTH_URL)
})
it('reports nothing for output without a link yet', () => {
expect(parseCodexLoginAuthUrl('')).toBe(null)
expect(parseCodexLoginAuthUrl('Codex login failed: network unreachable\n')).toBe(null)
})
it('offers no link at all rather than an unrelated one when the notice is missing', () => {
expect(
parseCodexLoginAuthUrl('A new version of codex is available: https://openai.com/codex\n')
).toBe(null)
})
})
@@ -0,0 +1,32 @@
import { stripAnsiEscapeSequences } from '../../shared/ansi-escape-sequences'
const AUTH_URL_MARKER = 'navigate to this url to authenticate:'
/**
* The browser sign-in link `codex login` prints, or null while its output has
* not carried a complete one yet.
*/
export function parseCodexLoginAuthUrl(output: string): string | null {
const plain = stripAnsiEscapeSequences(output)
const markerIndex = plain.toLowerCase().indexOf(AUTH_URL_MARKER)
// Why the marker is required: without it the first https link codex happens to
// print — an update notice, a docs link — would be offered as the sign-in link.
// If codex rewords the line, no link beats the wrong one.
if (markerIndex === -1) {
return null
}
const searchable = plain.slice(markerIndex + AUTH_URL_MARKER.length)
// Why: the trailing whitespace is required, not incidental. Output arrives in
// chunks, and a flush that ends mid-token would otherwise publish a truncated
// link that authenticates nothing.
const match = /(https:\/\/\S+?)[.,;:)\]]*\s/.exec(searchable)
if (!match) {
return null
}
try {
// Only the shape is trusted here; the pattern already fixed the scheme.
return new URL(match[1]).toString()
} catch {
return null
}
}
+82 -24
View File
@@ -1,19 +1,24 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import type { WindowsHostInteractiveLoginSpawn } from '../../shared/windows-interactive-login-spawn'
import { buildWindowsHostInteractiveLoginSpawn } from '../../shared/windows-interactive-login-spawn'
import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution'
import { CODEX_LOGIN_CANCELLED_MESSAGE } from '../../shared/codex-auth-errors'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { resolveCodexCommand } from '../codex-cli/command'
import { getSpawnArgsForWindows } from '../win32-utils'
import { runWslProcess } from '../wsl/wsl-runner'
import { parseCodexLoginAuthUrl } from './codex-login-auth-url'
import { loginAuthChanged, readLoginAuthSnapshot } from './codex-login-auth-snapshot'
import {
buildWslCodexAvailabilityScript,
buildWslCodexLoginArgs,
WSL_CODEX_AVAILABILITY_TIMEOUT_MS
} from './wsl-codex-command'
const LOGIN_TIMEOUT_MS = 120_000
// Why: matches Claude's window. Signing in through a copied link — a second
// browser, a password manager, an incognito window — routinely outlasts two
// minutes, and the old 120s deadline failed those users mid-flow.
const LOGIN_TIMEOUT_MS = 180_000
const MAX_LOGIN_OUTPUT_CHARS = 4_000
const WINDOWS_LOGIN_AUTH_POLL_INTERVAL_MS = 500
const WINDOWS_LOGIN_POST_AUTH_EXIT_GRACE_MS = 5_000
@@ -50,35 +55,47 @@ type CodexLoginSessionDependencies = {
child: CodexLoginChild,
interactiveLogin?: WindowsHostInteractiveLoginSpawn | null
) => void
/** Registers the handle that abandons this login; the caller clears it. */
setCancel: (cancel: () => boolean) => void
/** The browser link codex printed, published as soon as it is complete. */
onAuthUrl: (url: string) => void
}
function readLoginAuthSnapshot(authJsonPath: string): string | null | undefined {
try {
return readFileSync(authJsonPath, 'utf-8')
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ENOENT' || code === 'ENOTDIR') {
return null
}
// Why: codex can atomically replace auth.json while the poll runs; a later
// poll will observe the stable credential. An unreadable initial file must
// disable the shortcut rather than look like a fresh login.
return undefined
}
}
function loginAuthChanged(
initial: string | null | undefined,
current: string | null | undefined
): boolean {
// Why: metadata-only touches can happen before OAuth finishes. Requiring new
// credential bytes prevents reauthentication from being killed prematurely.
return initial !== undefined && current !== undefined && current !== null && current !== initial
type LoginCancellation = {
isCancelled: () => boolean
setSpawnedCancel: (cancel: () => boolean) => void
}
export async function runCodexLoginSession(
managedHomePath: string,
dependencies: CodexLoginSessionDependencies
): Promise<void> {
let cancelSpawnedLogin: (() => boolean) | null = null
let cancelled = false
dependencies.setCancel(() => {
// Why: only an accepted cancel latches. A spawned login that refuses —
// because it already authenticated — must stay cancellable, or the Cancel
// button and the next add both go dead for the rest of the deadline.
if (cancelled || cancelSpawnedLogin?.() === false) {
return false
}
// A cancel before the spawn has no tree to kill; the pre-spawn probe reads
// this flag instead of opening a browser nobody is waiting for.
cancelled = true
return true
})
await runCodexLoginProcess(managedHomePath, dependencies, {
isCancelled: () => cancelled,
setSpawnedCancel: (cancel) => {
cancelSpawnedLogin = cancel
}
})
}
async function runCodexLoginProcess(
managedHomePath: string,
dependencies: CodexLoginSessionDependencies,
cancellation: LoginCancellation
): Promise<void> {
const wslInfo = parseWslUncPath(managedHomePath)
if (wslInfo) {
@@ -87,9 +104,15 @@ export async function runCodexLoginSession(
// Why: reauthentication starts with an existing auth.json. Only new auth
// bytes prove this login completed; existence alone would kill the
// Windows OAuth flow five seconds after it opened.
// WSL keeps its baseline unread — the UNC round trip belongs nowhere in the
// pre-spawn path — so there is nothing to compare a WSL home against.
const initialAuthSnapshot = wslInfo
? null
: readLoginAuthSnapshot(join(managedHomePath, 'auth.json'))
const hasAuthBaseline = !wslInfo
if (cancellation.isCancelled()) {
throw new Error(CODEX_LOGIN_CANCELLED_MESSAGE)
}
await new Promise<void>((resolvePromise, rejectPromise) => {
const spawnConfig = wslInfo
@@ -119,6 +142,22 @@ export async function runCodexLoginSession(
}
}
// Why its own buffer: a stderr chunk interleaved between two halves of the
// link would end the match early, and the published link never changes.
let stdoutText = ''
let publishedAuthUrl = false
const publishAuthUrl = (chunk: Buffer): void => {
if (publishedAuthUrl) {
return
}
stdoutText = `${stdoutText}${chunk.toString()}`.slice(-MAX_LOGIN_OUTPUT_CHARS)
const authUrl = parseCodexLoginAuthUrl(stdoutText)
if (authUrl) {
publishedAuthUrl = true
dependencies.onAuthUrl(authUrl)
}
}
let timeout: ReturnType<typeof setTimeout> | null = null
let authWatchInterval: ReturnType<typeof setInterval> | null = null
let postAuthExitTimeout: ReturnType<typeof setTimeout> | null = null
@@ -138,6 +177,7 @@ export async function runCodexLoginSession(
postAuthExitTimeout = null
}
child.stdout?.off('data', appendOutput)
child.stdout?.off('data', publishAuthUrl)
child.stderr?.off('data', appendOutput)
child.off('error', onError)
child.off('close', onClose)
@@ -153,6 +193,23 @@ export async function runCodexLoginSession(
callback()
}
cancellation.setSpawnedCancel(() => {
// Why: once codex has written new credential bytes the sign-in already
// succeeded, and rejecting here would send the caller's rollback at the
// home it just authenticated. Nothing left to cancel — let it settle.
// Without a baseline (WSL) an existing auth.json says nothing about this
// login, so refusing on it would make a WSL reauth uncancellable.
const alreadyAuthenticated =
hasAuthBaseline &&
loginAuthChanged(initialAuthSnapshot, readLoginAuthSnapshot(authJsonPath))
if (settled || alreadyAuthenticated) {
return false
}
dependencies.killProcessTree(child, spawnConfig.interactiveLogin)
settle(() => rejectPromise(new Error(CODEX_LOGIN_CANCELLED_MESSAGE)))
return true
})
const timeoutError = new Error('Codex sign-in took too long to finish. Please try again.')
timeout = setTimeout(() => {
dependencies.killProcessTree(child, spawnConfig.interactiveLogin)
@@ -221,6 +278,7 @@ export async function runCodexLoginSession(
}
child.stdout?.on('data', appendOutput)
child.stdout?.on('data', publishAuthUrl)
child.stderr?.on('data', appendOutput)
child.on('error', onError)
child.on('close', onClose)
@@ -0,0 +1,192 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { EventEmitter } from 'node:events'
import { existsSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { PassThrough } from 'node:stream'
import {
createCodexAuthJson,
createRateLimits,
createRuntimeHome,
createSettings,
createStore,
registerCodexAccountsTestHomes,
testState
} from './service-test-harness'
vi.mock('electron', () => ({
app: {
getPath: () => testState.userDataDir
}
}))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
return {
...actual,
homedir: () => testState.fakeHomeDir
}
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the harness doubles implement every member the add-account login path reaches; this test drives the service only through addAccount, cancelPendingLogin and the login URL subscription.
const asServiceDouble = <T>(double: unknown): T => double as T
type StubLoginChild = EventEmitter & {
stdout: PassThrough
stderr: PassThrough
kill: () => boolean
exitCode: number | null
signalCode: string | null
}
function createStubLoginChild(): StubLoginChild {
const child: StubLoginChild = Object.assign(new EventEmitter(), {
stdout: new PassThrough(),
stderr: new PassThrough(),
exitCode: null,
signalCode: null,
kill: () => true
})
// The real codex CLI forwards SIGTERM to its native child and exits.
child.kill = vi.fn(() => {
child.exitCode = 143
return true
})
return child
}
/** A service whose `codex login` never finishes on its own. */
async function createServiceWithHangingLogin(): Promise<{
service: {
addAccount: () => Promise<{ accounts: { email: string }[] }>
selectAccount: (accountId: string | null) => Promise<unknown>
cancelPendingLogin: () => boolean
getPendingLoginUrl: () => string | null
onPendingLoginUrlChanged: (listener: (url: string | null) => void) => void
}
children: StubLoginChild[]
/** The `CODEX_HOME` each login was spawned against. */
loginHomes: string[]
}> {
vi.resetModules()
const children: StubLoginChild[] = []
const loginHomes: string[] = []
vi.doMock('node:child_process', () => ({
execFileSync: vi.fn(),
spawn: vi.fn((_command: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => {
loginHomes.push(options.env.CODEX_HOME ?? '')
const child = createStubLoginChild()
children.push(child)
return child
})
}))
vi.doMock('../codex-cli/command', () => ({
resolveCodexCommand: () => 'codex'
}))
const { CodexAccountService } = await import('./service')
const service = new CodexAccountService(
asServiceDouble(createStore(createSettings())),
asServiceDouble(createRateLimits()),
asServiceDouble(createRuntimeHome())
)
return { service, children, loginHomes }
}
describe('CodexAccountService abandoned login', () => {
registerCodexAccountsTestHomes()
afterEach(() => {
vi.doUnmock('node:child_process')
vi.doUnmock('../codex-cli/command')
})
it('supersedes the login a closed Settings pane abandoned instead of queueing behind it', async () => {
const { service, children } = await createServiceWithHangingLogin()
const abandoned = service.addAccount()
const abandonedRejection = expect(abandoned).rejects.toThrow('Codex sign-in was cancelled.')
await vi.waitUntil(() => children.length === 1)
// The user reopens Settings and clicks Add Account again.
const retry = service.addAccount()
const retryRejection = expect(retry).rejects.toThrow()
await abandonedRejection
expect(children[0].kill).toHaveBeenCalledTimes(1)
// Why: the point of the fix — the second login starts now, not after the
// abandoned one's whole sign-in deadline elapses.
await vi.waitUntil(() => children.length === 2)
expect(children[1].kill).not.toHaveBeenCalled()
service.cancelPendingLogin()
await retryRejection
})
it('frees the queue for a plain account switch too, not only for another add', async () => {
const { service, children } = await createServiceWithHangingLogin()
const abandoned = service.addAccount()
const abandonedRejection = expect(abandoned).rejects.toThrow('Codex sign-in was cancelled.')
await vi.waitUntil(() => children.length === 1)
// Why: switching to the system default is the commonest thing a user does
// after giving up on a sign-in, and it shares the add's mutation queue.
await service.selectAccount(null)
await abandonedRejection
expect(children[0].kill).toHaveBeenCalled()
})
it('refuses to cancel a sign-in that already wrote credentials, and keeps the account', async () => {
const { service, children, loginHomes } = await createServiceWithHangingLogin()
const pending = service.addAccount()
await vi.waitUntil(() => children.length === 1)
// The browser half of the OAuth flow finishes while the CLI lingers.
writeFileSync(
join(loginHomes[0], 'auth.json'),
createCodexAuthJson('user@example.com', 'provider-account-1', 'refresh-token'),
'utf-8'
)
// Why: cancelling here would send the rollback at a home that just
// authenticated. There is nothing left to cancel.
expect(service.cancelPendingLogin()).toBe(false)
expect(children[0].kill).not.toHaveBeenCalled()
children[0].emit('close', 0)
const accounts = await pending
expect(accounts.accounts.map((account) => account.email)).toEqual(['user@example.com'])
expect(existsSync(join(loginHomes[0], 'auth.json'))).toBe(true)
})
it('reports whether a pending login was there to cancel', async () => {
const { service, children } = await createServiceWithHangingLogin()
const pending = service.addAccount()
const rejection = expect(pending).rejects.toThrow('Codex sign-in was cancelled.')
await vi.waitUntil(() => children.length === 1)
expect(service.cancelPendingLogin()).toBe(true)
await rejection
expect(service.cancelPendingLogin()).toBe(false)
})
it('publishes the sign-in link codex prints and drops it when the login ends', async () => {
const { service, children } = await createServiceWithHangingLogin()
const published: (string | null)[] = []
service.onPendingLoginUrlChanged((url) => published.push(url))
const pending = service.addAccount()
const rejection = expect(pending).rejects.toThrow('Codex sign-in was cancelled.')
await vi.waitUntil(() => children.length === 1)
const authUrl = 'https://auth.openai.com/oauth/authorize?client_id=orca&state=abc'
children[0].stdout.write(
`Starting local login server on http://localhost:1455.\nIf your browser did not open, navigate to this URL to authenticate:\n\n${authUrl}\n`
)
await vi.waitUntil(() => service.getPendingLoginUrl() === authUrl)
expect(published).toEqual([authUrl])
service.cancelPendingLogin()
await rejection
// Why: the link dies with the login server it points back at.
expect(service.getPendingLoginUrl()).toBeNull()
expect(published).toEqual([authUrl, null])
})
})
@@ -70,7 +70,7 @@ describe('CodexAccountService config sync', () => {
'Codex sign-in took too long to finish.'
)
await vi.advanceTimersByTimeAsync(120_000)
await vi.advanceTimersByTimeAsync(180_000)
await rejection
expect(child.kill).toHaveBeenCalledTimes(1)
@@ -89,7 +89,10 @@ describe('CodexAccountService config sync', () => {
vi.resetModules()
vi.useFakeTimers()
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true })
Object.defineProperty(process, 'platform', {
value: 'win32',
configurable: true
})
const child = new EventEmitter() as EventEmitter & {
stdout: PassThrough
stderr: PassThrough
@@ -162,7 +165,10 @@ describe('CodexAccountService config sync', () => {
vi.resetModules()
vi.useFakeTimers()
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true })
Object.defineProperty(process, 'platform', {
value: 'win32',
configurable: true
})
const child = new EventEmitter() as EventEmitter & {
stdout: PassThrough
stderr: PassThrough
@@ -182,7 +188,9 @@ describe('CodexAccountService config sync', () => {
execFileSync: execFileSyncMock,
spawn: vi.fn(() => child)
}))
vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' }))
vi.doMock('../codex-cli/command', () => ({
resolveCodexCommand: () => 'codex'
}))
const authPath = join(testState.fakeHomeDir, 'auth.json')
writeFileSync(
authPath,
@@ -198,7 +206,9 @@ describe('CodexAccountService config sync', () => {
createRuntimeHome() as never
)
const loginPromise = (
service as unknown as { runCodexLogin(managedHomePath: string): Promise<void> }
service as unknown as {
runCodexLogin(managedHomePath: string): Promise<void>
}
).runCodexLogin(testState.fakeHomeDir)
await vi.advanceTimersByTimeAsync(6_000)
+66 -11
View File
@@ -80,6 +80,9 @@ function killLoginProcessTree(
export class CodexAccountService {
// Why: serialize the read-modify-write of settings; overlapping calls (e.g. double-click Add) would lose updates.
private mutationQueue: Promise<unknown> = Promise.resolve()
private cancelPendingCodexLogin: (() => boolean) | null = null
private pendingLoginUrl: string | null = null
private readonly pendingLoginUrlListeners = new Set<(url: string | null) => void>()
private readonly identity: CodexAccountIdentity
private readonly configMirror: CodexConfigMirror
private readonly managedHomePaths: CodexManagedHomePath
@@ -161,9 +164,46 @@ export class CodexAccountService {
}
async addAccount(target?: CodexAccountAddTarget): Promise<CodexRateLimitAccountsState> {
this.supersedePendingLogin()
return this.serializeMutation(() => this.registration.add(target))
}
/** Abandons the login waiting on a browser, if any. True when one was stopped. */
cancelPendingLogin(): boolean {
return this.cancelPendingCodexLogin?.() ?? false
}
/** The sign-in link of the login waiting on a browser, for a late-joining renderer. */
getPendingLoginUrl(): string | null {
return this.pendingLoginUrl
}
/** Registration lasts the process's lifetime; there is no teardown to hand back. */
onPendingLoginUrlChanged(listener: (url: string | null) => void): void {
this.pendingLoginUrlListeners.add(listener)
}
private setPendingLoginUrl(url: string | null): void {
// Why the guard: every login that ends before printing a link clears an
// already-empty value, and each change reaches every window.
if (this.pendingLoginUrl === url) {
return
}
this.pendingLoginUrl = url
for (const listener of this.pendingLoginUrlListeners) {
listener(url)
}
}
// Why before the queue, not inside it: the abandoned login owns the queue slot
// every later account action waits for. Called from the four the user drives,
// never from serializeMutation, which background reset-credit work also uses.
private supersedePendingLogin(): void {
if (this.cancelPendingLogin()) {
console.info('[codex-accounts] Cancelled a pending Codex login superseded by a new request.')
}
}
/**
* Registers a managed Codex account from an already-authenticated `CODEX_HOME`
* instead of driving `codex login` here. Lets the `orca account add --agent codex`
@@ -181,14 +221,17 @@ export class CodexAccountService {
accountId: string,
options?: CodexAccountReauthenticateOptions
): Promise<CodexRateLimitAccountsState> {
this.supersedePendingLogin()
return this.serializeMutation(() => this.registration.reauthenticate(accountId, options))
}
async removeAccount(accountId: string): Promise<CodexRateLimitAccountsState> {
this.supersedePendingLogin()
return this.serializeMutation(() => this.selection.remove(accountId))
}
async selectAccount(accountId: string | null): Promise<CodexRateLimitAccountsState> {
this.supersedePendingLogin()
return this.serializeMutation(() => this.selection.select(accountId))
}
@@ -196,6 +239,7 @@ export class CodexAccountService {
accountId: string | null,
target?: CodexAccountSelectionTarget
): Promise<CodexRateLimitAccountsState> {
this.supersedePendingLogin()
return this.serializeMutation(() => this.selection.select(accountId, target))
}
@@ -234,16 +278,27 @@ export class CodexAccountService {
}
private async runCodexLogin(managedHomePath: string): Promise<void> {
await runCodexLoginSession(managedHomePath, {
wslCommand: 'wsl.exe',
spawn: ({ command, args, env, stdio }) =>
spawn(command, args, {
stdio,
// Why: hide the outer wrapper only. A dedicated login console stays visible.
windowsHide: true,
env
}),
killProcessTree: killLoginProcessTree
})
try {
await runCodexLoginSession(managedHomePath, {
wslCommand: 'wsl.exe',
spawn: ({ command, args, env, stdio }) =>
spawn(command, args, {
stdio,
// Why: hide the outer wrapper only. A dedicated login console stays visible.
windowsHide: true,
env
}),
killProcessTree: killLoginProcessTree,
setCancel: (cancel) => {
this.cancelPendingCodexLogin = cancel
},
onAuthUrl: (url) => this.setPendingLoginUrl(url)
})
} finally {
// Why: both die with the login server — no surface may keep offering a
// link nothing is listening on, or a cancel with nothing to cancel.
this.cancelPendingCodexLogin = null
this.setPendingLoginUrl(null)
}
}
}
@@ -243,16 +243,29 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
})
describe('background stream thinning compatibility', () => {
it('reports authoritative snapshot support only for the corrected serializer protocol', () => {
it('reports authoritative snapshot support only for the corrected serializer protocol', async () => {
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 31 })
try {
expect(legacy.canProvideAuthoritativeBufferSnapshot('legacy-session')).toBe(false)
expect(adapter.canProvideAuthoritativeBufferSnapshot('current-session')).toBe(true)
expect(legacy.canProvideAuthoritativeBufferSnapshot(id)).toBe(false)
expect(adapter.canProvideAuthoritativeBufferSnapshot(id)).toBe(true)
} finally {
legacy.dispose()
}
})
// Why this matters beyond tidiness: getProviderForPty falls back to the local provider for
// any id it cannot place, so these ids reach this adapter for real. Answered from the
// protocol flag alone they came back `true`, which the renderer caches as a definitive
// per-pty licence to unmount a pane whose bytes this daemon never held.
it('refuses an authoritative snapshot claim for a session it does not own', async () => {
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
expect(adapter.canProvideAuthoritativeBufferSnapshot(id)).toBe(true)
expect(adapter.canProvideAuthoritativeBufferSnapshot('remote:env-1:pty-1')).toBe(false)
expect(adapter.canProvideAuthoritativeBufferSnapshot('never-spawned-session')).toBe(false)
})
it('reports background state on the authoritative-snapshot protocol', () => {
const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify')
try {
+6 -2
View File
@@ -237,8 +237,12 @@ export abstract class DaemonPtyRuntimeState {
return this.protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION
}
canProvideAuthoritativeBufferSnapshot(_id: string): boolean {
return this.supportsAuthoritativeBufferSnapshots
// Why the id is read rather than ignored: the contract promises a fact about THIS pty, and
// getProviderForPty falls back to the local provider for any id it cannot place. A
// remote-runtime id therefore reaches this adapter, and answering from the protocol flag
// alone returned `true` for a session this daemon has never owned.
canProvideAuthoritativeBufferSnapshot(id: string): boolean {
return this.supportsAuthoritativeBufferSnapshots && this.activeSessionIds.has(id)
}
protected get canDelegateBackgroundToDaemon(): boolean {
+6
View File
@@ -1,6 +1,7 @@
import { ipcMain } from 'electron'
import type { CodexAccountAddTarget, CodexAccountService } from '../codex-accounts/service'
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
import { broadcastCodexPendingLoginUrl } from './codex-pending-login-url-broadcast'
import { listRecordedCodexPaneLanes } from '../codex/codex-pane-account-registry'
import { forgetStaleCodexPanes, listStaleCodexPanes } from '../codex/codex-stale-pane-accounts'
import type { GlobalSettings } from '../../shared/global-settings-types'
@@ -38,6 +39,11 @@ export function registerCodexAccountHandlers(
ipcMain.handle('codexAccounts:add', (_event, args?: CodexAccountAddTarget) =>
codexAccounts.addAccount(args)
)
ipcMain.handle('codexAccounts:cancelPendingLogin', () => codexAccounts.cancelPendingLogin())
ipcMain.handle('codexAccounts:pendingLoginUrl', () => codexAccounts.getPendingLoginUrl())
// Why: Settings can open after the login already printed its link, so the
// renderer reads the current value on mount and this only carries changes.
codexAccounts.onPendingLoginUrlChanged(broadcastCodexPendingLoginUrl)
ipcMain.handle(
'codexAccounts:reauthenticate',
(_event, args: { accountId: string; activateIfSelectionWasEmpty?: boolean }) =>
@@ -0,0 +1,15 @@
import { BrowserWindow } from 'electron'
import { CODEX_PENDING_LOGIN_URL_CHANGED_CHANNEL } from '../../shared/codex-auth-errors'
export function broadcastCodexPendingLoginUrl(url: string | null): void {
for (const window of BrowserWindow.getAllWindows()) {
if (window.isDestroyed()) {
continue
}
try {
window.webContents.send(CODEX_PENDING_LOGIN_URL_CHANGED_CHANNEL, url)
} catch {
// A renderer can disappear between isDestroyed() and send().
}
}
}
+4
View File
@@ -11,6 +11,10 @@ export type CodexAccountsApi = {
runtime?: 'host' | 'wsl'
wslDistro?: string | null
}) => Promise<CodexRateLimitAccountsState>
cancelPendingLogin: () => Promise<boolean>
/** Sign-in link of the login waiting on a browser, or null when none is. */
getPendingLoginUrl: () => Promise<string | null>
onPendingLoginUrlChanged: (callback: (url: string | null) => void) => () => void
reauthenticate: (args: {
accountId: string
/** Local-only: activate the re-authed account when its runtime lane had no selection. */
+10
View File
@@ -1,10 +1,20 @@
import { ipcRenderer } from 'electron'
import { CODEX_PENDING_LOGIN_URL_CHANGED_CHANNEL } from '../../shared/codex-auth-errors'
import type { PreloadApi } from '../api-types'
export const codexAccountsApi = {
list: () => ipcRenderer.invoke('codexAccounts:list'),
add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }) =>
ipcRenderer.invoke('codexAccounts:add', args),
cancelPendingLogin: (): Promise<boolean> =>
ipcRenderer.invoke('codexAccounts:cancelPendingLogin'),
getPendingLoginUrl: (): Promise<string | null> =>
ipcRenderer.invoke('codexAccounts:pendingLoginUrl'),
onPendingLoginUrlChanged: (callback: (url: string | null) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, url: string | null): void => callback(url)
ipcRenderer.on(CODEX_PENDING_LOGIN_URL_CHANGED_CHANNEL, listener)
return () => ipcRenderer.removeListener(CODEX_PENDING_LOGIN_URL_CHANGED_CHANNEL, listener)
},
reauthenticate: (args: { accountId: string; activateIfSelectionWasEmpty?: boolean }) =>
ipcRenderer.invoke('codexAccounts:reauthenticate', args),
remove: (args: { accountId: string }) => ipcRenderer.invoke('codexAccounts:remove', args),
@@ -131,6 +131,7 @@ export function EditorConflictReviewSurface({
<div className={className}>
<EditorFileLoadErrorView
message={fileContent.loadError}
code={fileContent.loadErrorCode}
onRetry={() => reloadContent(contentFile)}
/>
</div>
@@ -188,6 +188,7 @@ export function EditorContent({
return (
<EditorFileLoadErrorView
message={fileContent.loadError}
code={fileContent.loadErrorCode}
onRetry={() => reloadContent(activeFile)}
/>
)
@@ -101,6 +101,7 @@ export function EditorEditFileSurface({
return (
<EditorFileLoadErrorView
message={fileContent.loadError}
code={fileContent.loadErrorCode}
onRetry={() => reloadContent(activeFile)}
/>
)
@@ -0,0 +1,40 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { EditorFileLoadErrorView } from './EditorFileLoadErrorView'
import {
WORKTREE_HOST_UNRESOLVED_CODE,
WORKTREE_HOST_UNRESOLVED_ERROR
} from './editor-panel-content-types'
describe('EditorFileLoadErrorView', () => {
afterEach(cleanup)
it('offers Retry as its only action', () => {
// Why: closing must stay with the tab strip, whose path carries the pin, shared-
// reference, and unsaved-changes checks; a second close control here would not.
const onRetry = vi.fn()
render(<EditorFileLoadErrorView message="selector_not_found" onRetry={onRetry} />)
screen.getByText('selector_not_found')
expect(screen.getAllByRole('button')).toHaveLength(1)
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(onRetry).toHaveBeenCalledOnce()
})
it('localizes the host-unresolved state by its sentinel code, not by the stored text', () => {
// Why: the stored `loadError` is an English fallback; the code is what selects the
// localized copy, so a translated catalog cannot desynchronize from the comparison.
render(
<EditorFileLoadErrorView
message="stored fallback text"
code={WORKTREE_HOST_UNRESOLVED_CODE}
onRetry={vi.fn()}
/>
)
screen.getByText(WORKTREE_HOST_UNRESOLVED_ERROR)
expect(screen.queryByText('stored fallback text')).toBeNull()
})
})
@@ -1,12 +1,32 @@
import { AlertCircle, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { WORKTREE_HOST_UNRESOLVED_CODE } from './editor-panel-content-types'
// Why: `loadError` is stored as English so logs and non-view consumers stay readable; the
// user-facing copy is keyed by the machine sentinel, never by the text, so localization
// cannot break the terminal-state comparison upstream (#21041).
function localizeFileLoadError(message: string, code: string | undefined): string {
if (code === WORKTREE_HOST_UNRESOLVED_CODE) {
return translate(
'editor.fileLoad.hostUnresolved',
"The host couldn't find this file's workspace. It may have been removed, or the host may not know about it yet. Retry, or close this tab from the tab strip."
)
}
return message
}
// Why no Close action here: this view renders for real tabs and for synthesized inline
// conflict rows alike, and only the tab strip's own close path carries the pin, shared-
// reference, and unsaved-changes semantics. The copy points the user at that path instead
// of adding a second one that would have to reimplement it (#21041).
export function EditorFileLoadErrorView({
message,
code,
onRetry
}: {
message: string
code?: string
onRetry: () => void
}): React.JSX.Element {
return (
@@ -17,7 +37,7 @@ export function EditorFileLoadErrorView({
<div className="font-medium text-foreground">
{translate('auto.components.editor.EditorContent.39f018b052', 'Unable to load file')}
</div>
<div className="mt-1 break-words">{message}</div>
<div className="mt-1 break-words">{localizeFileLoadError(message, code)}</div>
<Button type="button" variant="outline" size="sm" className="mt-3" onClick={onRetry}>
<RefreshCw className="size-3.5" />
{translate('auto.components.editor.EditorContent.2a512bb46a', 'Retry')}
@@ -18,6 +18,34 @@ export const WORKTREE_OWNER_NOT_READY_ERROR =
export const WORKTREE_OWNER_UNREACHABLE_ERROR =
"Couldn't reach the remote host. Check the connection, then retry."
/**
* Machine code the runtime host returns when its worktree resolver cannot place
* the file's workspace. It is UNKNOWN, not absence: the same code covers a deleted
* git worktree, a cold or failing scan (see remote-browser-stream-errors.ts), and
* a synchronous miss for an unregistered folder-workspace id or removed repo. For
* git worktrees the file-read path has no definitive "gone" answer; the codes that
* are definitive (`folder_workspace_path_missing:<path>`,
* `worktree_execution_host_unresolved`) are not classified here yet and still land
* on their raw text. The retry gate bounds this one, then swaps in the terminal
* message below — never an automatic close (#21041).
*/
export const WORKTREE_HOST_SELECTOR_NOT_FOUND_CODE = 'selector_not_found'
/**
* Client-side sentinel stored on `loadErrorCode` once the selector-not-found retry
* budget is spent. Namespaced `editor_` so it cannot be confused with the CLI's
* `worktree_host_unresolved` client error. The comparison key is deliberately not
* the display text, so localizing the message can never break the terminal-state
* check. Truthful about what is known (the host could not resolve the workspace)
* and what is not (whether it still exists); Retry starts a fresh budget, and the
* only close path is the tab strip's own, so nothing here discards a draft (#21041).
*/
export const WORKTREE_HOST_UNRESOLVED_CODE = 'editor_host_workspace_unresolved'
/** English fallback for `loadError` alongside the code above; the error view localizes it by code. */
export const WORKTREE_HOST_UNRESOLVED_ERROR =
"The host couldn't find this file's workspace. It may have been removed, or the host may not know about it yet. Retry, or close this tab from the tab strip."
export type FileContent = {
content: string
isBinary: boolean
@@ -25,6 +53,8 @@ export type FileContent = {
mimeType?: string
fileIdentity?: string
loadError?: string
/** Machine code from a runtime RPC failure; `loadError` alone may be prose (#21041). */
loadErrorCode?: string
/** Superseded by an external change; still rendered until the lazy reload lands. */
isStale?: boolean
}
@@ -4,7 +4,7 @@ import { getConnectionIdForFile, isWorktreeConnectionResolved } from '@/lib/conn
import { useAppStore } from '@/store'
import { getDiskBaselineSignature } from './diff-content-signature'
import { getRuntimeFileReadScope, readRuntimeFileContent } from '@/runtime/runtime-file-client'
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
import { RuntimeRpcCallError, settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
import { findWorkspaceFileRoute } from '@/lib/runtime-workspace-file-route'
import {
LOCAL_EXECUTION_HOST_ID,
@@ -205,9 +205,17 @@ export function useEditorPanelFileContentLoader({
return
}
const message = err instanceof Error ? err.message : String(err)
// Why: a host may put prose on the message and the machine token on `.code`;
// classifiers downstream must see the token, not only its rendering (#21041).
const loadErrorCode = err instanceof RuntimeRpcCallError ? err.code : undefined
setFileContents((prev) => ({
...prev,
[id]: { content: '', isBinary: false, loadError: message }
[id]: {
content: '',
isBinary: false,
loadError: message,
...(loadErrorCode ? { loadErrorCode } : {})
}
}))
} finally {
if (outstandingFileReadsRef.current[id] === generation) {
@@ -5,11 +5,16 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import type { OpenFile } from '@/store/slices/editor'
import {
WORKTREE_HOST_SELECTOR_NOT_FOUND_CODE,
WORKTREE_HOST_UNRESOLVED_CODE,
WORKTREE_HOST_UNRESOLVED_ERROR,
WORKTREE_OWNER_NOT_READY_ERROR,
WORKTREE_OWNER_UNREACHABLE_ERROR,
type FileContent
} from './editor-panel-content-types'
import {
FILE_LOAD_RETRY_DELAYS_MS,
isHostSelectorNotFoundError,
OWNER_NOT_READY_RETRY_DELAY_MS,
OWNER_NOT_READY_RETRY_LIMIT,
shouldRetryFileLoadError,
@@ -265,3 +270,148 @@ describe('useEditorPanelFileLoadRetry — owner-not-ready bounding (#6648)', ()
expect(fileContents[file.id]?.content).toBe('remote')
})
})
describe('useEditorPanelFileLoadRetry — selector_not_found bounding (#21041)', () => {
let container: HTMLDivElement | null = null
let root: Root | null = null
let setTimeoutSpy: MockInstance
beforeEach(() => {
vi.useFakeTimers()
setTimeoutSpy = vi.spyOn(window, 'setTimeout').mockImplementation(((fn: () => void) => {
fn()
return 0 as unknown as ReturnType<typeof setTimeout>
}) as typeof window.setTimeout)
})
afterEach(() => {
if (root) {
act(() => root?.unmount())
}
container?.remove()
container = null
root = null
setTimeoutSpy.mockRestore()
vi.useRealTimers()
})
it('retries the raw resolver code but never the terminal sentinel', () => {
expect(shouldRetryFileLoadError(WORKTREE_HOST_SELECTOR_NOT_FOUND_CODE)).toBe(true)
expect(shouldRetryFileLoadError('Selector not found')).toBe(true)
expect(
shouldRetryFileLoadError(WORKTREE_HOST_UNRESOLVED_ERROR, WORKTREE_HOST_UNRESOLVED_CODE)
).toBe(false)
// Why: the sentinel is the code, so a localized or reworded message stays terminal.
expect(shouldRetryFileLoadError('texto localizado', WORKTREE_HOST_UNRESOLVED_CODE)).toBe(false)
})
it('matches only the exact selector_not_found token', () => {
expect(isHostSelectorNotFoundError({ loadError: 'selector_not_found' })).toBe(true)
expect(
isHostSelectorNotFoundError({
loadError: 'Selector not found',
loadErrorCode: 'selector_not_found'
})
).toBe(true)
// Transport-wrapped token after a message boundary (shared matcher contract).
expect(
isHostSelectorNotFoundError({ loadError: 'relay call failed: selector_not_found\n' })
).toBe(true)
// Near misses: not the defined token, must not classify.
expect(isHostSelectorNotFoundError({ loadError: 'Selector_Not_Found' })).toBe(false)
expect(isHostSelectorNotFoundError({ loadError: 'SELECTOR_NOT_FOUND' })).toBe(false)
expect(isHostSelectorNotFoundError({ loadError: 'selector_not_found_v2' })).toBe(false)
expect(isHostSelectorNotFoundError({ loadError: 'the selector_not_found branch ran' })).toBe(
false
)
expect(
isHostSelectorNotFoundError({
loadError: 'Selector not found',
loadErrorCode: 'Selector_Not_Found'
})
).toBe(false)
expect(
isHostSelectorNotFoundError({
loadError: 'Selector not found',
loadErrorCode: 'tab_not_found'
})
).toBe(false)
})
// Why both host answers: a host may put the bare code on the message, or the code on
// `.code` with prose on `.message` (runtime-rpc-result.test.ts). Either must reach the
// same terminal state — matching only the rendered text would strand the second forever.
it.each([
['bare code as message', { loadError: WORKTREE_HOST_SELECTOR_NOT_FOUND_CODE }],
[
'code with human-readable message',
{ loadError: 'Selector not found', loadErrorCode: WORKTREE_HOST_SELECTOR_NOT_FOUND_CODE }
]
])(
'keeps a dirty mirrored tab (%s): bounded retries end in a truthful terminal message, not a close',
(_hostAnswer, failure) => {
const file = makeFile({
id: 'mirror-1',
filePath: '/home/user/project/NOTES.md',
relativePath: 'NOTES.md',
language: 'markdown',
mode: 'markdown-preview',
isDirty: true,
mirroredFromRuntimeSession: true
})
const attemptsRef = { current: {} as Record<string, number> }
const failedRead = (): FileContent => ({ content: '', isBinary: false, ...failure })
const fileContents: Record<string, FileContent> = { [file.id]: failedRead() }
const setFileContents = (
updater: (prev: Record<string, FileContent>) => Record<string, FileContent>
): void => {
replaceFileContents(fileContents, updater(fileContents))
}
// The host's resolver never places the workspace: every retry re-fails the same way.
const loadFileContent = vi.fn(async (_filePath: string, id: string) => {
fileContents[id] = failedRead()
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
for (let i = 0; i < FILE_LOAD_RETRY_DELAYS_MS.length + 2; i++) {
act(() => {
root?.render(
<Harness
file={file}
fileContents={{ ...fileContents }}
attemptsRef={attemptsRef}
loadFileContent={loadFileContent}
setFileContents={setFileContents}
/>
)
})
if (fileContents[file.id]?.loadError === WORKTREE_HOST_UNRESOLVED_ERROR) {
break
}
}
// Budget honored, then a truthful terminal state: the raw code is gone and the
// tab is still there for the user to retry or close.
expect(loadFileContent).toHaveBeenCalledTimes(FILE_LOAD_RETRY_DELAYS_MS.length)
expect(fileContents[file.id]?.loadError).toBe(WORKTREE_HOST_UNRESOLVED_ERROR)
expect(fileContents[file.id]?.loadErrorCode).toBe(WORKTREE_HOST_UNRESOLVED_CODE)
// Terminal: the message is not auto-retried.
act(() => {
root?.render(
<Harness
file={file}
fileContents={{ ...fileContents }}
attemptsRef={attemptsRef}
loadFileContent={loadFileContent}
setFileContents={setFileContents}
/>
)
})
expect(loadFileContent).toHaveBeenCalledTimes(FILE_LOAD_RETRY_DELAYS_MS.length)
}
)
})
@@ -1,12 +1,16 @@
import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
import type { OpenFile } from '@/store/slices/editor'
import { hasRuntimeRpcErrorCode } from '../../../../shared/runtime-rpc-error-code'
import {
WORKTREE_HOST_SELECTOR_NOT_FOUND_CODE,
WORKTREE_HOST_UNRESOLVED_CODE,
WORKTREE_HOST_UNRESOLVED_ERROR,
WORKTREE_OWNER_NOT_READY_ERROR,
WORKTREE_OWNER_UNREACHABLE_ERROR,
type FileContent
} from './editor-panel-content-types'
const FILE_LOAD_RETRY_DELAYS_MS = [250, 1000, 2500]
export const FILE_LOAD_RETRY_DELAYS_MS = [250, 1000, 2500]
// Why: a remote host can take a while to finish connecting. The owner-not-ready
// check is a pure local store read (it throws before any network call until the
// SSH repo hydrates), so poll it at a steady cadence — but cap the wait so a
@@ -19,6 +23,18 @@ function isOwnerNotReadyError(message: string): boolean {
return message === WORKTREE_OWNER_NOT_READY_ERROR
}
// Why the shared matcher: the token may arrive on `.code` with prose on the message, as
// the bare message, or transport-wrapped ("…: selector_not_found"); a message compare
// alone would strand the first shape on raw text with no way out (#21041).
export function isHostSelectorNotFoundError(
failure: Pick<FileContent, 'loadError' | 'loadErrorCode'>
): boolean {
return hasRuntimeRpcErrorCode(
{ code: failure.loadErrorCode, message: failure.loadError },
WORKTREE_HOST_SELECTOR_NOT_FOUND_CODE
)
}
type UseEditorPanelFileLoadRetryParams = {
activeFile: OpenFile | null
fileContents: Record<string, FileContent>
@@ -33,10 +49,10 @@ type UseEditorPanelFileLoadRetryParams = {
setFileContents: Dispatch<SetStateAction<Record<string, FileContent>>>
}
export function shouldRetryFileLoadError(message: string): boolean {
// Terminal: the owner-not-ready budget is spent; only an explicit Retry should
// restart it, never the automatic backoff.
if (message === WORKTREE_OWNER_UNREACHABLE_ERROR) {
export function shouldRetryFileLoadError(message: string, code?: string): boolean {
// Terminal: a retry budget is spent; only an explicit Retry should restart it,
// never the automatic backoff.
if (message === WORKTREE_OWNER_UNREACHABLE_ERROR || code === WORKTREE_HOST_UNRESOLVED_CODE) {
return false
}
const lower = message.toLowerCase()
@@ -60,12 +76,15 @@ export function useEditorPanelFileLoadRetry({
const activeFileLoadError = activeFileLoadRetryId
? fileContents[activeFileLoadRetryId]?.loadError
: undefined
const activeFileLoadErrorCode = activeFileLoadRetryId
? fileContents[activeFileLoadRetryId]?.loadErrorCode
: undefined
useEffect(() => {
if (
!activeFileLoadRetryId ||
!activeFileLoadError ||
!shouldRetryFileLoadError(activeFileLoadError)
!shouldRetryFileLoadError(activeFileLoadError, activeFileLoadErrorCode)
) {
return
}
@@ -75,21 +94,31 @@ export function useEditorPanelFileLoadRetry({
? OWNER_NOT_READY_RETRY_LIMIT
: FILE_LOAD_RETRY_DELAYS_MS.length
if (retryCount >= retryLimit) {
// Why: the remote host never finished connecting. Replace the transient
// "still connecting" text with a truthful terminal message so it does not
// look like it is still retrying; Retry starts a fresh budget (#6648).
if (ownerNotReady) {
// Why: the remote host never finished connecting (#6648), or its worktree
// resolver still cannot place the workspace (#21041). Replace the transient
// text with a truthful terminal message so it does not look like it is still
// retrying; Retry starts a fresh budget. selector_not_found is UNKNOWN, not
// absence, so the tab stays open: closing is the user's call, which is also
// what keeps an unsaved draft from being discarded on a resolver blip.
const terminalFailure: Pick<FileContent, 'loadError' | 'loadErrorCode'> | null = ownerNotReady
? { loadError: WORKTREE_OWNER_UNREACHABLE_ERROR }
: isHostSelectorNotFoundError({
loadError: activeFileLoadError,
loadErrorCode: activeFileLoadErrorCode
})
? {
loadError: WORKTREE_HOST_UNRESOLVED_ERROR,
loadErrorCode: WORKTREE_HOST_UNRESOLVED_CODE
}
: null
if (terminalFailure) {
setFileContents((prev) => {
if (prev[activeFileLoadRetryId]?.loadError !== activeFileLoadError) {
return prev
}
return {
...prev,
[activeFileLoadRetryId]: {
content: '',
isBinary: false,
loadError: WORKTREE_OWNER_UNREACHABLE_ERROR
}
[activeFileLoadRetryId]: { content: '', isBinary: false, ...terminalFailure }
}
})
}
@@ -126,6 +155,7 @@ export function useEditorPanelFileLoadRetry({
}, [
activeFileLoadRetryId,
activeFileLoadError,
activeFileLoadErrorCode,
fileLoadRetryAttemptsRef,
loadFileContent,
openFilesRef,
@@ -0,0 +1,176 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OpenFile } from '@/store/slices/editor'
import type { FileContent } from './editor-panel-content-types'
const mocks = vi.hoisted(() => ({
getState: vi.fn(),
readRuntimeFileContent: vi.fn()
}))
vi.mock('@/runtime/runtime-file-client', () => ({
getRuntimeFileReadScope: vi.fn(
(settings: { activeRuntimeEnvironmentId?: string | null } | null | undefined) =>
settings?.activeRuntimeEnvironmentId ?? null
),
readRuntimeFileContent: mocks.readRuntimeFileContent,
subscribeRuntimeFileChanges: vi.fn()
}))
vi.mock('@/runtime/runtime-git-client', () => ({
getRuntimeGitBranchDiff: vi.fn(),
getRuntimeGitCommitDiff: vi.fn(),
getRuntimeGitDiff: vi.fn(),
getRuntimeGitScope: vi.fn(() => null)
}))
vi.mock('@/lib/connection-context', () => ({
getConnectionId: vi.fn(),
getConnectionIdForFile: vi.fn(),
isWorktreeConnectionResolved: vi.fn(() => true)
}))
vi.mock('@/lib/runtime-workspace-file-route', () => ({
findWorkspaceFileRoute: vi.fn(() => null)
}))
vi.mock('@/store', () => ({ useAppStore: { getState: mocks.getState } }))
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
import {
WORKTREE_HOST_UNRESOLVED_CODE,
WORKTREE_HOST_UNRESOLVED_ERROR
} from './editor-panel-content-types'
import { useEditorPanelContentState } from './useEditorPanelContentState'
import { FILE_LOAD_RETRY_DELAYS_MS } from './useEditorPanelFileLoadRetry'
let latestFileContents: Record<string, FileContent> = {}
const EMPTY_EDITOR_VIEW_MODE = {}
const HOST_READ_LATENCY_MS = 1
// Why: openFiles/editorViewMode must be referentially stable across renders — the prune
// effect keys on them, and a fresh array per render would loop it.
function Probe({ activeFile, openFiles }: { activeFile: OpenFile; openFiles: OpenFile[] }): null {
const state = useEditorPanelContentState({
activeFile,
isChangesMode: false,
openFiles,
gitStatusEntries: undefined,
editorViewMode: EMPTY_EDITOR_VIEW_MODE
})
latestFileContents = state.fileContents
return null
}
// A host-mirrored markdown tab in a runtime-owned worktree, with unsaved edits.
function makeDirtyMirroredFile(): OpenFile {
return {
id: 'mirror-1',
filePath: '/home/user/project/NOTES.md',
relativePath: 'NOTES.md',
worktreeId: 'project::/home/user/project',
language: 'markdown',
isDirty: true,
mode: 'markdown-preview',
mirroredFromRuntimeSession: true,
runtimeEnvironmentId: 'env-1'
}
}
async function advanceTimers(ms: number): Promise<void> {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms)
})
}
describe('useEditorPanelContentState — host cannot resolve a mirrored file (#21041)', () => {
let container: HTMLDivElement | null = null
let root: Root | null = null
beforeEach(() => {
vi.useFakeTimers()
latestFileContents = {}
// Why: opening any tab arms useLocalLogTail's change subscription on window.api.
vi.stubGlobal('api', {
fs: { authorizeExternalPath: vi.fn(), onLocalLogTailChanged: vi.fn(() => () => {}) }
})
mocks.readRuntimeFileContent.mockReset()
mocks.getState.mockReset()
})
afterEach(() => {
if (root) {
act(() => root?.unmount())
}
container?.remove()
container = null
root = null
vi.unstubAllGlobals()
vi.useRealTimers()
})
it('keeps a dirty mirrored tab open when the host keeps answering selector_not_found', async () => {
// Why: selector_not_found is the host's "could not resolve right now", not proof the
// workspace is gone. The only safe outcome is a bounded retry that ends in a truthful
// terminal message with the tab and its draft untouched — never a close.
const activeFile = makeDirtyMirroredFile()
const openFiles = [activeFile]
const closeFile = vi.fn()
const editorDrafts = { [activeFile.id]: '# unsaved edits' }
mocks.getState.mockReturnValue({
settings: { activeRuntimeEnvironmentId: 'env-1' },
openFiles,
editorDrafts,
closeFile,
setLastKnownDiskSignature: vi.fn()
})
// Why the latency: a real host read rejects after I/O, in a later task than the retry
// that issued it. An immediate rejection would batch with the retry's own state
// update into one render and the effect would never re-arm — a test artifact.
// The documented host shape: machine code on `.code`, prose on `.message`
// (runtime-rpc-result.test.ts). Matching the message text alone would miss it.
const hostError = new RuntimeRpcCallError({
id: 'rpc-1',
ok: false,
error: { code: 'selector_not_found', message: 'Selector not found' }
})
mocks.readRuntimeFileContent.mockImplementation(
() =>
new Promise((_, reject) => {
setTimeout(() => reject(hostError), HOST_READ_LATENCY_MS)
})
)
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(<Probe activeFile={activeFile} openFiles={openFiles} />)
})
await advanceTimers(HOST_READ_LATENCY_MS)
expect(latestFileContents[activeFile.id]?.loadError).toBe('Selector not found')
expect(latestFileContents[activeFile.id]?.loadErrorCode).toBe('selector_not_found')
// Exhaust the bounded backoff; every retry gets the same answer.
for (const delayMs of FILE_LOAD_RETRY_DELAYS_MS) {
await advanceTimers(delayMs)
await advanceTimers(HOST_READ_LATENCY_MS)
}
// The budget is spent; the tab and its draft must still be there.
expect(closeFile).not.toHaveBeenCalled()
expect(editorDrafts[activeFile.id]).toBe('# unsaved edits')
const expectedReads = 1 + FILE_LOAD_RETRY_DELAYS_MS.length
expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(expectedReads)
expect(latestFileContents[activeFile.id]?.loadError).toBe(WORKTREE_HOST_UNRESOLVED_ERROR)
expect(latestFileContents[activeFile.id]?.loadErrorCode).toBe(WORKTREE_HOST_UNRESOLVED_CODE)
// Terminal: no more reads, still no eviction.
await advanceTimers(60_000)
expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(expectedReads)
expect(closeFile).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,93 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { i18n } from '../../i18n/i18n'
import { CodexLoginLinkNotice } from './CodexLoginLinkNotice'
const AUTH_URL = 'https://auth.openai.com/oauth/authorize?client_id=orca&state=abc123'
type LinkListener = (url: string | null) => void
function stubApi(pendingUrl: string | null): {
listeners: LinkListener[]
writeClipboardText: ReturnType<typeof vi.fn>
openUrl: ReturnType<typeof vi.fn>
} {
const listeners: LinkListener[] = []
const writeClipboardText = vi.fn(() => Promise.resolve())
const openUrl = vi.fn(() => Promise.resolve())
Object.defineProperty(globalThis, 'api', {
configurable: true,
value: {
codexAccounts: {
getPendingLoginUrl: () => Promise.resolve(pendingUrl),
onPendingLoginUrlChanged: (listener: LinkListener) => {
listeners.push(listener)
return () => {
listeners.splice(listeners.indexOf(listener), 1)
}
}
},
ui: { writeClipboardText },
shell: { openUrl }
}
})
return { listeners, writeClipboardText, openUrl }
}
afterEach(() => {
cleanup()
Reflect.deleteProperty(globalThis, 'api')
})
describe('CodexLoginLinkNotice', () => {
beforeEach(async () => {
await i18n.changeLanguage('en')
})
it('renders nothing while no login is waiting on a browser', async () => {
stubApi(null)
const { container } = render(<CodexLoginLinkNotice />)
await waitFor(() => expect(container.textContent).toBe(''))
})
it('shows the link of a login that was already running when Settings opened', async () => {
stubApi(AUTH_URL)
const { container } = render(<CodexLoginLinkNotice />)
await waitFor(() => expect(container.textContent).toContain(AUTH_URL))
})
it('copies the link and opens it in the default browser on request', async () => {
const { listeners, writeClipboardText, openUrl } = stubApi(null)
const { container } = render(<CodexLoginLinkNotice />)
await waitFor(() => expect(listeners.length).toBe(1))
listeners[0](AUTH_URL)
await waitFor(() => expect(container.textContent).toContain('Copy link'))
const button = (label: string): HTMLButtonElement => {
const found = Array.from(container.querySelectorAll('button')).find((candidate) =>
candidate.textContent?.includes(label)
)
expect(found).not.toBeUndefined()
return found!
}
fireEvent.click(button('Copy link'))
await waitFor(() => expect(writeClipboardText).toHaveBeenCalledWith(AUTH_URL))
await waitFor(() => expect(container.textContent).toContain('Copied'))
fireEvent.click(button('Open'))
expect(openUrl).toHaveBeenCalledWith(AUTH_URL)
})
it('drops the link once the login ends', async () => {
const { listeners } = stubApi(AUTH_URL)
const { container } = render(<CodexLoginLinkNotice />)
await waitFor(() => expect(container.textContent).toContain(AUTH_URL))
listeners[0](null)
await waitFor(() => expect(container.textContent).toBe(''))
})
})
@@ -0,0 +1,50 @@
import { Check, Copy, ExternalLink } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { useClipboardTextCopyFeedback } from '@/hooks/use-clipboard-text-copy-feedback'
import { Button } from '../ui/button'
import { useCodexPendingLoginUrl } from './use-codex-pending-login-url'
/**
* The sign-in link of an in-flight `codex login`, so the user can finish the
* flow in a browser of their choosing instead of only the one Codex opened.
*/
export function CodexLoginLinkNotice(): React.JSX.Element | null {
const url = useCodexPendingLoginUrl()
const { copyText, status } = useClipboardTextCopyFeedback(url ?? '')
if (!url) {
return null
}
return (
<div className="space-y-2 rounded-md border border-border/70 bg-muted/30 px-3 py-2">
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.AccountsPane.codexLoginLinkPending',
'Codex opened this sign-in link in your browser. Copy it to finish signing in somewhere else — a private window, or another profile.'
)}
</p>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate rounded bg-muted px-1.5 py-1 font-mono text-[11px] text-foreground/80">
{url}
</span>
<Button variant="outline" size="xs" onClick={() => void copyText()}>
{status === 'copied' ? <Check /> : <Copy />}
{status === 'copied'
? translate('auto.components.settings.AccountsPane.codexLoginLinkCopied', 'Copied')
: translate('auto.components.settings.AccountsPane.codexLoginLinkCopy', 'Copy link')}
</Button>
<Button variant="ghost" size="xs" onClick={() => void window.api.shell.openUrl(url)}>
<ExternalLink />
{translate('auto.components.settings.AccountsPane.codexLoginLinkOpen', 'Open')}
</Button>
</div>
{status === 'failed' ? (
<p className="text-xs text-destructive">
{translate(
'auto.components.settings.AccountsPane.codexLoginLinkCopyFailed',
'Could not copy the link.'
)}
</p>
) : null}
</div>
)
}
@@ -25,7 +25,8 @@ import type {
import {
getClaudeAccountErrorDescription,
getCodexAccountErrorDescription,
isClaudeAccountCancellation
isClaudeAccountCancellation,
isCodexAccountCancellation
} from './accounts-pane-action-errors'
import { getClaudeAccountLabel } from './accounts-pane-runtime'
@@ -117,6 +118,11 @@ export function createCodexAccountActionRunner(
})
}
} catch (error) {
// Why: cancelling — from the Cancel button, or by asking for a new login
// that supersedes this one — is the user's own doing, not a failure.
if (isCodexAccountCancellation(error)) {
return
}
toast.error(
translate(
'auto.components.settings.AccountsPane.5bf8764953',
@@ -1,3 +1,5 @@
import { CODEX_LOGIN_CANCELLED_MESSAGE } from '../../../../shared/codex-auth-errors'
export function getCodexAccountErrorDescription(error: unknown): string {
const message = String((error as Error)?.message ?? error)
.replace(/^Error occurred in handler for 'codexAccounts:[^']+':\s*/i, '')
@@ -42,6 +44,13 @@ export function getClaudeAccountErrorDescription(error: unknown): string {
)
}
export function isCodexAccountCancellation(error: unknown): boolean {
return (
getCodexAccountErrorDescription(error).toLowerCase() ===
CODEX_LOGIN_CANCELLED_MESSAGE.toLowerCase()
)
}
export function isClaudeAccountCancellation(error: unknown): boolean {
return getClaudeAccountErrorDescription(error).toLowerCase() === 'claude sign-in was cancelled.'
}
@@ -1,10 +1,11 @@
import { AlertTriangle, Loader2, Plus } from 'lucide-react'
import { AlertTriangle, Loader2, Plus, X } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { selectCodexProviderAccount } from '@/runtime/runtime-provider-accounts-client'
import { Badge } from '../ui/badge'
import { Button } from '../ui/button'
import { Label } from '../ui/label'
import { OpenAIIcon } from '../status-bar/icons'
import { CodexLoginLinkNotice } from './CodexLoginLinkNotice'
import { SearchableSetting } from './SearchableSetting'
import { getAccountsCodexSearchEntries } from './accounts-search'
import { getCodexSystemDefaultSubtitle } from './accounts-pane-runtime'
@@ -152,36 +153,52 @@ export function renderCodexAccountsSection(model: AccountsPaneSectionModel): Rea
)}
</p>
</div>
<Button
variant="outline"
size="xs"
onClick={() =>
void runCodexAccountAction('adding', () =>
window.api.codexAccounts.add({
runtime: accountRuntime.runtime,
wslDistro: accountRuntime.wslDistro
})
)
}
disabled={
// Why: interactive `codex login` needs a desktop browser and
// would authenticate against this device, not the server.
isRemoteAccountScope ||
codexAction !== 'idle' ||
wslCapabilitiesLoading ||
accountRuntimeUnavailable
}
className="gap-1.5"
>
<div className="flex shrink-0 items-center gap-1.5">
<Button
variant="outline"
size="xs"
onClick={() =>
void runCodexAccountAction('adding', () =>
window.api.codexAccounts.add({
runtime: accountRuntime.runtime,
wslDistro: accountRuntime.wslDistro
})
)
}
disabled={
// Why: interactive `codex login` needs a desktop browser and
// would authenticate against this device, not the server.
isRemoteAccountScope ||
codexAction !== 'idle' ||
wslCapabilitiesLoading ||
accountRuntimeUnavailable
}
className="gap-1.5"
>
{codexAction === 'adding' ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Plus className="size-3" />
)}
{translate('auto.components.settings.AccountsPane.b0e948a4f9', 'Add Account')}
</Button>
{codexAction === 'adding' ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Plus className="size-3" />
)}
{translate('auto.components.settings.AccountsPane.b0e948a4f9', 'Add Account')}
</Button>
<Button
variant="ghost"
size="xs"
onClick={() => void window.api.codexAccounts.cancelPendingLogin()}
className="gap-1.5"
>
<X />
{translate('auto.components.settings.AccountsPane.dbb9626ed1', 'Cancel')}
</Button>
) : null}
</div>
</div>
{remoteAccountScopeNotice}
{/* Why not in a remote scope: the link belongs to a login running on
this desktop, which has nothing to do with the server named above. */}
{isRemoteAccountScope ? null : <CodexLoginLinkNotice />}
<div className="space-y-2">
<button
@@ -0,0 +1,37 @@
import { useEffect, useRef, useState } from 'react'
/**
* The sign-in link of the Codex login currently waiting on a browser, or null.
*
* Reads the current value on mount as well as subscribing: the login starts in
* the main process and keeps running while Settings is closed, so a pane that
* opens midway through one would otherwise never see the link.
*/
export function useCodexPendingLoginUrl(): string | null {
const [url, setUrl] = useState<string | null>(null)
const publishedRef = useRef(false)
useEffect(() => {
let mounted = true
publishedRef.current = false
const unsubscribe = window.api.codexAccounts.onPendingLoginUrlChanged((next) => {
publishedRef.current = true
setUrl(next)
})
void window.api.codexAccounts
.getPendingLoginUrl()
.then((current) => {
// Why: a push can land before this read resolves; the push is newer.
if (mounted && !publishedRef.current) {
setUrl(current)
}
})
.catch(() => {})
return () => {
mounted = false
unsubscribe()
}
}, [])
return url
}
+8
View File
@@ -118,6 +118,9 @@
"richMarkdown": {
"tooLarge": "File is larger than the {{limit}} rich editing limit. Showing source mode instead.",
"openAnyway": "Open anyway"
},
"fileLoad": {
"hostUnresolved": "The host couldn't find this file's workspace. It may have been removed, or the host may not know about it yet. Retry, or close this tab from the tab strip."
}
},
"githubChecks": {
@@ -6550,6 +6553,11 @@
"codexConfigSyncBlankSource": "Codex is still using the settings it last synced because {{value0}} is empty. That is expected while a synced folder finishes downloading.",
"codexConfigSyncManagedHomeUnavailable": "Orca could not read this accounts Codex files just now, so settings may not be syncing. This usually clears on its own — antivirus or a backup tool briefly locks them.",
"codexConfigSyncUnreadableSource": "Codex is still using the settings it last synced because {{value0}} could not be read. Check that file's permissions.",
"codexLoginLinkPending": "Codex opened this sign-in link in your browser. Copy it to finish signing in somewhere else — a private window, or another profile.",
"codexLoginLinkCopied": "Copied",
"codexLoginLinkCopy": "Copy link",
"codexLoginLinkOpen": "Open",
"codexLoginLinkCopyFailed": "Could not copy the link.",
"d6f1b9b6a2": "MiniMax API key is required.",
"7c5d8a4e1b": "MiniMax API key was not saved.",
"4d2c7b9e83": "MiniMax API key saved.",
@@ -27,18 +27,42 @@ export function createGrokAccountsApi(): NonNullable<Partial<PreloadApi>['grokAc
}
}
export function createAccountsApi(): never {
const empty = {
function createEmptyManagedAccountsState(): {
accounts: never[]
activeAccountId: null
activeAccountIdsByRuntime: { host: null; wsl: Record<string, string | null> }
} {
return {
accounts: [],
activeAccountId: null,
activeAccountIdsByRuntime: { host: null, wsl: {} }
}
}
export function createClaudeAccountsApi(): PreloadApi['claudeAccounts'] {
const empty = createEmptyManagedAccountsState()
return {
list: () => Promise.resolve(empty),
add: () => Promise.resolve(empty),
cancelPendingLogin: () => Promise.resolve(false),
reauthenticate: () => Promise.resolve(empty),
remove: () => Promise.resolve(empty),
select: () => Promise.resolve(empty)
}
}
export function createCodexAccountsApi(): PreloadApi['codexAccounts'] {
const empty = createEmptyManagedAccountsState()
return {
list: () => Promise.resolve(empty),
add: () => Promise.resolve(empty),
cancelPendingLogin: () => Promise.resolve(false),
// Why: the login runs on the desktop host that owns the browser, so a web
// client has no link to offer and nothing to publish changes from.
getPendingLoginUrl: () => Promise.resolve(null),
onPendingLoginUrlChanged: () => () => {},
reauthenticate: () => Promise.resolve(empty),
remove: () => Promise.resolve(empty),
select: () => Promise.resolve(empty),
// Why: launch accounts are recorded on the host that owns the PTY, which the
// web client never is — report no stale panes rather than reject the sweep.
@@ -47,5 +71,5 @@ export function createAccountsApi(): never {
// client has no recorded lane to offer and every pane falls to derivation.
listRecordedPaneLanes: () => Promise.resolve({}),
forgetStalePanes: () => Promise.resolve()
} as never
}
}
+4 -3
View File
@@ -2,7 +2,8 @@ import type { PreloadApi } from '../../../preload/api-types'
import type { StatsSummary } from '../../../shared/process-stats-types'
import { createWebE2EApi } from './preload-api/web-e2e-api'
import {
createAccountsApi,
createClaudeAccountsApi,
createCodexAccountsApi,
createGrokAccountsApi,
createMiniMaxCredentialsApi
} from './preload-api/web-agent-accounts-api'
@@ -106,8 +107,8 @@ function createWebPreloadApi(): Partial<PreloadApi> {
rateLimits: createRateLimitsApi(),
minimaxCredentials: createMiniMaxCredentialsApi(),
grokAccounts: createGrokAccountsApi(),
codexAccounts: createAccountsApi(),
claudeAccounts: createAccountsApi(),
codexAccounts: createCodexAccountsApi(),
claudeAccounts: createClaudeAccountsApi(),
cli: createCliApi(),
macosTccPrompts: createMacosTccPromptsApi(),
codexConfigSync: {
+6
View File
@@ -1,3 +1,9 @@
/** Carries the sign-in link of an in-flight `codex login` from main to every window. */
export const CODEX_PENDING_LOGIN_URL_CHANGED_CHANNEL = 'codexAccounts:pendingLoginUrlChanged'
/** The rejection a cancelled `codex login` produces; the Accounts pane reads it to keep a cancellation out of the error toast. */
export const CODEX_LOGIN_CANCELLED_MESSAGE = 'Codex sign-in was cancelled.'
const CODEX_AUTH_ERROR_PATTERNS = [
/access token could not be refreshed/i,
/authentication session could not be refreshed/i,