mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 10:37:35 +00:00
* Revert "Revert Claude per-account folders (#24434, #26405) before release (#26638)"
This reverts commit 9ca9b4e36b.
* Move the terminal daemon protocol to 44 for the re-landed claude function
v43 daemons (the revert) lack the claude account function, so new terminals must not reuse them. v42 and v43 stay attachable.
* Move the local build compatibility contract to daemon protocol 44
* Point the old-terminal test at daemon protocol 44
* Claude accounts re-land: never sign anyone out, keep proxies working, accounts mirror ~/.claude (#26769)
* Run a Claude account on System default until it has its own login
An account saved before per-account folders has no login in its folder. While System default (~/.claude, or the user's own CLAUDE_CONFIG_DIR) is signed in to the same email, the router sends every launch there and writes that choice into the pointer file, so nobody is logged out by the update. Once the account signs in to its own folder, the next launch uses it. The WSL router follows the same rule with the guest's ~/.claude.
* Refresh each Claude account folder from ~/.claude before every launch
~/.claude (or the user's own CLAUDE_CONFIG_DIR) is now the master copy. Each account folder gets its whole settings.json, including the proxy address and its key, and every .claude.json key except the login, Claude's account caches and install ids, so first-run questions are not asked again. Every other top-level entry is linked (folders) or copied (files), except Claude's per-folder daemon, jobs, live sessions, login files and throwaways. sessions is no longer shared, and an earlier link is undone.
The refresh runs before every Claude Orca starts, for each new terminal and on account switch. Orca never writes back to ~/.claude. The ledger that tracked selective sharing is gone.
* Route chat auth and unselected-account usage through the Claude router
Chats decided whether to drop inherited Anthropic auth from the selection alone, so an account running on System default lost the user's own key. They now ask the router, as terminals, AI commit messages, automations and usage already do. Usage for an unselected account is read where its launches would run. A test reads every entry point through the fallback and fails if a launch path outside the router reads the Claude selection or builds an account folder. The router's two launch errors move to their own file to keep it under the line limit.
* Show Claude account sign-in prompts only when an account cannot run
With the fallback, an account System default is signed in to already works, so:
- The one-time "Finish setting up your Claude accounts" toast is gone.
- Accounts System default covers no longer need a sign-in: no Sign in in the status bar menu or Settings, and they can be selected.
- The old-terminal banner shows only when claude in that terminal would run a different account than the selected one, and asks again after a switch. Its wording is unchanged.
The retired toast's saved flag stays in the paired-client schema so an older client's write is still accepted. The email comparison moves beside the login reader to keep the router under the line limit.
* Keep the user's shell Anthropic auth on Claude account launches
A signed-in account's launches used to drop ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN and CLAUDE_CODE_OAUTH_TOKEN from the inherited environment while ANTHROPIC_BASE_URL passed through, so a proxy set in the shell got the subscription login and answered 401. Host launches on an account now keep the shell's auth with its address, as System default does. Chats still name the account kind in a failed sign-in, now from the router rather than from whether auth was dropped.
* Share from ~/.claude only what Claude itself would seed into a new folder
The deny list missed much of Claude's runtime state, so tokens, locks and live files such as .session_ingress_token, server-sessions.json and remote-control were copied or linked over each account's own every launch. The list now mirrors the one Claude uses when it seeds a fresh config folder from ~/.claude: its runtime entries, every hidden entry, daemon files and agent memory. teams, ide, downloads and scratch are on it, so they stay per folder.
* Merge trusted folders and MCP servers into an account instead of replacing them
Each refresh replaced the account's projects and mcpServers with ~/.claude's, so folder trust and allowed tools recorded only in the account were lost, and a refresh landing between Orca's folder-trust write and Claude's start brought the trust dialog back. Both are now merged per folder path or server name: ~/.claude wins for entries it has, the account keeps the rest, and a folder trusted in the account stays trusted. Both writers take Claude's lock on the file, so the order no longer matters.
* Require the same organization before System default stands in for an account
The fallback compared emails only, so one email saved for two organizations made both accounts run on whichever organization System default was signed in to. When the saved account and System default's login both name an organization, it must now match too, on the host and in WSL, and the old-terminal banner uses the same comparison. The saved-account lookup the three checks repeated is now one helper.
* Treat v42 terminals as having the claude account function
v42 daemons shipped the same claude function and pointer path as v44, so claude in those terminals already follows the selected account. The old-terminal banner now shows only for daemons before v42 and for v43, the revert, which dropped the function.
* Fail the routing test when a new module resolves the claude binary
The census only caught code that read the Claude selection, not a new launcher that runs claude without asking the router. A second check now lists every module that resolves the claude binary or loads the Agent SDK, each with why it is routed, and fails on any new one, or on a listed one that no longer matches.
* Skip the account refresh on launches that run on System default
A launch that falls back to System default never uses the account folder, so starting a setup for it on every launch was wasted work. Account switches still set the folder up for a later sign-in.
* Give a terminal opened before the login shell's env only the account pointer
At startup, a pane could be routed before the login shell reported the user's own CLAUDE_CONFIG_DIR, so the fallback compared against ~/.claude.json instead. Until that env arrives a pane gets only the pointer, and the pointer is rewritten once it does, so claude typed there reads the right folder. The recent claude --version answer moves beside the version probe to keep the router under the line limit.
* Drop the chat-only Anthropic auth stripping and override refusal
Chats no longer drop the shell's Anthropic auth on any account, so the chat launch's override refusal and the policy's stripAuthEnv could only be reached from test fixtures. The structured auth policy is now just which login a failed sign-in names. Terminals keep their refusal, which still guards WSL launches; the failure reason stays in the shared vocabulary for older peers.
* Publish a new account state file whole
The first .claude.json write went straight to the target, so a crash mid-write could leave Claude a truncated state file. It is now written to a staged file and linked into place without replacing one Claude created meanwhile.
* Read an unselected WSL account's usage where the WSL router would run it
Usage for an unselected WSL account read its saved folder directly, so an account the guest's ~/.claude covers showed no usage, and the read bypassed the router. It now asks the WSL router, after the same check that the distro is already running.
* Keep the shell's Anthropic auth when the claude function runs an account
The claude shell function (POSIX, fish and PowerShell) unset ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, AWS_BEARER_TOKEN_BEDROCK and auth-like ANTHROPIC_CUSTOM_HEADERS before running Claude in an account folder, so a proxy set in the shell lost its key while keeping its address. It now only sets CLAUDE_CONFIG_DIR and Orca's marker, as on System default. Protocol 44 is unreleased, so no new bump; the shell-wrapper snapshots are regenerated.
* Never drop the user's Anthropic auth from a Claude launch
Orca's launches stripped ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, AWS_BEARER_TOKEN_BEDROCK and auth-like ANTHROPIC_CUSTOM_HEADERS for WSL terminals, terminal splits and through the preparation's stripAuthEnv flag, and refused a launch whose agent env set them. If the user's shell overrides the login, that is their setup: every launch now keeps it, on accounts, System default and WSL alike. The flag, the strip paths and the override refusal are gone; the refusal's reason stays in the shared vocabulary so older peers still decode it.
Orca's own process env and the claude --version probe still drop these variables: they are Orca's, not the user's launch.
* Leave out of an account's state everything Claude resets when it signs out
The .claude.json copy left out the login and keys matching cache patterns, but still carried account state such as additionalModelOptionsAnsweredAt, artifactRosterDenied, lastSeenOrgDefaultUpdatedAt and the subscription notices. The copy now leaves out the exact keys Claude resets on logout, beside the install ids, Console API key and first-token date; the cache patterns stay for account caches logout does not reset. Onboarding is still copied.
* Judge an old terminal's Claude banner by the pane's own runtime
The banner compared every old pane with the host selection and showed whenever only a WSL account was selected, so old host panes showed it with nothing to say and old WSL panes were judged by the host's account. The pane now names its runtime: a host pane is compared with the host selection, a WSL pane with its distro's selection through the WSL router, and a pane whose runtime is unknown, or with no account selected for it, stays hidden.
* Leave an account's settings file alone when only Orca's hooks differ from the default home's
* Reread a WSL account's Claude state only when the file changes
* Move tests merged from main onto the account-only Claude auth policy
630 lines
22 KiB
TypeScript
630 lines
22 KiB
TypeScript
import path from 'node:path'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
handlers,
|
|
store,
|
|
withPlatform,
|
|
WORKSPACE_DIR,
|
|
WORKTREE_FEATURE_PATH,
|
|
getStagedCommitContextMock,
|
|
resolveCommitMessageSettingsMock,
|
|
generateCommitMessageFromContextMock,
|
|
cancelGenerateCommitMessageLocalMock,
|
|
cancelGeneratePullRequestFieldsLocalMock,
|
|
getSshGitProviderMock,
|
|
resetFilesystemIpcMocks
|
|
} from './filesystem-test-harness'
|
|
|
|
vi.mock('electron', async () => (await import('./filesystem-test-harness')).electronMock)
|
|
vi.mock('fs/promises', async () => (await import('./filesystem-test-harness')).fsPromisesMock)
|
|
vi.mock(
|
|
'../wsl-unc-delete',
|
|
async () => (await import('./filesystem-test-harness')).wslUncDeleteMock
|
|
)
|
|
vi.mock(
|
|
'../crash-reporting/crash-breadcrumb-store',
|
|
async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock
|
|
)
|
|
vi.mock(
|
|
'../local-downloaded-folder-promotion',
|
|
async () => (await import('./filesystem-test-harness')).folderPromotionMock
|
|
)
|
|
vi.mock(
|
|
'../git/status',
|
|
async () => (await import('./filesystem-test-harness')).gitStatusModuleMock
|
|
)
|
|
vi.mock(
|
|
'../git/check-ignored-paths',
|
|
async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock
|
|
)
|
|
vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock)
|
|
vi.mock(
|
|
'../providers/ssh-filesystem-dispatch',
|
|
async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock
|
|
)
|
|
vi.mock(
|
|
'../providers/ssh-git-dispatch',
|
|
async () => (await import('./filesystem-test-harness')).sshGitDispatchMock
|
|
)
|
|
vi.mock(
|
|
'../text-generation/commit-message-text-generation',
|
|
async () => (await import('./filesystem-test-harness')).textGenerationModuleMock
|
|
)
|
|
vi.mock(
|
|
'../text-generation/pull-request-context',
|
|
async () => (await import('./filesystem-test-harness')).pullRequestContextMock
|
|
)
|
|
vi.mock(
|
|
'../source-control/pull-request-template',
|
|
async () => (await import('./filesystem-test-harness')).pullRequestTemplateMock
|
|
)
|
|
vi.mock(
|
|
'../source-control/pull-request-linked-issue',
|
|
async () => (await import('./filesystem-test-harness')).pullRequestLinkedIssueMock
|
|
)
|
|
|
|
import { registerFilesystemHandlers } from './filesystem'
|
|
import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache'
|
|
|
|
describe('registerFilesystemHandlers', () => {
|
|
beforeEach(() => {
|
|
resetFilesystemIpcMocks()
|
|
// Reset module-level auth cache so each test starts with a fresh dirty
|
|
// flag — prevents stale worktree data from a prior test's cache rebuild.
|
|
invalidateAuthorizedRootsCache()
|
|
})
|
|
|
|
it('generates a local commit message from main-process staged context', async () => {
|
|
const context = {
|
|
branch: 'feature/ai',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
}
|
|
const params = { agentId: 'codex', model: 'gpt-5.4-mini', thinkingLevel: 'low' }
|
|
resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params })
|
|
getStagedCommitContextMock.mockResolvedValue(context)
|
|
generateCommitMessageFromContextMock.mockResolvedValue({
|
|
success: true,
|
|
message: 'Update README'
|
|
})
|
|
|
|
registerFilesystemHandlers(store as never)
|
|
|
|
await expect(
|
|
handlers.get('git:generateCommitMessage')!(null, {
|
|
worktreePath: WORKTREE_FEATURE_PATH
|
|
})
|
|
).resolves.toEqual({ success: true, message: 'Update README' })
|
|
|
|
expect(getStagedCommitContextMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {
|
|
admissionTier: 'interactive'
|
|
})
|
|
expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(context, params, {
|
|
kind: 'local',
|
|
cwd: WORKTREE_FEATURE_PATH
|
|
})
|
|
})
|
|
|
|
it('uses one-shot resolved params for local commit message generation', async () => {
|
|
const context = {
|
|
branch: 'feature/ai',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
}
|
|
const sourceControlAiResolvedParams = {
|
|
agentId: 'codex' as const,
|
|
model: 'gpt-5.5',
|
|
thinkingLevel: 'high',
|
|
customPrompt: 'Use Conventional Commits.'
|
|
}
|
|
getStagedCommitContextMock.mockResolvedValue(context)
|
|
generateCommitMessageFromContextMock.mockResolvedValue({
|
|
success: true,
|
|
message: 'feat: update readme'
|
|
})
|
|
|
|
registerFilesystemHandlers(store as never)
|
|
|
|
await expect(
|
|
handlers.get('git:generateCommitMessage')!(null, {
|
|
worktreePath: WORKTREE_FEATURE_PATH,
|
|
sourceControlAiResolvedParams
|
|
})
|
|
).resolves.toEqual({ success: true, message: 'feat: update readme' })
|
|
|
|
expect(resolveCommitMessageSettingsMock).not.toHaveBeenCalled()
|
|
expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(
|
|
context,
|
|
sourceControlAiResolvedParams,
|
|
{
|
|
kind: 'local',
|
|
cwd: WORKTREE_FEATURE_PATH
|
|
}
|
|
)
|
|
})
|
|
|
|
it('prepares the selected Codex account home before local generation', async () => {
|
|
const context = {
|
|
branch: 'feature/ai',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
}
|
|
const params = { agentId: 'codex', model: 'gpt-5.4-mini', thinkingLevel: 'low' }
|
|
resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params })
|
|
getStagedCommitContextMock.mockResolvedValue(context)
|
|
generateCommitMessageFromContextMock.mockResolvedValue({
|
|
success: true,
|
|
message: 'Update README'
|
|
})
|
|
|
|
registerFilesystemHandlers(store as never, {
|
|
prepareForCodexLaunch: () => '/managed/codex-home'
|
|
})
|
|
|
|
await handlers.get('git:generateCommitMessage')!(null, {
|
|
worktreePath: WORKTREE_FEATURE_PATH
|
|
})
|
|
|
|
expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(
|
|
context,
|
|
params,
|
|
expect.objectContaining({
|
|
kind: 'local',
|
|
cwd: WORKTREE_FEATURE_PATH,
|
|
env: expect.objectContaining({ CODEX_HOME: '/managed/codex-home' })
|
|
})
|
|
)
|
|
})
|
|
|
|
it('routes local WSL project commit-message generation through the project runtime target', async () => {
|
|
await withPlatform('win32', async () => {
|
|
const context = {
|
|
branch: 'feature/ai',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
}
|
|
const params = { agentId: 'codex', model: 'gpt-5.4-mini', thinkingLevel: 'low' }
|
|
const prepareForCodexLaunch = vi.fn(() => '\\\\wsl.localhost\\Ubuntu\\home\\tester\\.codex')
|
|
resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params })
|
|
getStagedCommitContextMock.mockResolvedValue(context)
|
|
generateCommitMessageFromContextMock.mockResolvedValue({
|
|
success: true,
|
|
message: 'Update README'
|
|
})
|
|
const wslStore = {
|
|
...store,
|
|
getRepos: () => [
|
|
{
|
|
id: 'repo-1',
|
|
path: WORKTREE_FEATURE_PATH,
|
|
displayName: 'repo',
|
|
badgeColor: '#000',
|
|
addedAt: 0
|
|
}
|
|
],
|
|
getProjects: () => [
|
|
{
|
|
id: 'project-1',
|
|
sourceRepoIds: ['repo-1'],
|
|
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
|
|
}
|
|
],
|
|
getSettings: () => ({
|
|
workspaceDir: WORKSPACE_DIR,
|
|
localWindowsRuntimeDefault: { kind: 'windows-host' }
|
|
})
|
|
}
|
|
|
|
registerFilesystemHandlers(wslStore as never, { prepareForCodexLaunch })
|
|
|
|
await handlers.get('git:generateCommitMessage')!(null, {
|
|
worktreePath: WORKTREE_FEATURE_PATH
|
|
})
|
|
|
|
expect(getStagedCommitContextMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {
|
|
admissionTier: 'interactive',
|
|
wslDistro: 'Ubuntu'
|
|
})
|
|
expect(prepareForCodexLaunch).toHaveBeenCalledWith({
|
|
runtime: 'wsl',
|
|
wslDistro: 'Ubuntu'
|
|
})
|
|
expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(
|
|
context,
|
|
params,
|
|
expect.objectContaining({
|
|
kind: 'local',
|
|
cwd: WORKTREE_FEATURE_PATH,
|
|
wslDistro: 'Ubuntu',
|
|
env: expect.objectContaining({ CODEX_HOME: '/home/tester/.codex' })
|
|
})
|
|
)
|
|
})
|
|
})
|
|
|
|
it('enriches the local commit context with a validated worktree linked issue', async () => {
|
|
const context = {
|
|
branch: 'feature/ai',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
}
|
|
const params = { agentId: 'codex', model: 'gpt-5.4-mini' }
|
|
const worktreeId = `repo-1::${WORKTREE_FEATURE_PATH}`
|
|
resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params })
|
|
getStagedCommitContextMock.mockResolvedValue(context)
|
|
generateCommitMessageFromContextMock.mockResolvedValue({ success: true, message: 'Update' })
|
|
const linkedStore = {
|
|
...store,
|
|
getWorktreeMeta: (id: string) => (id === worktreeId ? { linkedIssue: 123 } : undefined)
|
|
}
|
|
|
|
registerFilesystemHandlers(linkedStore as never)
|
|
|
|
await handlers.get('git:generateCommitMessage')!(null, {
|
|
worktreePath: WORKTREE_FEATURE_PATH,
|
|
worktreeId
|
|
})
|
|
|
|
expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(
|
|
{ ...context, linkedIssue: 123 },
|
|
params,
|
|
expect.objectContaining({ kind: 'local' })
|
|
)
|
|
})
|
|
|
|
// Why: folder-repo instances keep `::workspace:<uuid>` on the meta key while the
|
|
// request path is the stripped cwd. A strip-before-lookup "cleanup" would still
|
|
// pass plain-id tests and silently lose enrichment on second workspaces.
|
|
it('enriches local commit context when the worktree id carries a folder-repo workspace suffix', async () => {
|
|
const context = {
|
|
branch: 'feature/ai',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
}
|
|
const params = { agentId: 'codex', model: 'gpt-5.4-mini' }
|
|
const instanceId = `repo-1::${WORKTREE_FEATURE_PATH}::workspace:${'0'.repeat(8)}-0000-0000-0000-${'0'.repeat(12)}`
|
|
resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params })
|
|
getStagedCommitContextMock.mockResolvedValue(context)
|
|
generateCommitMessageFromContextMock.mockResolvedValue({ success: true, message: 'Update' })
|
|
const getWorktreeMeta = vi.fn((id: string) =>
|
|
id === instanceId ? { linkedIssue: 9 } : undefined
|
|
)
|
|
|
|
registerFilesystemHandlers({ ...store, getWorktreeMeta } as never)
|
|
|
|
await handlers.get('git:generateCommitMessage')!(null, {
|
|
worktreePath: WORKTREE_FEATURE_PATH,
|
|
worktreeId: instanceId
|
|
})
|
|
|
|
expect(getWorktreeMeta).toHaveBeenCalledWith(instanceId)
|
|
expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(
|
|
{ ...context, linkedIssue: 9 },
|
|
params,
|
|
expect.objectContaining({ kind: 'local' })
|
|
)
|
|
})
|
|
|
|
// Why: the renderer derives worktreePath from worktreeId, so a mismatched pair
|
|
// models an independent caller (relay/CLI/future), not a stale renderer context.
|
|
it('ignores an independently supplied id that does not own the requested worktree path', async () => {
|
|
const context = {
|
|
branch: 'feature/ai',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
}
|
|
const params = { agentId: 'codex', model: 'gpt-5.4-mini' }
|
|
const getWorktreeMeta = vi.fn(() => ({ linkedIssue: 123 }))
|
|
resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params })
|
|
getStagedCommitContextMock.mockResolvedValue(context)
|
|
generateCommitMessageFromContextMock.mockResolvedValue({ success: true, message: 'Update' })
|
|
|
|
registerFilesystemHandlers({ ...store, getWorktreeMeta } as never)
|
|
|
|
await handlers.get('git:generateCommitMessage')!(null, {
|
|
worktreePath: WORKTREE_FEATURE_PATH,
|
|
worktreeId: `repo-1::${path.resolve('/workspace/repo-other')}`
|
|
})
|
|
|
|
expect(getWorktreeMeta).not.toHaveBeenCalled()
|
|
// Why: without this the assertion below passes vacuously on an early return.
|
|
expect(generateCommitMessageFromContextMock.mock.calls).toHaveLength(1)
|
|
expect(generateCommitMessageFromContextMock.mock.calls[0]?.[0]).not.toHaveProperty(
|
|
'linkedIssue'
|
|
)
|
|
})
|
|
|
|
it('enriches the SSH commit context from host meta using the remote path', async () => {
|
|
const context = { branch: 'main', stagedSummary: 'A\tremote.txt', stagedPatch: '+remote' }
|
|
const params = { agentId: 'custom', model: '', customAgentCommand: 'agent' }
|
|
const worktreeId = 'repo-1::/remote/repo'
|
|
resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params })
|
|
getSshGitProviderMock.mockReturnValue({
|
|
getStagedCommitContext: vi.fn().mockResolvedValue(context),
|
|
executeCommitMessagePlan: vi.fn()
|
|
})
|
|
generateCommitMessageFromContextMock.mockResolvedValue({ success: true, message: 'Add file' })
|
|
const linkedStore = {
|
|
...store,
|
|
getWorktreeMeta: (id: string) => (id === worktreeId ? { linkedIssue: 77 } : undefined)
|
|
}
|
|
|
|
registerFilesystemHandlers(linkedStore as never)
|
|
|
|
await handlers.get('git:generateCommitMessage')!(null, {
|
|
worktreePath: '/remote/repo',
|
|
worktreeId,
|
|
connectionId: 'conn-1'
|
|
})
|
|
|
|
expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(
|
|
{ ...context, linkedIssue: 77 },
|
|
params,
|
|
expect.objectContaining({ kind: 'remote' })
|
|
)
|
|
})
|
|
|
|
it('returns a sanitized error when local agent account preparation fails', async () => {
|
|
const context = {
|
|
branch: 'feature/ai',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
}
|
|
const params = { agentId: 'codex', model: 'gpt-5.4-mini', thinkingLevel: 'low' }
|
|
resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params })
|
|
getStagedCommitContextMock.mockResolvedValue(context)
|
|
|
|
registerFilesystemHandlers(store as never, {
|
|
prepareForCodexLaunch: () => {
|
|
throw new Error('failed to read /Users/alice/.codex/auth.json')
|
|
}
|
|
})
|
|
|
|
await expect(
|
|
handlers.get('git:generateCommitMessage')!(null, {
|
|
worktreePath: WORKTREE_FEATURE_PATH
|
|
})
|
|
).resolves.toEqual({
|
|
success: false,
|
|
error: 'Failed to prepare the selected agent account for commit message generation.'
|
|
})
|
|
expect(generateCommitMessageFromContextMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('prepares the selected Claude auth environment before local generation', async () => {
|
|
const previousAnthropicApiKey = process.env.ANTHROPIC_API_KEY
|
|
process.env.ANTHROPIC_API_KEY = 'shell-proxy-key'
|
|
const context = {
|
|
branch: 'feature/ai',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
}
|
|
const params = { agentId: 'claude', model: 'haiku' }
|
|
resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params })
|
|
getStagedCommitContextMock.mockResolvedValue(context)
|
|
generateCommitMessageFromContextMock.mockResolvedValue({
|
|
success: true,
|
|
message: 'Update README'
|
|
})
|
|
|
|
try {
|
|
registerFilesystemHandlers(store as never, {
|
|
prepareForClaudeLaunch: async () => ({
|
|
configDir: '/managed/claude',
|
|
envPatch: { CLAUDE_CONFIG_DIR: '/managed/claude' },
|
|
provenance: 'managed:account-1'
|
|
})
|
|
})
|
|
|
|
await handlers.get('git:generateCommitMessage')!(null, {
|
|
worktreePath: WORKTREE_FEATURE_PATH
|
|
})
|
|
|
|
const target = generateCommitMessageFromContextMock.mock.calls[0]?.[2] as
|
|
| { env?: NodeJS.ProcessEnv }
|
|
| undefined
|
|
expect(target?.env).toEqual(
|
|
expect.objectContaining({
|
|
CLAUDE_CONFIG_DIR: '/managed/claude'
|
|
})
|
|
)
|
|
// The shell's own key stays with the account, as on System default.
|
|
expect(target?.env?.ANTHROPIC_API_KEY).toBe('shell-proxy-key')
|
|
} finally {
|
|
if (previousAnthropicApiKey === undefined) {
|
|
delete process.env.ANTHROPIC_API_KEY
|
|
} else {
|
|
process.env.ANTHROPIC_API_KEY = previousAnthropicApiKey
|
|
}
|
|
}
|
|
})
|
|
|
|
it('starts local generation from the login-shell environment', async () => {
|
|
const previousBaseUrl = process.env.ANTHROPIC_BASE_URL
|
|
delete process.env.ANTHROPIC_BASE_URL
|
|
const context = {
|
|
branch: 'feature/ai',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
}
|
|
const params = { agentId: 'claude', model: 'haiku' }
|
|
resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params })
|
|
getStagedCommitContextMock.mockResolvedValue(context)
|
|
generateCommitMessageFromContextMock.mockResolvedValue({
|
|
success: true,
|
|
message: 'Update README'
|
|
})
|
|
|
|
try {
|
|
registerFilesystemHandlers(store as never, {
|
|
resolveBaseEnvironment: async () => ({ ANTHROPIC_BASE_URL: 'https://proxy.example' }),
|
|
prepareForClaudeLaunch: async () => ({
|
|
configDir: '/home/me/.claude',
|
|
envPatch: {},
|
|
provenance: 'system'
|
|
})
|
|
})
|
|
|
|
await handlers.get('git:generateCommitMessage')!(null, {
|
|
worktreePath: WORKTREE_FEATURE_PATH
|
|
})
|
|
|
|
expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(
|
|
context,
|
|
params,
|
|
expect.objectContaining({
|
|
kind: 'local',
|
|
env: { ANTHROPIC_BASE_URL: 'https://proxy.example' }
|
|
})
|
|
)
|
|
} finally {
|
|
if (previousBaseUrl !== undefined) {
|
|
process.env.ANTHROPIC_BASE_URL = previousBaseUrl
|
|
}
|
|
}
|
|
})
|
|
|
|
it('generates an SSH commit message using remote staged context and relay execution', async () => {
|
|
const context = {
|
|
branch: 'main',
|
|
stagedSummary: 'A\tremote.txt',
|
|
stagedPatch: '+remote'
|
|
}
|
|
const params = { agentId: 'custom', model: '', customAgentCommand: 'agent' }
|
|
const executeCommitMessagePlan = vi.fn()
|
|
const prepareForCodexLaunch = vi.fn(() => '/managed/codex-home')
|
|
const prepareForClaudeLaunch = vi.fn()
|
|
resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params })
|
|
getSshGitProviderMock.mockReturnValue({
|
|
getStagedCommitContext: vi.fn().mockResolvedValue(context),
|
|
executeCommitMessagePlan
|
|
})
|
|
generateCommitMessageFromContextMock.mockResolvedValue({
|
|
success: true,
|
|
message: 'Add remote file'
|
|
})
|
|
|
|
const resolveBaseEnvironment = vi.fn(async () => ({}))
|
|
registerFilesystemHandlers(store as never, {
|
|
prepareForCodexLaunch,
|
|
prepareForClaudeLaunch,
|
|
resolveBaseEnvironment
|
|
})
|
|
|
|
await expect(
|
|
handlers.get('git:generateCommitMessage')!(null, {
|
|
worktreePath: '/remote/repo',
|
|
connectionId: 'conn-1'
|
|
})
|
|
).resolves.toEqual({ success: true, message: 'Add remote file' })
|
|
|
|
expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(
|
|
context,
|
|
params,
|
|
expect.objectContaining({
|
|
kind: 'remote',
|
|
cwd: '/remote/repo',
|
|
missingBinaryLocation: 'remote PATH'
|
|
})
|
|
)
|
|
const target = generateCommitMessageFromContextMock.mock.calls[0]?.[2]
|
|
await target.execute(
|
|
{ binary: 'agent', args: [], stdinPayload: null, label: 'agent' },
|
|
'/cwd',
|
|
1,
|
|
'commit-message'
|
|
)
|
|
expect(executeCommitMessagePlan).toHaveBeenCalledWith(
|
|
{ binary: 'agent', args: [], stdinPayload: null, label: 'agent' },
|
|
'/cwd',
|
|
1,
|
|
'commit-message'
|
|
)
|
|
expect(prepareForCodexLaunch).not.toHaveBeenCalled()
|
|
expect(prepareForClaudeLaunch).not.toHaveBeenCalled()
|
|
expect(resolveBaseEnvironment).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('routes SSH generation cancellations to separate provider operations', async () => {
|
|
const cancelGenerateCommitMessage = vi.fn().mockResolvedValue(undefined)
|
|
getSshGitProviderMock.mockReturnValue({ cancelGenerateCommitMessage })
|
|
|
|
registerFilesystemHandlers(store as never)
|
|
|
|
await handlers.get('git:cancelGenerateCommitMessage')!(null, {
|
|
worktreePath: '/remote/repo',
|
|
connectionId: 'conn-1'
|
|
})
|
|
await handlers.get('git:cancelGeneratePullRequestFields')!(null, {
|
|
worktreePath: '/remote/repo',
|
|
connectionId: 'conn-1'
|
|
})
|
|
|
|
expect(cancelGenerateCommitMessage).toHaveBeenNthCalledWith(1, '/remote/repo', 'commit-message')
|
|
expect(cancelGenerateCommitMessage).toHaveBeenNthCalledWith(
|
|
2,
|
|
'/remote/repo',
|
|
'pull-request-fields'
|
|
)
|
|
expect(cancelGenerateCommitMessageLocalMock).not.toHaveBeenCalled()
|
|
expect(cancelGeneratePullRequestFieldsLocalMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not call the generator when no staged changes exist', async () => {
|
|
resolveCommitMessageSettingsMock.mockReturnValue({
|
|
ok: true,
|
|
params: { agentId: 'codex', model: 'gpt-5.4-mini' }
|
|
})
|
|
getStagedCommitContextMock.mockResolvedValue(null)
|
|
|
|
registerFilesystemHandlers(store as never)
|
|
|
|
await expect(
|
|
handlers.get('git:generateCommitMessage')!(null, {
|
|
worktreePath: WORKTREE_FEATURE_PATH
|
|
})
|
|
).resolves.toEqual({ success: false, error: 'No staged changes to summarize.' })
|
|
|
|
expect(generateCommitMessageFromContextMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('sanitizes local staged-context read failures before returning to the renderer', async () => {
|
|
resolveCommitMessageSettingsMock.mockReturnValue({
|
|
ok: true,
|
|
params: { agentId: 'codex', model: 'gpt-5.4-mini' }
|
|
})
|
|
getStagedCommitContextMock.mockRejectedValue(new Error('fatal: /secret/repo failed'))
|
|
|
|
registerFilesystemHandlers(store as never)
|
|
|
|
await expect(
|
|
handlers.get('git:generateCommitMessage')!(null, {
|
|
worktreePath: WORKTREE_FEATURE_PATH
|
|
})
|
|
).resolves.toEqual({ success: false, error: 'Failed to read staged changes.' })
|
|
|
|
expect(generateCommitMessageFromContextMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('sanitizes SSH staged-context read failures before returning to the renderer', async () => {
|
|
resolveCommitMessageSettingsMock.mockReturnValue({
|
|
ok: true,
|
|
params: { agentId: 'codex', model: 'gpt-5.4-mini' }
|
|
})
|
|
getSshGitProviderMock.mockReturnValue({
|
|
getStagedCommitContext: vi.fn().mockRejectedValue(new Error('fatal: /remote/secret failed'))
|
|
})
|
|
|
|
registerFilesystemHandlers(store as never)
|
|
|
|
await expect(
|
|
handlers.get('git:generateCommitMessage')!(null, {
|
|
worktreePath: '/remote/repo',
|
|
connectionId: 'conn-1'
|
|
})
|
|
).resolves.toEqual({ success: false, error: 'Failed to read staged changes.' })
|
|
|
|
expect(generateCommitMessageFromContextMock).not.toHaveBeenCalled()
|
|
})
|
|
})
|