mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
* fix(codex): stop a transient filesystem error from logging out the active account A single unreadable read of a managed Codex home's ownership marker cleared the user's active account selection, permanently. On Windows any exclusive lock — Defender real-time scanning, a backup agent, a sync client — makes every read of that marker fail with EBUSY, and the background rate-limit poll runs every 15 minutes plus once at every app start. Root cause: the ownership gate answered two very different questions through one channel. "This home is not ours" (a successful observation that failed a trust check) and "we could not read it" both surfaced as a throw, which the caller flattened to null, which three call sites took as proof the home was untrustworthy and wrote activeCodexManagedAccountId: null. Refusing to USE an unverified home is correct. Erasing the user's account selection because a file was briefly locked is not. The gate now returns a tri-state verdict. `untrusted` comes only from a proven trust failure or a definitive ENOENT/ENOTDIR where absence is itself the verdict; every other filesystem exception is `indeterminate`. Only `untrusted` may touch persisted state. Because `null` already meant "fall through to the system default" on both the launch and poll paths, not-clearing on its own would have run a DIFFERENT account behind a UI still showing the selected one. So the refusal needed real channels rather than a sentinel: - the poll returns an explicit skip; returning null would not have skipped at all, since the fetcher maps null to ~/.codex and would have spawned a token-refreshing app-server inside the user's real credential home - pane launch throws a typed temporary-unavailability error that both PTY implementations convert into a clean refusal with a retry message, including the re-resolution after the async auth-readiness wait - automatic session resume resolves the selected home eagerly, so an unreadable account can no longer be silently replaced by another one in the ranking - config-sync status reports a distinct managed-home-unavailable stall instead of "synced", with a bounded renderer retry so it clears on its own Also fixes the ticket's second symptom. The status bar's Sign in button called a re-auth that captured the selection before login and restored it after, so re-authenticating a deselected account restored `null` — a successful login that left the account inactive, with no success toast to distinguish it from failure. It now activates the account it just signed in, but only when the pre-login selection was empty, so it cannot silently switch accounts for multi-account users, and it runs the same restart prompt an explicit switch does. No retry or grace window inside the synchronous gate: it runs on the Electron main process in a loop over accounts, so a sleep there would freeze the UI. Recovery is simply the next readable evaluation. The WSL lane has the same class of defect, including one path that deletes a credential mirror. It is pre-existing, unreachable from these host code paths, and deliberately left for its own change; the host clearing sites cannot reach a WSL account because getSelfContainedManagedHostAccount excludes them. Fixes STA-4422 * test(codex): cover pending reset home ownership
218 lines
7.4 KiB
TypeScript
218 lines
7.4 KiB
TypeScript
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { prepareLocalCommitMessageAgentEnv } from './commit-message-agent-environment'
|
|
import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership'
|
|
|
|
const originalEnv = { ...process.env }
|
|
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
|
const tempDirs: string[] = []
|
|
|
|
afterEach(() => {
|
|
if (originalPlatform) {
|
|
Object.defineProperty(process, 'platform', originalPlatform)
|
|
}
|
|
for (const key of Object.keys(process.env)) {
|
|
if (!(key in originalEnv)) {
|
|
delete process.env[key]
|
|
}
|
|
}
|
|
Object.assign(process.env, originalEnv)
|
|
while (tempDirs.length > 0) {
|
|
rmSync(tempDirs.pop()!, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
function makeHome(): string {
|
|
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
|
|
const dir = mkdtempSync(join(tmpdir(), 'orca-commit-env-'))
|
|
tempDirs.push(dir)
|
|
process.env.HOME = dir
|
|
process.env.SHELL = '/bin/zsh'
|
|
delete process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR
|
|
delete process.env.ORCA_PI_SOURCE_AGENT_DIR
|
|
return dir
|
|
}
|
|
|
|
describe('prepareLocalCommitMessageAgentEnv', () => {
|
|
it('hydrates OpenCode config dir from shell startup files for headless generation', async () => {
|
|
const home = makeHome()
|
|
delete process.env.OPENCODE_CONFIG_DIR
|
|
writeFileSync(join(home, '.zshrc'), 'export OPENCODE_CONFIG_DIR="$HOME/company/opencode"\n')
|
|
|
|
const result = await prepareLocalCommitMessageAgentEnv('opencode', undefined)
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
env: expect.objectContaining({
|
|
OPENCODE_CONFIG_DIR: `${home}/company/opencode`
|
|
})
|
|
})
|
|
})
|
|
|
|
it('prefers the original OpenCode config root over inherited PTY overlays', async () => {
|
|
process.env.OPENCODE_CONFIG_DIR = '/tmp/orca-opencode-overlay'
|
|
process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = '/Users/tester/company/opencode'
|
|
|
|
const result = await prepareLocalCommitMessageAgentEnv('opencode', undefined)
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
env: expect.objectContaining({
|
|
OPENCODE_CONFIG_DIR: '/Users/tester/company/opencode'
|
|
})
|
|
})
|
|
})
|
|
|
|
it('hydrates Pi agent dir from shell startup files for headless generation', async () => {
|
|
const home = makeHome()
|
|
delete process.env.PI_CODING_AGENT_DIR
|
|
writeFileSync(join(home, '.zshrc'), 'export PI_CODING_AGENT_DIR="$HOME/.config/pi-agent"\n')
|
|
|
|
const result = await prepareLocalCommitMessageAgentEnv('pi', undefined)
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
env: expect.objectContaining({
|
|
PI_CODING_AGENT_DIR: `${home}/.config/pi-agent`
|
|
})
|
|
})
|
|
})
|
|
|
|
it('prefers the original Pi agent root over inherited PTY overlays', async () => {
|
|
process.env.PI_CODING_AGENT_DIR = '/tmp/orca-pi-overlay'
|
|
process.env.ORCA_PI_SOURCE_AGENT_DIR = '/Users/tester/.pi/agent'
|
|
|
|
const result = await prepareLocalCommitMessageAgentEnv('pi', undefined)
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
env: expect.objectContaining({
|
|
PI_CODING_AGENT_DIR: '/Users/tester/.pi/agent'
|
|
})
|
|
})
|
|
})
|
|
|
|
it('does not synthesize env for agents without shell-scoped auth or config roots', async () => {
|
|
makeHome()
|
|
|
|
await expect(prepareLocalCommitMessageAgentEnv('cursor', undefined)).resolves.toEqual({
|
|
ok: true
|
|
})
|
|
})
|
|
|
|
it('falls back to inherited env when managed account resolvers are unavailable', async () => {
|
|
await expect(prepareLocalCommitMessageAgentEnv('codex', undefined)).resolves.toEqual({
|
|
ok: true
|
|
})
|
|
await expect(prepareLocalCommitMessageAgentEnv('claude', undefined)).resolves.toEqual({
|
|
ok: true
|
|
})
|
|
})
|
|
|
|
it('sets CODEX_HOME for host managed Codex accounts', async () => {
|
|
const result = await prepareLocalCommitMessageAgentEnv('codex', {
|
|
prepareForCodexLaunch: () =>
|
|
'C:\\Users\\tester\\AppData\\Roaming\\Orca\\codex-accounts\\a\\home'
|
|
})
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
env: expect.objectContaining({
|
|
CODEX_HOME: 'C:\\Users\\tester\\AppData\\Roaming\\Orca\\codex-accounts\\a\\home'
|
|
})
|
|
})
|
|
})
|
|
|
|
// Why (#STA-4422): launch prep throws when the managed home is unreadable.
|
|
// Falling through to an env would run the headless commit agent on the user's
|
|
// real ~/.codex while the UI shows the managed account selected.
|
|
it('fails commit generation instead of building a system-account env when the managed home is unreadable', async () => {
|
|
process.env.CODEX_HOME = '/home/me/.config/codex'
|
|
delete process.env.ORCA_CODEX_HOME
|
|
|
|
const result = await prepareLocalCommitMessageAgentEnv('codex', {
|
|
prepareForCodexLaunch: () => {
|
|
throw new ManagedCodexHomeTemporarilyUnavailableError()
|
|
}
|
|
})
|
|
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
error: 'Failed to prepare the selected agent account for commit message generation.'
|
|
})
|
|
expect(result).not.toHaveProperty('env')
|
|
})
|
|
|
|
it('strips a nested-Orca CODEX_HOME override when the launch resolves to the real home', async () => {
|
|
process.env.CODEX_HOME = '/managed/runtime/home'
|
|
process.env.ORCA_CODEX_HOME = '/managed/runtime/home'
|
|
|
|
const result = await prepareLocalCommitMessageAgentEnv('codex', {
|
|
prepareForCodexLaunch: () => null
|
|
})
|
|
|
|
expect(result.ok).toBe(true)
|
|
const env = (result as { ok: true; env?: NodeJS.ProcessEnv }).env
|
|
expect(env).toBeDefined()
|
|
expect(env?.CODEX_HOME).toBeUndefined()
|
|
expect(env?.ORCA_CODEX_HOME).toBeUndefined()
|
|
})
|
|
|
|
it('preserves a user-owned CODEX_HOME when the launch resolves to the real home', async () => {
|
|
process.env.CODEX_HOME = '/home/me/.config/codex'
|
|
delete process.env.ORCA_CODEX_HOME
|
|
|
|
const result = await prepareLocalCommitMessageAgentEnv('codex', {
|
|
prepareForCodexLaunch: () => null
|
|
})
|
|
|
|
expect(result.ok).toBe(true)
|
|
const env = (result as { ok: true; env?: NodeJS.ProcessEnv }).env
|
|
expect(env?.CODEX_HOME).toBe('/home/me/.config/codex')
|
|
})
|
|
|
|
it('does not pass WSL managed Codex homes to host-local commit generation', async () => {
|
|
process.env.CODEX_HOME = 'C:\\Users\\tester\\.codex'
|
|
|
|
const result = await prepareLocalCommitMessageAgentEnv('codex', {
|
|
prepareForCodexLaunch: () =>
|
|
'\\\\wsl.localhost\\Ubuntu\\home\\tester\\.local\\share\\orca\\codex-accounts\\a\\home'
|
|
})
|
|
|
|
expect(result).toEqual({ ok: true })
|
|
})
|
|
|
|
it('passes WSL managed Codex homes as Linux paths for WSL-local commit generation', async () => {
|
|
const result = await prepareLocalCommitMessageAgentEnv(
|
|
'codex',
|
|
{
|
|
prepareForCodexLaunch: (target) => {
|
|
expect(target).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
|
return '\\\\wsl.localhost\\Ubuntu\\home\\tester\\.codex'
|
|
}
|
|
},
|
|
{ runtime: 'wsl', wslDistro: 'Ubuntu' }
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
env: expect.objectContaining({
|
|
CODEX_HOME: '/home/tester/.codex'
|
|
})
|
|
})
|
|
})
|
|
|
|
it('does not hydrate host shell config roots for WSL-local commit generation', async () => {
|
|
process.env.OPENCODE_CONFIG_DIR = 'C:\\Users\\tester\\opencode'
|
|
|
|
const result = await prepareLocalCommitMessageAgentEnv('opencode', undefined, {
|
|
runtime: 'wsl',
|
|
wslDistro: 'Ubuntu'
|
|
})
|
|
|
|
expect(result).toEqual({ ok: true })
|
|
})
|
|
})
|