mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/floating-agent-launch
This commit is contained in:
+2
-2
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
`[1mnavigate to this URL to authenticate:[0m\n\n[4m${AUTH_URL}[0m\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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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().
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 account’s 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user