From 817d7a2dff077d015c36b20965160619dbd15854 Mon Sep 17 00:00:00 2001 From: moe Date: Sun, 6 Sep 2026 00:14:35 -0400 Subject: [PATCH] feat: add isolated provider accounts and safe session handoff --- apps/cli/src/agent/AGENTS.md | 4 + .../account-profile-authentication.test.ts | 281 ++++++++ apps/cli/src/agent/account-profiles.test.ts | 172 +++++ apps/cli/src/agent/account-profiles.ts | 284 ++++++++ apps/cli/src/agent/acp-authentication.test.ts | 5 +- apps/cli/src/agent/acp-authentication.ts | 251 ++++++- apps/cli/src/agent/acp-binary-manager.test.ts | 6 +- apps/cli/src/agent/acp-runner.ts | 41 +- apps/cli/src/agent/claude-env-conflict.ts | 14 + apps/cli/src/agent/title-generator.ts | 2 + apps/cli/src/claude-acp-entry.ts | 11 + .../code-collab-v2-diff-evidence.test.ts | 56 +- .../code-collab-v2-service.test.ts | 51 +- .../code-collab/file-index-scan-core.test.ts | 38 ++ .../lib/code-collab/file-index-scan-core.ts | 9 +- .../workspace-watch-worker-core.test.ts | 21 +- apps/cli/src/lib/loro/doc.ts | 1 + .../src/lib/loro/sqlite-repo-store.test.ts | 11 +- apps/cli/src/lib/message-handler.ts | 210 +++++- .../src/lib/pr-poller/pr-poller-state.test.ts | 2 + apps/cli/src/lib/pr-poller/pr-poller-state.ts | 22 +- .../src/lib/session-file-attachments.test.ts | 4 +- .../src/lib/session-file-blob-store.test.ts | 7 +- apps/cli/src/mcp/lody-mcp-server.test.ts | 5 +- .../src/orchestration/operation-store.test.ts | 28 +- apps/cli/src/session/AGENTS.md | 15 + .../session/session-account-handoff.test.ts | 295 +++++++++ .../src/session/session-account-handoff.ts | 157 +++++ .../session-edit-and-resend-service.ts | 1 + .../src/session/session-execution-service.ts | 283 +++++++- .../session-fork-operation-store.test.ts | 21 +- apps/cli/src/session/session-fork-service.ts | 4 + apps/cli/src/session/session-manager.test.ts | 29 + apps/cli/src/session/session-manager.ts | 69 +- apps/cli/src/session/session.ts | 62 +- apps/cli/src/session/terminal-manager.ts | 35 +- apps/cli/src/session/types.ts | 2 + .../worktree/speculative-worktree.test.ts | 1 + .../src/session/worktree/worktree-manager.ts | 16 +- apps/cli/src/utils/index.test.ts | 5 +- .../tests/account-profiles-platform-check.ts | 58 ++ apps/cli/tests/agent-setting.test.ts | 22 +- apps/cli/tests/gh-shim-script.test.ts | 28 +- .../history-session-catalog-client.test.ts | 6 +- apps/cli/tests/lody-mcp-server.test.ts | 12 +- apps/cli/tests/login-shell-env.test.ts | 9 + .../message-handler-image-upload.test.ts | 88 ++- ...ssage-handler-machine-registration.test.ts | 1 + apps/cli/tests/session-env.test.ts | 45 ++ .../tests/session-execution-service.test.ts | 619 +++++++++++++++--- apps/cli/tests/terminal-manager.test.ts | 31 + .../cli/tests/worktree-manager.create.test.ts | 7 +- .../cli/tests/worktree-manager.remove.test.ts | 1 + locales/en.json | 14 + locales/zh_CN.json | 14 + packages/components/AGENTS.md | 8 + packages/components/src/atoms/runtime.ts | 10 + .../sessions/session-chat-interface.tsx | 29 +- .../settings/account-profile-list.tsx | 58 ++ .../components/settings/account-profiles.tsx | 242 +++++++ .../settings/acp-authentication-panel.tsx | 13 +- .../settings/agent-config-dialog.tsx | 58 +- .../hooks/use-machine-acp-authentication.ts | 5 + .../src/providers/create-workspace-runtime.ts | 74 ++- .../stories/AccountProfileList.stories.tsx | 41 ++ .../tests/account-profiles.test.tsx | 233 +++++++ ...te-workspace-runtime-meta-recovery.test.ts | 52 ++ .../use-machine-acp-authentication.test.tsx | 2 + .../src/machine-rpc-server.ts | 43 ++ packages/loro-streams-rpc/src/rpc.ts | 116 +++- .../tests/loro-streams-rpc.test.ts | 104 +++ packages/shared/src/account-profiles.ts | 23 + packages/shared/src/index.ts | 1 + .../src/machine-protocol-capabilities.ts | 13 + packages/shared/src/message-schemas.ts | 71 ++ packages/shared/src/message.ts | 55 ++ .../shared/src/node/local-cli-host-lease.cjs | 3 + .../shared/src/node/local-cli-host-lease.ts | 4 + .../shared/src/node/local-session-control.cjs | 116 ++++ .../shared/src/node/local-session-control.ts | 22 + packages/shared/src/schema.ts | 22 + .../shared/tests/account-profiles.test.ts | 117 ++++ .../shared/tests/local-cli-host-lease.test.ts | 64 +- 83 files changed, 4741 insertions(+), 344 deletions(-) create mode 100644 apps/cli/src/agent/account-profile-authentication.test.ts create mode 100644 apps/cli/src/agent/account-profiles.test.ts create mode 100644 apps/cli/src/agent/account-profiles.ts create mode 100644 apps/cli/src/lib/code-collab/file-index-scan-core.test.ts create mode 100644 apps/cli/src/session/session-account-handoff.test.ts create mode 100644 apps/cli/src/session/session-account-handoff.ts create mode 100644 apps/cli/tests/account-profiles-platform-check.ts create mode 100644 packages/components/src/components/settings/account-profile-list.tsx create mode 100644 packages/components/src/components/settings/account-profiles.tsx create mode 100644 packages/components/src/stories/AccountProfileList.stories.tsx create mode 100644 packages/components/tests/account-profiles.test.tsx create mode 100644 packages/shared/src/account-profiles.ts create mode 100644 packages/shared/tests/account-profiles.test.ts diff --git a/apps/cli/src/agent/AGENTS.md b/apps/cli/src/agent/AGENTS.md index 13a1dac17..129672683 100644 --- a/apps/cli/src/agent/AGENTS.md +++ b/apps/cli/src/agent/AGENTS.md @@ -85,6 +85,10 @@ arrive: context/message-flow.md "Upstream". `resolveACPProcessLaunchAsync()`: Claude/Codex/Kimi/Grok may install Lody-managed native or Node-package runtimes, while DeepSeek Harness publishes an immutable Cordis composition before its npx launch. +- `account-profiles.ts` resolves machine-local account ids after environment merging. + Missing ids mean System Default and preserve native auth/config exactly. Extra + Codex/Claude profiles own isolated homes, never copied native credentials; auth, + status and title launches use the same binding. Never fall back on a missing profile. - `deepseek-harness-runtime.ts` is the standard Harness-home (`DSH_HOME`, then `~/.dsh`), atomic-config, and npx launch wrapper around the `packages/acp-extension-dsh` submodule. It publishes Lody's versioned ACP composition beside (without replacing) user Harness config and diff --git a/apps/cli/src/agent/account-profile-authentication.test.ts b/apps/cli/src/agent/account-profile-authentication.test.ts new file mode 100644 index 000000000..688f0c7c2 --- /dev/null +++ b/apps/cli/src/agent/account-profile-authentication.test.ts @@ -0,0 +1,281 @@ +import { EventEmitter } from 'node:events'; +import type { ChildProcess } from 'node:child_process'; +import { PassThrough } from 'node:stream'; +import * as fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Logger } from '@/utils/logger'; +import { AcpAuthenticationManager, probeBuiltinAuthentication } from './acp-authentication'; +import { + acquireAccountProfileUse, + acquireAccountProfileAuthentication, + createAccountProfile, + listAccountProfiles, + validateAccountProfile, +} from './account-profiles'; +import { startLocalAcpAgent } from './acp-runner'; + +const logger: Logger = { + info() {}, + warn() {}, + error() {}, + success() {}, + debug() {}, + setLevel() {}, + child: () => logger, + close: async () => {}, +}; +const roots: string[] = []; +async function root() { + const value = await fs.mkdtemp(path.join(os.tmpdir(), 'lody-account-auth-test-')); + roots.push(value); + return value; +} +afterEach(async () => { + for (const value of roots.splice(0)) await fs.rm(value, { recursive: true, force: true }); +}); + +function childProcess() { + const child = new EventEmitter() as ChildProcess; + child.stdin = new PassThrough(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.exitCode = null; + child.kill = vi.fn(() => { + child.exitCode = 0; + queueMicrotask(() => child.emit('exit', 0)); + return true; + }); + return child; +} + +describe('account authentication provider boundary', () => { + it('releases cancelled pre-spawn login leases without releasing a subsequent retry lease', async () => { + const profilesRoot = await root(); + const input = { cliType: 'builtin' as const, agentType: 'claude', profilesRoot }; + const profile = await createAccountProfile({ ...input, label: 'Retry' }); + const selected = { + ...input, + accountProfileId: profile.accountProfileId, + runtimeOverrides: { claudeCodeExecutable: '/verified/claude' }, + }; + let preparationStarted!: () => void; + const preparing = new Promise((resolve) => { + preparationStarted = resolve; + }); + let finishPreparation!: (env: Record) => void; + const pendingEnv = new Promise>((resolve) => { + finishPreparation = resolve; + }); + let spawned!: () => void; + const didSpawn = new Promise((resolve) => { + spawned = resolve; + }); + const child = childProcess(); + const spawnProcess = vi.fn(() => { + spawned(); + return child; + }); + let attempts = 0; + const released = vi.fn(); + const manager = new AcpAuthenticationManager(logger, { + spawnProcess: spawnProcess as never, + resolveLoginShellEnv: async () => { + if (++attempts === 1) { + preparationStarted(); + return await pendingEnv; + } + return {}; + }, + }); + const first = manager.authenticate({ + ...selected, + requestId: 'cancelled', + onAccountLeaseReleased: released, + }); + await preparing; + manager.cancel('claude', 'cancelled'); + expect(released).toHaveBeenCalledTimes(1); + const retry = manager.authenticate({ + ...selected, + requestId: 'retry', + onAccountLeaseReleased: released, + }); + await didSpawn; + finishPreparation({}); + expect(await first).toMatchObject({ disposition: 'cancelled' }); + expect(() => acquireAccountProfileUse(selected)).toThrow('signing in'); + expect(spawnProcess).toHaveBeenCalledTimes(1); + child.exitCode = 0; + child.emit('exit', 0); + expect(await retry).toMatchObject({ disposition: 'authenticated' }); + expect(released).toHaveBeenCalledTimes(2); + acquireAccountProfileUse(selected)(); + }); + it('blocks login during auxiliary use and blocks auxiliary launch during login across managers', async () => { + const profilesRoot = await root(); + const input = { cliType: 'builtin' as const, agentType: 'codex', profilesRoot }; + const profile = await createAccountProfile({ ...input, label: 'Shared across workspaces' }); + const selected = { ...input, accountProfileId: profile.accountProfileId }; + const releaseUse = acquireAccountProfileUse(selected); + const spawnProcess = vi.fn(); + const manager = new AcpAuthenticationManager(logger, { spawnProcess: spawnProcess as never }); + try { + await expect( + manager.authenticate({ ...selected, requestId: 'blocked-login' }) + ).resolves.toMatchObject({ success: false, disposition: 'error' }); + expect(spawnProcess).not.toHaveBeenCalled(); + expect(() => acquireAccountProfileAuthentication(selected)).toThrow('in use'); + } finally { + releaseUse(); + } + const releaseAuth = acquireAccountProfileAuthentication(selected); + try { + await expect( + startLocalAcpAgent({ + ...selected, + workdir: profilesRoot, + logger, + terminalManager: { + createTerminal: async () => '', + terminalOutput: async () => ({ output: '', truncated: false, exitStatus: null }), + releaseTerminal: async () => {}, + waitForTerminalExit: async () => ({ exitCode: 0 }), + killTerminal: async () => {}, + }, + onUpdateMessage: () => {}, + onRequestPermission: async () => ({ outcome: { outcome: 'cancelled' } }), + }) + ).rejects.toThrow('signing in'); + expect(() => acquireAccountProfileUse(selected)).toThrow('signing in'); + } finally { + releaseAuth(); + } + acquireAccountProfileUse(selected)(); + }); + it('detects native Codex identity with read-only account/read and no home override', async () => { + const requests: unknown[] = []; + const child = childProcess(); + child.stdin?.on('data', (chunk: Buffer) => { + const message = JSON.parse(chunk.toString()) as { id?: number; method: string }; + requests.push(message); + if (message.id) + queueMicrotask(() => + child.stdout?.emit( + 'data', + Buffer.from( + JSON.stringify({ + id: message.id, + result: + message.id === 1 + ? {} + : { account: { type: 'chatgpt', email: 'work@example.test' } }, + }) + '\n' + ) + ) + ); + }); + const spawnProcess = vi.fn(() => child); + const result = await probeBuiltinAuthentication({ + cliType: 'builtin', + agentType: 'codex', + accountStatusOnly: true, + env: { CODEX_HOME: '/native-home' }, + runtimeOverrides: { codexPath: '/verified/codex' }, + logger, + resolveLoginShellEnv: async () => ({ CODEX_HOME: '/shell-home' }), + spawnProcess: spawnProcess as never, + }); + expect(result).toEqual({ status: 'authenticated', identity: 'work@example.test' }); + expect(requests).toContainEqual({ + id: 2, + method: 'account/read', + params: { refreshToken: false }, + }); + const call = spawnProcess.mock.calls[0] as unknown as [ + string, + string[], + { env: NodeJS.ProcessEnv }, + ]; + expect(call[2].env.CODEX_HOME).toBe('/native-home'); + expect(requests.some((value) => JSON.stringify(value).includes('thread/'))).toBe(false); + }); + + it('keeps malformed account status unknown and refuses to validate it', async () => { + const spawnProcess = () => { + const child = childProcess(); + child.stdin?.once('data', () => + queueMicrotask(() => child.stdout?.emit('data', Buffer.from('malformed\n'))) + ); + return child; + }; + const input = { + cliType: 'builtin' as const, + agentType: 'codex', + runtimeOverrides: { codexPath: '/verified/codex' }, + logger, + resolveLoginShellEnv: async () => ({}), + spawnProcess: spawnProcess as never, + }; + await expect(validateAccountProfile(input)).rejects.toThrow('could not be verified'); + const profiles = await listAccountProfiles({ ...input, profilesRoot: await root() }); + expect(profiles).toEqual([ + { accountProfileId: 'system-default', label: 'System Default', status: 'unknown' }, + ]); + }); + + it('allows independent account logins and cancels only the requested account', async () => { + const profilesRoot = await root(); + const input = { cliType: 'builtin' as const, agentType: 'claude', profilesRoot }; + const a = await createAccountProfile({ ...input, label: 'A' }); + const b = await createAccountProfile({ ...input, label: 'B' }); + const children: ChildProcess[] = []; + const childrenByProfile = new Map(); + let spawned!: () => void; + const bothSpawned = new Promise((resolve) => { + spawned = resolve; + }); + const environments: NodeJS.ProcessEnv[] = []; + const spawnProcess = ( + _command: string, + _args: string[], + options: { env: NodeJS.ProcessEnv } + ) => { + const child = childProcess(); + children.push(child); + childrenByProfile.set(options.env.LODY_ACCOUNT_PROFILE_ID ?? '', child); + environments.push(options.env); + if (children.length === 2) spawned(); + return child; + }; + const manager = new AcpAuthenticationManager(logger, { + spawnProcess: spawnProcess as never, + resolveLoginShellEnv: async () => ({ + CLAUDE_CONFIG_DIR: '/native-claude', + ANTHROPIC_API_KEY: 'synthetic', + }), + }); + const auth = (requestId: string, accountProfileId: string) => + manager.authenticate({ + ...input, + requestId, + accountProfileId, + runtimeOverrides: { claudeCodeExecutable: '/verified/claude' }, + }); + const first = auth('first', a.accountProfileId); + const second = auth('second', b.accountProfileId); + await bothSpawned; + expect(environments[0]?.CLAUDE_CONFIG_DIR).not.toBe(environments[1]?.CLAUDE_CONFIG_DIR); + expect(environments.every((env) => env.ANTHROPIC_API_KEY === undefined)).toBe(true); + manager.cancel('claude', 'first'); + const secondChild = childrenByProfile.get(b.accountProfileId); + expect(secondChild?.kill).not.toHaveBeenCalled(); + if (secondChild) { + secondChild.exitCode = 0; + secondChild.emit('exit', 0); + } + expect(await first).toMatchObject({ disposition: 'cancelled' }); + expect(await second).toMatchObject({ disposition: 'authenticated' }); + }); +}); diff --git a/apps/cli/src/agent/account-profiles.test.ts b/apps/cli/src/agent/account-profiles.test.ts new file mode 100644 index 000000000..78047c6d9 --- /dev/null +++ b/apps/cli/src/agent/account-profiles.test.ts @@ -0,0 +1,172 @@ +import * as fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + accountProfileAuthenticationArgs, + createAccountProfile, + resolveAccountProfileEnv, +} from './account-profiles'; + +const roots: string[] = []; +async function temporaryRoot() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'lody-account-test-')); + roots.push(root); + return root; +} +afterEach(async () => { + for (const root of roots.splice(0)) await fs.rm(root, { recursive: true, force: true }); +}); + +describe('provider account isolation', () => { + it('leaves legacy and explicit System Default environments identical without touching paths', async () => { + const env = { + CODEX_HOME: '/native/missing', + CLAUDE_CONFIG_DIR: '/native/claude', + ANTHROPIC_API_KEY: 'synthetic', + }; + for (const accountProfileId of [undefined, 'system-default']) { + expect( + await resolveAccountProfileEnv({ + cliType: 'builtin', + agentType: 'codex', + accountProfileId, + env, + profilesRoot: '/does-not-exist', + }) + ).toBe(env); + } + }); + + it.each(['codex', 'claude'])( + 'authenticates %s accounts into empty independent homes without changing native state', + async (agentType) => { + const profilesRoot = await temporaryRoot(); + const input = { cliType: 'builtin' as const, agentType, profilesRoot }; + const a = await createAccountProfile({ ...input, label: 'Work' }); + const b = await createAccountProfile({ ...input, label: 'Personal' }); + const original = { + CODEX_HOME: '/native', + CLAUDE_CONFIG_DIR: '/native-claude', + ANTHROPIC_API_KEY: 'synthetic', + OPENAI_API_KEY: 'synthetic', + PATH: '/bin', + }; + const envA = await resolveAccountProfileEnv({ + ...input, + accountProfileId: a.accountProfileId, + env: original, + }); + const envB = await resolveAccountProfileEnv({ + ...input, + accountProfileId: b.accountProfileId, + env: original, + }); + const key = agentType === 'codex' ? 'CODEX_HOME' : 'CLAUDE_CONFIG_DIR'; + expect(envA[key]).not.toBe(envB[key]); + expect(await fs.readdir(envA[key] ?? '')).toEqual( + agentType === 'codex' ? ['config.toml'] : [] + ); + expect(await fs.readdir(envB[key] ?? '')).toEqual( + agentType === 'codex' ? ['config.toml'] : [] + ); + expect(envA[agentType === 'codex' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY']).toBeUndefined(); + expect(original.CODEX_HOME).toBe('/native'); + expect(envA.PATH).toBe('/bin'); + } + ); + + it('rejects missing, malformed and wrong-provider profile bindings rather than falling back', async () => { + const profilesRoot = await temporaryRoot(); + const input = { cliType: 'builtin' as const, agentType: 'codex', profilesRoot }; + const profile = await createAccountProfile({ ...input, label: 'Work' }); + await expect( + resolveAccountProfileEnv({ ...input, accountProfileId: '../native' }) + ).rejects.toThrow(); + await expect( + resolveAccountProfileEnv({ + ...input, + agentType: 'claude', + accountProfileId: profile.accountProfileId, + }) + ).rejects.toThrow(); + await fs.writeFile( + path.join(profilesRoot, 'codex', profile.accountProfileId, 'profile.json'), + '{}' + ); + await expect( + resolveAccountProfileEnv({ ...input, accountProfileId: profile.accountProfileId }) + ).rejects.toThrow(); + }); + + it('removes Windows case variants and pins managed Codex storage for every native login', async () => { + const input = { + cliType: 'builtin' as const, + agentType: 'codex', + profilesRoot: await temporaryRoot(), + }; + const profile = await createAccountProfile({ ...input, label: 'Windows' }); + const selection = { ...input, accountProfileId: profile.accountProfileId }; + const env = await resolveAccountProfileEnv({ + ...selection, + env: { + codex_home: 'C:\\native', + openai_api_key: 'synthetic', + OpenAI_Base_Url: 'https://example.test', + CODEX_CONFIG: '{"cli_auth_credentials_store":"keyring"}', + }, + }); + expect(env.codex_home).toBeUndefined(); + expect(env.openai_api_key).toBeUndefined(); + expect(env.OpenAI_Base_Url).toBeUndefined(); + expect(JSON.parse(env.CODEX_CONFIG ?? '{}')).toEqual({ cli_auth_credentials_store: 'file' }); + expect(accountProfileAuthenticationArgs(selection, ['login'])).toEqual([ + '-c', + 'cli_auth_credentials_store="file"', + 'login', + ]); + const args = ['login']; + expect(accountProfileAuthenticationArgs({ agentType: 'codex' }, args)).toBe(args); + }); + + it('refuses a redirected profile root without writing into its target', async () => { + const root = await temporaryRoot(); + const target = path.join(root, 'native'); + await fs.mkdir(target); + const profilesRoot = path.join(root, 'profiles'); + await fs.symlink(target, profilesRoot, process.platform === 'win32' ? 'junction' : 'dir'); + await expect( + createAccountProfile({ cliType: 'builtin', agentType: 'codex', profilesRoot, label: 'Test' }) + ).rejects.toThrow('root is invalid'); + expect(await fs.readdir(target)).toEqual([]); + }); + + it('replays account creation without duplicating accounts or replacing existing credentials/config', async () => { + const profilesRoot = await temporaryRoot(); + const input = { + cliType: 'builtin' as const, + agentType: 'codex', + profilesRoot, + label: 'Work', + operationId: 'workspace/request', + }; + const [first, concurrent] = await Promise.all([ + createAccountProfile(input), + createAccountProfile(input), + ]); + expect(concurrent.accountProfileId).toBe(first.accountProfileId); + const home = path.join(profilesRoot, 'codex', first.accountProfileId, 'home'); + await fs.writeFile(path.join(home, 'auth.json'), 'synthetic-auth-fixture'); + await fs.writeFile(path.join(home, 'config.toml'), 'synthetic-config-fixture'); + const replay = await createAccountProfile(input); + expect(replay.accountProfileId).toBe(first.accountProfileId); + expect(await fs.readdir(path.join(profilesRoot, 'codex'))).toEqual([first.accountProfileId]); + expect(await fs.readFile(path.join(home, 'auth.json'), 'utf8')).toBe('synthetic-auth-fixture'); + expect(await fs.readFile(path.join(home, 'config.toml'), 'utf8')).toBe( + 'synthetic-config-fixture' + ); + await expect(createAccountProfile({ ...input, label: 'Different' })).rejects.toThrow( + 'different label' + ); + }); +}); diff --git a/apps/cli/src/agent/account-profiles.ts b/apps/cli/src/agent/account-profiles.ts new file mode 100644 index 000000000..efe82035f --- /dev/null +++ b/apps/cli/src/agent/account-profiles.ts @@ -0,0 +1,284 @@ +import { randomUUID } from 'node:crypto'; +import { v5 as uuidV5 } from 'uuid'; +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import { z } from 'zod'; +import { + SYSTEM_DEFAULT_ACCOUNT_PROFILE_ID, + type AgentConfigCliType, + type AccountProfileSummary, +} from '@lody/shared'; +import { getLodyDataDir } from '@lody/shared/node/installation-profile'; +import { scrubManagedClaudeAccountEnv } from './claude-env-conflict'; + +export { SYSTEM_DEFAULT_ACCOUNT_PROFILE_ID, type AccountProfileSummary } from '@lody/shared'; +const ProfileSchema = z.object({ + accountProfileId: z.string().uuid(), + label: z.string().trim().min(1).max(120), +}); +export type AccountProfileInput = { + cliType: AgentConfigCliType; + agentType: string; + accountProfileId?: string; + env?: NodeJS.ProcessEnv; + profilesRoot?: string; +}; + +export function isManagedAccountProfile(accountProfileId?: string): boolean { + return accountProfileId !== undefined && accountProfileId !== SYSTEM_DEFAULT_ACCOUNT_PROFILE_ID; +} + +type AccountLeaseInput = Pick; +type AccountLeaseState = { users: number; authenticating: boolean }; +const accountLeases = new Map(); + +function acquireAccountLease(input: AccountLeaseInput, authentication: boolean): () => void { + if (!isManagedAccountProfile(input.accountProfileId)) return () => {}; + if ( + input.cliType !== 'builtin' || + (input.agentType !== 'codex' && input.agentType !== 'claude') + ) { + throw new Error('Account profiles are supported only for built-in Codex and Claude'); + } + const id = z.string().uuid().parse(input.accountProfileId); + const key = `${input.agentType}:${id}`; + const state = accountLeases.get(key) ?? { users: 0, authenticating: false }; + if (state.authenticating || (authentication && state.users > 0)) { + throw new Error( + 'This account is in use or signing in. Wait for its processes to stop and retry.' + ); + } + if (authentication) state.authenticating = true; + else state.users += 1; + accountLeases.set(key, state); + let released = false; + return () => { + if (released) return; + released = true; + if (authentication) state.authenticating = false; + else state.users -= 1; + if (!state.authenticating && state.users === 0) accountLeases.delete(key); + }; +} + +/** Process-wide across workspaces; held until the account's provider process exits. */ +export function acquireAccountProfileUse(input: AccountLeaseInput): () => void { + return acquireAccountLease(input, false); +} + +export function acquireAccountProfileAuthentication(input: AccountLeaseInput): () => void { + return acquireAccountLease(input, true); +} + +function providerRoot(input: AccountProfileInput): string { + if ( + input.cliType !== 'builtin' || + (input.agentType !== 'codex' && input.agentType !== 'claude') + ) { + throw new Error('Account profiles are supported only for built-in Codex and Claude'); + } + return path.join( + input.profilesRoot ?? path.join(getLodyDataDir(), 'agent-accounts'), + input.agentType + ); +} + +async function readProfile(input: AccountProfileInput) { + await assertProfileRoots(input); + const id = z.string().uuid().parse(input.accountProfileId); + const directory = path.join(providerRoot(input), id); + const stat = await fs.lstat(directory); + if (!stat.isDirectory() || stat.isSymbolicLink()) + throw new Error('Account profile directory is invalid'); + const profile = ProfileSchema.parse( + JSON.parse(await fs.readFile(path.join(directory, 'profile.json'), 'utf8')) + ); + if (profile.accountProfileId !== id) throw new Error('Account profile identity is invalid'); + const home = path.join(directory, 'home'); + const homeStat = await fs.lstat(home); + if (!homeStat.isDirectory() || homeStat.isSymbolicLink()) + throw new Error('Account profile home is invalid'); + return { profile, home }; +} + +async function assertProfileRoots(input: AccountProfileInput): Promise { + const root = providerRoot(input); + for (const directory of [path.dirname(root), root]) { + try { + const stat = await fs.lstat(directory); + if (!stat.isDirectory() || stat.isSymbolicLink()) + throw new Error('Account profile root is invalid'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } +} + +/** Resolve only Lody-owned metadata. Default never inspects or adopts native credentials. */ +export async function resolveAccountProfileEnv( + input: AccountProfileInput +): Promise { + const original = input.env ?? process.env; + if (!isManagedAccountProfile(input.accountProfileId)) return original; + const { home } = await readProfile(input); + const env = + input.agentType === 'claude' ? scrubManagedClaudeAccountEnv(original) : { ...original }; + env.LODY_ACCOUNT_PROFILE_ID = input.accountProfileId; + // Managed subscription accounts must not silently use ambient API credentials. + for (const key of Object.keys(env)) { + const normalizedKey = key.toUpperCase(); + if (normalizedKey === (input.agentType === 'codex' ? 'CODEX_HOME' : 'CLAUDE_CONFIG_DIR')) + delete env[key]; + if ( + input.agentType === 'codex' && + /^(OPENAI_API_KEY|OPENAI_BASE_URL|CODEX_API_KEY|CODEX_AUTH_JSON|CODEX_CONFIG)$/.test( + normalizedKey + ) + ) + delete env[key]; + } + if (input.agentType === 'codex') { + env.CODEX_HOME = home; + env.CODEX_CONFIG = JSON.stringify({ cli_auth_credentials_store: 'file' }); + } else env.CLAUDE_CONFIG_DIR = home; + return env; +} + +export function accountProfileAuthenticationArgs( + input: Pick, + args: string[] +): string[] { + return input.agentType === 'codex' && isManagedAccountProfile(input.accountProfileId) + ? ['-c', 'cli_auth_credentials_store="file"', ...args] + : args; +} + +const creatingProfiles = new Map>(); + +async function createProfileFiles( + input: AccountProfileInput, + profile: z.infer +): Promise { + const directory = path.join(providerRoot(input), profile.accountProfileId); + await fs.mkdir(providerRoot(input), { recursive: true, mode: 0o700 }); + try { + await fs.mkdir(directory, { mode: 0o700 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + const existing = await readProfile({ ...input, accountProfileId: profile.accountProfileId }); + if (existing.profile.label !== profile.label) + throw new Error('Account creation request was already used with a different label', { + cause: error, + }); + return { ...existing.profile, status: 'unknown' }; + } + await fs.mkdir(path.join(directory, 'home'), { mode: 0o700 }); + if (input.agentType === 'codex') { + await fs.writeFile( + path.join(directory, 'home', 'config.toml'), + 'cli_auth_credentials_store = "file"\n', + { flag: 'wx', mode: 0o600 } + ); + } + await fs.writeFile(path.join(directory, 'profile.json'), JSON.stringify(profile), { + flag: 'wx', + mode: 0o600, + }); + return { ...profile, status: 'unauthenticated' }; +} + +export async function createAccountProfile( + input: AccountProfileInput & { label: string; operationId?: string } +): Promise { + await assertProfileRoots(input); + const operationId = + input.operationId === undefined + ? undefined + : z.string().min(1).max(1024).parse(input.operationId); + const profile = ProfileSchema.parse({ + accountProfileId: + operationId === undefined + ? randomUUID() + : uuidV5(`${input.agentType}:${operationId}`, uuidV5.URL), + label: input.label, + }); + const key = path.join(providerRoot(input), profile.accountProfileId); + const pending = creatingProfiles.get(key); + if (pending) { + const result = await pending; + if (result.label !== profile.label) + throw new Error('Account creation request was already used with a different label'); + return result; + } + const creation = createProfileFiles(input, profile); + creatingProfiles.set(key, creation); + try { + return await creation; + } finally { + creatingProfiles.delete(key); + } +} + +type ProbeOptions = Omit< + import('./acp-authentication').ProbeBuiltinAuthenticationOptions, + 'accountProfileId' +>; +export async function validateAccountProfile( + input: AccountProfileInput & ProbeOptions +): Promise { + const { probeBuiltinAuthentication } = await import('./acp-authentication'); + const result = await probeBuiltinAuthentication({ ...input, accountStatusOnly: true }); + if (result.status !== 'authenticated') + throw new Error('Target account authentication could not be verified. Sign in and retry.'); +} + +export async function listAccountProfiles( + input: AccountProfileInput & ProbeOptions +): Promise { + const root = providerRoot(input); + await assertProfileRoots(input); + const rows: AccountProfileSummary[] = [ + { + accountProfileId: SYSTEM_DEFAULT_ACCOUNT_PROFILE_ID, + label: 'System Default', + status: 'unknown', + }, + ]; + let entries: string[]; + try { + entries = await fs.readdir(root); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + entries = []; + } + for (const accountProfileId of entries.sort()) { + if (!z.string().uuid().safeParse(accountProfileId).success) continue; + try { + const { profile } = await readProfile({ ...input, accountProfileId }); + rows.push({ ...profile, status: 'unknown' }); + } catch { + rows.push({ accountProfileId, label: 'Unavailable account', status: 'error' }); + } + } + const { probeBuiltinAuthentication } = await import('./acp-authentication'); + const deadline = AbortSignal.timeout(15_000); + const signal = input.signal ? AbortSignal.any([input.signal, deadline]) : deadline; + for (const row of rows) { + if (signal.aborted) break; + if (row.status === 'error') continue; + try { + const result = await probeBuiltinAuthentication({ + ...input, + accountProfileId: row.accountProfileId, + accountStatusOnly: true, + signal, + statusProbeTimeoutMs: Math.min(input.statusProbeTimeoutMs ?? 5_000, 5_000), + }); + row.status = result.status; + if (result.status === 'authenticated') row.identity = result.identity; + } catch { + row.status = signal.aborted ? 'unknown' : 'error'; + } + } + return rows; +} diff --git a/apps/cli/src/agent/acp-authentication.test.ts b/apps/cli/src/agent/acp-authentication.test.ts index 92e0c0c7a..c876776a9 100644 --- a/apps/cli/src/agent/acp-authentication.test.ts +++ b/apps/cli/src/agent/acp-authentication.test.ts @@ -1,6 +1,7 @@ import { EventEmitter } from 'node:events'; import type { ChildProcess } from 'node:child_process'; import { PassThrough } from 'node:stream'; +import { resolve as resolvePath } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -126,7 +127,7 @@ describe('AcpAuthenticationManager', () => { }) ).resolves.toEqual({ success: true, disposition: 'authenticated' }); expect(spawnProcess).toHaveBeenCalledWith( - command, + resolvePath(command), args, expect.objectContaining({ cwd: expect.any(String) }) ); @@ -350,7 +351,7 @@ describe('probeBuiltinAuthentication', () => { }) ).resolves.toEqual({ status: 'authenticated' }); expect(spawnProcess).toHaveBeenCalledWith( - '/test/claude', + resolvePath('/test/claude'), ['auth', 'status', '--json'], expect.objectContaining({ stdio: 'ignore' }) ); diff --git a/apps/cli/src/agent/acp-authentication.ts b/apps/cli/src/agent/acp-authentication.ts index 2c01d2d16..8a8993a20 100644 --- a/apps/cli/src/agent/acp-authentication.ts +++ b/apps/cli/src/agent/acp-authentication.ts @@ -1,6 +1,7 @@ import type { ChildProcess } from 'child_process'; import os from 'os'; import spawn from 'cross-spawn'; +import { z } from 'zod'; import type { AuthMethod } from '@agentclientprotocol/sdk'; import type { AgentConfigCliType, @@ -20,6 +21,13 @@ import { formatErrorMessage } from '@/utils/format-error'; import { BuiltinAuthenticationOutputParser } from './acp-authentication-output'; import { shutdownLocalAcpAgent } from './acp-runner'; import { getLoginShellEnv } from './login-shell-env'; +import { + acquireAccountProfileUse, + acquireAccountProfileAuthentication, + accountProfileAuthenticationArgs, + resolveAccountProfileEnv, +} from './account-profiles'; +import { withAcpSessionStartSlot } from './acp-session-start-gate'; import { mergeACPProcessEnv, mergeLoginShellEnv, @@ -93,8 +101,10 @@ const BUILTIN_AUTH_METHODS = { } satisfies Record; type RunningAuthentication = { + releaseAccountLease?: () => void; child?: ChildProcess; requestId: string; + agentType: string; cancelled: boolean; timedOut: boolean; terminating: boolean; @@ -110,15 +120,18 @@ type AcpAuthenticationManagerOptions = { }; export type BuiltinAuthenticationProbeResult = - | { status: 'authenticated' } + | { status: 'authenticated'; identity?: string } | { status: 'unauthenticated'; authMethods: readonly AuthMethod[] } | { status: 'unknown' }; -type ProbeBuiltinAuthenticationOptions = { +export type ProbeBuiltinAuthenticationOptions = { cliType: AgentConfigCliType; agentType: string; runtimeOverrides?: BuiltinRuntimeOverrides; env?: NodeJS.ProcessEnv; + accountProfileId?: string; + profilesRoot?: string; + accountStatusOnly?: boolean; onManagedRuntimeProgress?: Parameters< typeof resolveBuiltinAuthenticationProcessLaunch >[0]['onManagedRuntimeProgress']; @@ -147,6 +160,9 @@ async function buildAuthenticationProcessEnv(options: { launch: ResolvedACPProcessLaunch; agentType: string; env?: NodeJS.ProcessEnv; + accountProfileId?: string; + profilesRoot?: string; + accountStatusOnly?: boolean; resolveLoginShellEnv: typeof getLoginShellEnv; }): Promise { const loginShellEnv = await options.resolveLoginShellEnv(); @@ -156,12 +172,13 @@ async function buildAuthenticationProcessEnv(options: { NO_COLOR: '1', }; delete baseEnv.FORCE_COLOR; - return withoutElectronBootstrapCredentials( + const merged = withoutElectronBootstrapCredentials( withDefaultAcpPathEntries( mergeACPProcessEnv(options.launch, mergeLoginShellEnv(baseEnv, loginShellEnv)), options.agentType ) ); + return resolveAccountProfileEnv({ ...options, cliType: 'builtin', env: merged }); } /** @@ -173,6 +190,17 @@ async function buildAuthenticationProcessEnv(options: { */ export async function probeBuiltinAuthentication( options: ProbeBuiltinAuthenticationOptions +): Promise { + const release = acquireAccountProfileUse(options); + try { + return await probeBuiltinAuthenticationWithAccountLease(options); + } finally { + release(); + } +} + +async function probeBuiltinAuthenticationWithAccountLease( + options: ProbeBuiltinAuthenticationOptions ): Promise { options.signal?.throwIfAborted(); if (options.cliType !== 'builtin' || !isManagedBuiltinAgentType(options.agentType)) { @@ -181,7 +209,7 @@ export async function probeBuiltinAuthentication( if ( options.agentType === 'kimi' || options.agentType === 'grok' || - options.agentType === 'codex' + (options.agentType === 'codex' && !options.accountStatusOnly) ) { return { status: 'unknown' }; } @@ -200,18 +228,31 @@ export async function probeBuiltinAuthentication( launch, agentType: options.agentType, env: options.env, + accountProfileId: options.accountProfileId, + profilesRoot: options.profilesRoot, resolveLoginShellEnv: options.resolveLoginShellEnv ?? getLoginShellEnv, }); options.signal?.throwIfAborted(); if (hasBuiltinEnvAuthentication(options.agentType, env)) { return { status: 'unknown' }; } + if (options.agentType === 'codex' && options.accountStatusOnly) { + return withAcpSessionStartSlot( + { label: 'account-status', logger: options.logger, abortSignal: options.signal }, + () => probeCodexAccount(options, launch, env) + ); + } const child = (options.spawnProcess ?? spawn)(launch.command, launch.args, { cwd: os.homedir(), env, - stdio: 'ignore', + stdio: options.accountStatusOnly ? ['ignore', 'pipe', 'ignore'] : 'ignore', windowsHide: true, }); + let statusOutput = ''; + child.stdout?.on('data', (chunk: Buffer) => { + if (statusOutput.length < 16_384) + statusOutput += chunk.toString('utf8').slice(0, 16_384 - statusOutput.length); + }); const timeoutMs = Math.max(1, options.statusProbeTimeoutMs ?? DEFAULT_STATUS_PROBE_TIMEOUT_MS); const exit = await new Promise<{ aborted?: boolean; @@ -273,17 +314,147 @@ export async function probeBuiltinAuthentication( `${getBuiltinDisplayName(options.agentType)} authentication status failed: ${formatErrorMessage(exit.error)}` ); } + const parsed = z + .object({ loggedIn: z.boolean(), email: z.string().max(320).optional() }) + .safeParse( + (() => { + try { + return JSON.parse(statusOutput); + } catch { + return null; + } + })() + ); + if (options.accountStatusOnly && exit.code === 0 && (!parsed.success || !parsed.data.loggedIn)) { + return { status: 'unknown' }; + } return exit.code === 0 - ? { status: 'authenticated' } + ? { + status: 'authenticated', + ...(parsed.success && parsed.data.email ? { identity: parsed.data.email } : {}), + } : { status: 'unauthenticated', authMethods: BUILTIN_AUTH_METHODS[options.agentType], }; } +/** Official app-server account/read, without refreshing tokens or creating a thread. */ +async function probeCodexAccount( + options: ProbeBuiltinAuthenticationOptions, + launch: ResolvedACPProcessLaunch, + env: NodeJS.ProcessEnv +): Promise { + const child = (options.spawnProcess ?? spawn)( + launch.command, + accountProfileAuthenticationArgs(options, ['app-server']), + { + cwd: os.homedir(), + env, + stdio: ['pipe', 'pipe', 'ignore'], + windowsHide: true, + } + ); + let timer: ReturnType | undefined; + let onAbort = () => {}; + try { + return await new Promise((resolve) => { + let finished = false; + let buffer = ''; + let receivedBytes = 0; + const finish = (result: BuiltinAuthenticationProbeResult) => { + if (finished) return; + finished = true; + resolve(result); + }; + const send = (value: unknown) => child.stdin?.write(`${JSON.stringify(value)}\n`); + onAbort = () => finish({ status: 'unknown' }); + options.signal?.addEventListener('abort', onAbort, { once: true }); + timer = setTimeout(onAbort, options.statusProbeTimeoutMs ?? DEFAULT_STATUS_PROBE_TIMEOUT_MS); + timer.unref?.(); + child.once('error', onAbort); + child.once('exit', onAbort); + child.stdin?.on('error', onAbort); + child.stdout?.on('data', (chunk: Buffer) => { + if (finished) return; + receivedBytes += chunk.length; + buffer += chunk.toString('utf8'); + if (receivedBytes > 65_536) { + onAbort(); + return; + } + let newline: number; + while ((newline = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + let value: unknown; + try { + value = JSON.parse(line); + } catch { + onAbort(); + return; + } + const message = z + .object({ + id: z.number().optional(), + result: z.unknown().optional(), + error: z.unknown().optional(), + }) + .safeParse(value); + if (!message.success) continue; + if (message.data.error !== undefined) { + onAbort(); + return; + } + if (message.data.id === 1) { + send({ method: 'initialized', params: {} }); + send({ id: 2, method: 'account/read', params: { refreshToken: false } }); + } else if (message.data.id === 2) { + const account = z + .object({ + account: z + .object({ type: z.string(), email: z.string().max(320).optional() }) + .nullable(), + }) + .safeParse(message.data.result); + if (!account.success) { + onAbort(); + return; + } + finish( + account.data.account + ? { + status: 'authenticated', + ...(account.data.account.email ? { identity: account.data.account.email } : {}), + } + : { status: 'unauthenticated', authMethods: BUILTIN_AUTH_METHODS.codex } + ); + } + } + }); + if (options.signal?.aborted) { + onAbort(); + return; + } + send({ + id: 1, + method: 'initialize', + params: { clientInfo: { name: 'lody-account-status', version: '1' }, capabilities: {} }, + }); + }); + } finally { + clearTimeout(timer); + options.signal?.removeEventListener('abort', onAbort); + await shutdownLocalAcpAgent({ + agentProcess: child, + logger: options.logger, + sessionLabel: 'account-status', + }); + } +} + export class AcpAuthenticationManager { - // Each builtin provider has one shared credential store, so concurrent login - // attempts are intentionally keyed by agent type. + // Login slots are isolated by provider and account profile. private readonly runningByAgentType = new Map(); private readonly authenticationTimeoutMs: number; private readonly terminationGraceMs: number; @@ -313,6 +484,9 @@ export class AcpAuthenticationManager { customAcp?: CustomAcpLaunchSpec; runtimeOverrides?: BuiltinRuntimeOverrides; env?: Record; + accountProfileId?: string; + profilesRoot?: string; + onAccountLeaseReleased?: () => void; onProgress?: (event: AcpAuthenticationProgressEvent) => void; }): Promise { if (options.cliType !== 'builtin' || !isManagedBuiltinAgentType(options.agentType)) { @@ -325,8 +499,9 @@ export class AcpAuthenticationManager { const displayName = getBuiltinDisplayName(options.agentType); const agentType: BuiltinCliType = options.agentType; + const accountKey = JSON.stringify([agentType, options.accountProfileId ?? 'system-default']); - if (this.runningByAgentType.has(options.agentType)) { + if (this.runningByAgentType.has(accountKey)) { return { success: false, disposition: 'error', @@ -336,6 +511,7 @@ export class AcpAuthenticationManager { const running: RunningAuthentication = { requestId: options.requestId, + agentType, cancelled: false, timedOut: false, terminating: false, @@ -344,7 +520,7 @@ export class AcpAuthenticationManager { }; // Reserve the slot before any async launch preparation. This makes // concurrent starts and cancellation deterministic even before spawn. - this.runningByAgentType.set(options.agentType, running); + this.runningByAgentType.set(accountKey, running); let timeoutHandle: ReturnType | undefined; const interruptedResult = (): AcpAuthenticationResult | null => { @@ -363,14 +539,23 @@ export class AcpAuthenticationManager { timeoutHandle = setTimeout(() => { if (running.cancelled) return; running.timedOut = true; - if (!running.child && this.runningByAgentType.get(options.agentType) === running) { - this.runningByAgentType.delete(options.agentType); + if (!running.child && this.runningByAgentType.get(accountKey) === running) { + this.runningByAgentType.delete(accountKey); + running.releaseAccountLease?.(); } this.terminateAuthentication(options.agentType, running, 'timed out'); }, this.authenticationTimeoutMs); timeoutHandle.unref?.(); try { + const releaseProfileAuthentication = acquireAccountProfileAuthentication(options); + let leaseReleased = false; + running.releaseAccountLease = () => { + if (leaseReleased) return; + leaseReleased = true; + releaseProfileAuthentication(); + options.onAccountLeaseReleased?.(); + }; const launch = await resolveBuiltinAuthenticationProcessLaunch({ cliType: options.cliType, agentType: options.agentType, @@ -387,19 +572,27 @@ export class AcpAuthenticationManager { launch, agentType: options.agentType, env: options.env, + accountProfileId: options.accountProfileId, + profilesRoot: options.profilesRoot, resolveLoginShellEnv: this.resolveLoginShellEnv, }); const preparationInterruption = interruptedResult(); if (preparationInterruption) return preparationInterruption; options.onProgress?.({ status: 'starting' }); - const child = this.spawnProcess(launch.command, launch.args, { - cwd: os.homedir(), - env, - stdio: ['pipe', 'pipe', 'pipe'], - detached: process.platform !== 'win32', - windowsHide: true, - }); + const startingInterruption = interruptedResult(); + if (startingInterruption) return startingInterruption; + const child = this.spawnProcess( + launch.command, + accountProfileAuthenticationArgs(options, launch.args), + { + cwd: os.homedir(), + env, + stdio: ['pipe', 'pipe', 'pipe'], + detached: process.platform !== 'win32', + windowsHide: true, + } + ); running.child = child; child.stdin?.on('error', (error: unknown) => { this.logger.debug( @@ -459,24 +652,29 @@ export class AcpAuthenticationManager { options.onProgress?.({ status: 'error', error: message }); return { success: false, disposition: 'error', error: message }; } finally { + running.releaseAccountLease?.(); if (timeoutHandle) { clearTimeout(timeoutHandle); } - if (this.runningByAgentType.get(options.agentType) === running) { - this.runningByAgentType.delete(options.agentType); + if (this.runningByAgentType.get(accountKey) === running) { + this.runningByAgentType.delete(accountKey); } } } cancel(agentType: string, requestId: string): AcpAuthenticationResult { - const running = this.runningByAgentType.get(agentType); + const entry = [...this.runningByAgentType.entries()].find( + ([, item]) => item.agentType === agentType && item.requestId === requestId + ); + const running = entry?.[1]; if (!running || running.requestId !== requestId) { return { success: true, disposition: 'not-running' }; } running.cancelled = true; - if (!running.child && this.runningByAgentType.get(agentType) === running) { - this.runningByAgentType.delete(agentType); + if (!running.child && entry !== undefined) { + this.runningByAgentType.delete(entry[0]); + running.releaseAccountLease?.(); } this.terminateAuthentication(agentType, running, 'cancelled'); return { success: true, disposition: 'cancelled' }; @@ -487,7 +685,10 @@ export class AcpAuthenticationManager { requestId: string, authorizationCode: string ): AcpAuthenticationResult { - const running = this.runningByAgentType.get(agentType); + const entry = [...this.runningByAgentType.entries()].find( + ([, item]) => item.agentType === agentType && item.requestId === requestId + ); + const running = entry?.[1]; if (!running || running.requestId !== requestId) { return { success: true, disposition: 'not-running' }; } diff --git a/apps/cli/src/agent/acp-binary-manager.test.ts b/apps/cli/src/agent/acp-binary-manager.test.ts index 7ae52703d..5a39035f2 100644 --- a/apps/cli/src/agent/acp-binary-manager.test.ts +++ b/apps/cli/src/agent/acp-binary-manager.test.ts @@ -108,8 +108,10 @@ describe('AcpBinaryManager', () => { expect(launch.command).toBe(join(rootDir, 'test-raw', '1.0.0', 'linux-x86_64', 'foo')); expect(launch.args).toEqual(['acp']); expect(existsSync(launch.command)).toBe(true); - const mode = (await stat(launch.command)).mode; - expect(mode & 0o100).toBe(0o100); // owner-executable + if (process.platform !== 'win32') { + const mode = (await stat(launch.command)).mode; + expect(mode & 0o100).toBe(0o100); // POSIX owner-executable bit + } }); it('extracts a .tar.gz archive and resolves the nested cmd', async () => { diff --git a/apps/cli/src/agent/acp-runner.ts b/apps/cli/src/agent/acp-runner.ts index 090c5317b..9a6758e35 100644 --- a/apps/cli/src/agent/acp-runner.ts +++ b/apps/cli/src/agent/acp-runner.ts @@ -26,6 +26,7 @@ import { type AcpSessionStartTarget, } from './agent-client'; import { getLoginShellEnv } from './login-shell-env'; +import { acquireAccountProfileUse, resolveAccountProfileEnv } from './account-profiles'; import { mergeACPProcessEnv, mergeLoginShellEnv, @@ -269,6 +270,7 @@ export const spawnAcpProcess = (options: SpawnAcpProcessOptions): ChildProcess = }; export type StartLocalAcpAgentOptions = { + accountProfileId?: string; cliType: AgentConfigCliType; agentType: string; customAcp?: CustomAcpLaunchSpec; @@ -346,6 +348,19 @@ export const __test__ = { }; export const startLocalAcpAgent = async (options: StartLocalAcpAgentOptions) => { + const release = acquireAccountProfileUse(options); + try { + const result = await startLocalAcpAgentWithAccountLease(options); + if (result.agentProcess.exitCode !== null || result.agentProcess.signalCode != null) release(); + else result.agentProcess.once('exit', release); + return result; + } catch (error) { + release(); + throw error; + } +}; + +const startLocalAcpAgentWithAccountLease = async (options: StartLocalAcpAgentOptions) => { options.signal?.throwIfAborted(); // Async resolve so registry agents distributed as a platform binary are // downloaded/unpacked on demand before spawn (no-op for builtin/npx/uvx/local). @@ -373,7 +388,10 @@ export const startLocalAcpAgent = async (options: StartLocalAcpAgentOptions) => isResume: false, }; - const baseEnv = withoutElectronBootstrapCredentials(options.env ?? process.env); + const baseEnv = await resolveAccountProfileEnv({ + ...options, + env: withoutElectronBootstrapCredentials(options.env ?? process.env), + }); // Codex CLI reads config from `~/.codex` by default. E2E and title-agent runs use a temporary, // repo-local Codex home so their rollout/history state stays isolated. A title agent copies the // user's config into that home because custom model-provider routing and authentication must stay @@ -394,17 +412,20 @@ export const startLocalAcpAgent = async (options: StartLocalAcpAgentOptions) => // withLoopbackNoProxy runs outermost so a proxy contributed by the login // shell is covered too: the agent reaches Lody's MCP HTTP host over // loopback, and a proxy that intercepts that kills MCP entirely. - const mergedStartupEnv = withLoopbackNoProxy( - withoutElectronBootstrapCredentials( - withLodyNpmCacheForNpx( - launch.command, - withDefaultAcpPathEntries( - mergeACPProcessEnv(launch, mergeLoginShellEnv(env, loginShellEnv)), - options.agentType + const mergedStartupEnv = await resolveAccountProfileEnv({ + ...options, + env: withLoopbackNoProxy( + withoutElectronBootstrapCredentials( + withLodyNpmCacheForNpx( + launch.command, + withDefaultAcpPathEntries( + mergeACPProcessEnv(launch, mergeLoginShellEnv(env, loginShellEnv)), + options.agentType + ) ) ) - ) - ); + ), + }); const envWithAcpStartup = isTitleAgentCodexRun ? withTitleAgentCodexConfig(mergedStartupEnv) : mergedStartupEnv; diff --git a/apps/cli/src/agent/claude-env-conflict.ts b/apps/cli/src/agent/claude-env-conflict.ts index 622f7088e..fd7ba7472 100644 --- a/apps/cli/src/agent/claude-env-conflict.ts +++ b/apps/cli/src/agent/claude-env-conflict.ts @@ -42,6 +42,20 @@ const CLAUDE_AUTH_ROUTING_KEYS = [ 'CLAUDE_CODE_SUBAGENT_MODEL', ] as const; +/** A managed subscription account must not inherit another authentication route. */ +export function scrubManagedClaudeAccountEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const result = { ...env }; + for (const key of Object.keys(result)) { + if ( + /^(ANTHROPIC_|CLAUDE_CODE_(USE_|SKIP_))/.test(key.toUpperCase()) || + key.toUpperCase() === 'CLAUDE_CODE_OAUTH_TOKEN' + ) { + delete result[key]; + } + } + return result; +} + /** * Keys that, when present in the user's agent config, signal explicit * auth/routing intent. Setting one of these means "I am choosing how Claude diff --git a/apps/cli/src/agent/title-generator.ts b/apps/cli/src/agent/title-generator.ts index 7fdc35593..912bb70ae 100644 --- a/apps/cli/src/agent/title-generator.ts +++ b/apps/cli/src/agent/title-generator.ts @@ -279,6 +279,7 @@ const noopTerminalManager: TerminalManager = { }; export type GenerateTitleOptions = { + accountProfileId?: string; cliType: AgentConfigCliType; agentType: AgentType; customAcp?: CustomAcpLaunchSpec; @@ -306,6 +307,7 @@ export const generateTitleIsolated = async ( const { agentProcess, client, acpSessionId, sessionResponse } = await startLocalAcpAgent({ cliType: options.cliType, agentType: options.agentType, + ...(options.accountProfileId ? { accountProfileId: options.accountProfileId } : {}), customAcp: options.customAcp, runtimeOverrides: options.runtimeOverrides, workdir, diff --git a/apps/cli/src/claude-acp-entry.ts b/apps/cli/src/claude-acp-entry.ts index 84df35781..a1f3a3bd4 100644 --- a/apps/cli/src/claude-acp-entry.ts +++ b/apps/cli/src/claude-acp-entry.ts @@ -1,15 +1,26 @@ import { resolveSettings } from '@anthropic-ai/claude-agent-sdk'; import { runAcp } from 'acp-extension-claude'; +import { scrubManagedClaudeAccountEnv } from './agent/claude-env-conflict'; if (!process.env.CLAUDE_CODE_EXECUTABLE?.trim()) { console.error('CLAUDE_CODE_EXECUTABLE is required for the bundled Claude ACP adapter.'); process.exit(1); } +const accountConfigDir = process.env.LODY_ACCOUNT_PROFILE_ID + ? process.env.CLAUDE_CONFIG_DIR + : undefined; const policy = await resolveSettings({ settingSources: [] }); for (const [key, value] of Object.entries(policy.effective.env ?? {})) { process.env[key] = value; } +if (accountConfigDir !== undefined) { + const isolated = scrubManagedClaudeAccountEnv(process.env); + for (const key of Object.keys(process.env)) { + if (!(key in isolated)) delete process.env[key]; + } + process.env.CLAUDE_CONFIG_DIR = accountConfigDir; +} // ACP uses stdout for protocol messages. Keep diagnostics on stderr. console.log = console.error; diff --git a/apps/cli/src/lib/code-collab/code-collab-v2-diff-evidence.test.ts b/apps/cli/src/lib/code-collab/code-collab-v2-diff-evidence.test.ts index 3a690c8ba..0efbfd1c6 100644 --- a/apps/cli/src/lib/code-collab/code-collab-v2-diff-evidence.test.ts +++ b/apps/cli/src/lib/code-collab/code-collab-v2-diff-evidence.test.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { @@ -7,14 +8,14 @@ import { pendingEventFromWriteTextFileEvidence, } from './code-collab-v2-diff-evidence'; -const WORKSPACE_ROOT = '/workspace'; +const WORKSPACE_ROOT = path.resolve('/workspace'); describe('Code Collab v2 ACP diff evidence normalization', () => { it('keeps Codex-style full-file standard diff evidence', async () => { const event = await pendingEventFromStandardDiffEvidence({ workspaceRoot: WORKSPACE_ROOT, diff: { - path: '/workspace/target.txt', + path: path.join(WORKSPACE_ROOT, 'target.txt'), oldText: 'alpha old line\nbeta stays\n', newText: 'alpha new line\nbeta stays\n', }, @@ -22,7 +23,7 @@ describe('Code Collab v2 ACP diff evidence normalization', () => { }); expect(event).toEqual({ - path: '/workspace/target.txt', + path: path.join(WORKSPACE_ROOT, 'target.txt'), oldText: 'alpha old line\nbeta stays\n', newText: 'alpha new line\nbeta stays\n', oldTextEvidence: 'strong', @@ -33,7 +34,7 @@ describe('Code Collab v2 ACP diff evidence normalization', () => { const event = await pendingEventFromStandardDiffEvidence({ workspaceRoot: WORKSPACE_ROOT, diff: { - path: '/workspace/target.txt', + path: path.join(WORKSPACE_ROOT, 'target.txt'), oldText: 'alpha old line', newText: 'alpha new line', }, @@ -41,7 +42,7 @@ describe('Code Collab v2 ACP diff evidence normalization', () => { }); expect(event).toEqual({ - path: '/workspace/target.txt', + path: path.join(WORKSPACE_ROOT, 'target.txt'), oldText: 'alpha old line\nbeta stays\n', newText: 'alpha new line\nbeta stays\n', oldTextEvidence: 'strong', @@ -52,7 +53,7 @@ describe('Code Collab v2 ACP diff evidence normalization', () => { const event = await pendingEventFromStandardDiffEvidence({ workspaceRoot: WORKSPACE_ROOT, diff: { - path: '/workspace/target.txt', + path: path.join(WORKSPACE_ROOT, 'target.txt'), oldText: 'old', newText: 'new', }, @@ -65,12 +66,12 @@ describe('Code Collab v2 ACP diff evidence normalization', () => { it('records fs/write_text_file evidence as strong full-file evidence', () => { expect( pendingEventFromWriteTextFileEvidence({ - path: '/workspace/target.txt', + path: path.join(WORKSPACE_ROOT, 'target.txt'), oldText: 'alpha old line\nbeta stays\n', newText: 'alpha new line\nbeta stays\n', }) ).toEqual({ - path: '/workspace/target.txt', + path: path.join(WORKSPACE_ROOT, 'target.txt'), oldText: 'alpha old line\nbeta stays\n', newText: 'alpha new line\nbeta stays\n', oldTextEvidence: 'strong', @@ -81,7 +82,7 @@ describe('Code Collab v2 ACP diff evidence normalization', () => { const event = await pendingEventFromStandardDiffEvidence({ workspaceRoot: WORKSPACE_ROOT, diff: { - path: '/workspace/target.txt', + path: path.join(WORKSPACE_ROOT, 'target.txt'), oldText: null, newText: 'alpha new line\n', }, @@ -94,13 +95,13 @@ describe('Code Collab v2 ACP diff evidence normalization', () => { it('lets later strong evidence repair a tentative standard-null oldText', () => { const merged = mergePendingDiffStoreEvents([ { - path: '/workspace/target.txt', + path: path.join(WORKSPACE_ROOT, 'target.txt'), oldText: null, newText: 'alpha new line\nbeta stays\n', oldTextEvidence: 'standard-null', }, { - path: '/workspace/target.txt', + path: path.join(WORKSPACE_ROOT, 'target.txt'), oldText: 'alpha old line\nbeta stays\n', newText: 'alpha new line\nbeta stays\n', oldTextEvidence: 'strong', @@ -109,7 +110,7 @@ describe('Code Collab v2 ACP diff evidence normalization', () => { expect(merged).toEqual([ { - path: '/workspace/target.txt', + path: path.join(WORKSPACE_ROOT, 'target.txt'), oldText: 'alpha old line\nbeta stays\n', newText: 'alpha new line\nbeta stays\n', }, @@ -119,13 +120,13 @@ describe('Code Collab v2 ACP diff evidence normalization', () => { it('keeps strong create evidence from fs/write_text_file across later edits', () => { const merged = mergePendingDiffStoreEvents([ { - path: '/workspace/target.txt', + path: path.join(WORKSPACE_ROOT, 'target.txt'), oldText: null, newText: 'draft\n', oldTextEvidence: 'strong', }, { - path: '/workspace/target.txt', + path: path.join(WORKSPACE_ROOT, 'target.txt'), oldText: 'draft\n', newText: 'final\n', oldTextEvidence: 'strong', @@ -134,7 +135,7 @@ describe('Code Collab v2 ACP diff evidence normalization', () => { expect(merged).toEqual([ { - path: '/workspace/target.txt', + path: path.join(WORKSPACE_ROOT, 'target.txt'), oldText: null, newText: 'final\n', }, @@ -146,12 +147,12 @@ describe('pendingEventFromAgentEditEvidence', () => { it('chains old text from the previous recorded state', async () => { const event = await pendingEventFromAgentEditEvidence({ workspaceRoot: WORKSPACE_ROOT, - edit: { path: '/workspace/a.ts', changeType: 'update' }, + edit: { path: path.join(WORKSPACE_ROOT, 'a.ts'), changeType: 'update' }, latestText: { status: 'tracked', text: 'old\n' }, readCurrentText: async () => 'new\n', }); expect(event).toEqual({ - path: '/workspace/a.ts', + path: path.join(WORKSPACE_ROOT, 'a.ts'), oldText: 'old\n', newText: 'new\n', oldTextEvidence: 'strong', @@ -161,7 +162,11 @@ describe('pendingEventFromAgentEditEvidence', () => { it('prefers the agent-reported pre-image over chaining', async () => { const event = await pendingEventFromAgentEditEvidence({ workspaceRoot: WORKSPACE_ROOT, - edit: { path: '/workspace/a.ts', changeType: 'update', contentOldText: 'reported old\n' }, + edit: { + path: path.join(WORKSPACE_ROOT, 'a.ts'), + changeType: 'update', + contentOldText: 'reported old\n', + }, latestText: { status: 'tracked', text: 'chained old\n' }, readCurrentText: async () => 'new\n', }); @@ -175,7 +180,12 @@ describe('pendingEventFromAgentEditEvidence', () => { it('reconstructs old text from a single fragment replacement', async () => { const event = await pendingEventFromAgentEditEvidence({ workspaceRoot: WORKSPACE_ROOT, - edit: { path: '/workspace/a.ts', changeType: 'update', oldString: 'foo', newString: 'bar' }, + edit: { + path: path.join(WORKSPACE_ROOT, 'a.ts'), + changeType: 'update', + oldString: 'foo', + newString: 'bar', + }, latestText: { status: 'untracked' }, readCurrentText: async () => 'x bar y\n', }); @@ -189,7 +199,7 @@ describe('pendingEventFromAgentEditEvidence', () => { it('treats a first-seen created file as added (old absent)', async () => { const event = await pendingEventFromAgentEditEvidence({ workspaceRoot: WORKSPACE_ROOT, - edit: { path: '/workspace/new.ts', changeType: 'add' }, + edit: { path: path.join(WORKSPACE_ROOT, 'new.ts'), changeType: 'add' }, latestText: { status: 'untracked' }, readCurrentText: async () => 'created\n', }); @@ -203,7 +213,7 @@ describe('pendingEventFromAgentEditEvidence', () => { it('rejects an untracked update with no pre-image instead of seeding a fake empty diff', async () => { const event = await pendingEventFromAgentEditEvidence({ workspaceRoot: WORKSPACE_ROOT, - edit: { path: '/workspace/a.ts', changeType: 'update' }, + edit: { path: path.join(WORKSPACE_ROOT, 'a.ts'), changeType: 'update' }, latestText: { status: 'untracked' }, readCurrentText: async () => 'whole file\n', }); @@ -213,7 +223,7 @@ describe('pendingEventFromAgentEditEvidence', () => { it('records a chained deletion as new=null', async () => { const event = await pendingEventFromAgentEditEvidence({ workspaceRoot: WORKSPACE_ROOT, - edit: { path: '/workspace/a.ts', changeType: 'delete' }, + edit: { path: path.join(WORKSPACE_ROOT, 'a.ts'), changeType: 'delete' }, latestText: { status: 'tracked', text: 'gone\n' }, readCurrentText: async () => null, }); @@ -227,7 +237,7 @@ describe('pendingEventFromAgentEditEvidence', () => { it('skips an untracked delete with no pre-image', async () => { const event = await pendingEventFromAgentEditEvidence({ workspaceRoot: WORKSPACE_ROOT, - edit: { path: '/workspace/a.ts', changeType: 'delete' }, + edit: { path: path.join(WORKSPACE_ROOT, 'a.ts'), changeType: 'delete' }, latestText: { status: 'untracked' }, readCurrentText: async () => null, }); diff --git a/apps/cli/src/lib/code-collab/code-collab-v2-service.test.ts b/apps/cli/src/lib/code-collab/code-collab-v2-service.test.ts index ee98998c9..fc17a75ea 100644 --- a/apps/cli/src/lib/code-collab/code-collab-v2-service.test.ts +++ b/apps/cli/src/lib/code-collab/code-collab-v2-service.test.ts @@ -222,42 +222,49 @@ describe('CodeCollabV2Service text RPC boundary', () => { const publishStarted = new Promise((resolve) => { resolvePublishStarted = resolve; }); + let releasePublish: (() => void) | undefined; + const publishReleased = new Promise((resolve) => { + releasePublish = resolve; + }); + let resolvePublishFinished: (() => void) | undefined; + const publishFinished = new Promise((resolve) => { + resolvePublishFinished = resolve; + }); const service = new CodeCollabV2Service({ resolveWorkspace: makeResolver(workspaceRoot), publishFileIndex: async () => { resolvePublishStarted?.(); - await new Promise(() => undefined); + await publishReleased; }, + publishFileIndexSignal: async () => resolvePublishFinished?.(), }); - const opened = await service.openText({ sessionId: SESSION_ID, path: 'hello.ts' }); - - const saved = await Promise.race([ - service.saveText({ + try { + // Use the known fixture digest so openText does not start an independent + // reconciliation that could still hold the workspace during cleanup. + const saved = await service.saveText({ sessionId: SESSION_ID, requestedByUserId: 'user-1', path: 'hello.ts', - baseDigest: opened.digest, + baseDigest: digestText('old\n'), text: { encoding: 'plain', text: 'new\n', rawBytes: Buffer.byteLength('new\n'), }, - }), - new Promise((_, reject) => { - setTimeout(() => { - reject(new Error('saveText did not return after writing to disk')); - }, 250); - }), - ]); - - expect(saved).toEqual({ - status: 'ok', - path: 'hello.ts', - digest: digestText('new\n'), - rawBytes: Buffer.byteLength('new\n'), - }); - expect(await readFile(filePath, 'utf8')).toBe('new\n'); - await publishStarted; + }); + await publishStarted; + expect(saved).toEqual({ + status: 'ok', + path: 'hello.ts', + digest: digestText('new\n'), + rawBytes: Buffer.byteLength('new\n'), + }); + expect(await readFile(filePath, 'utf8')).toBe('new\n'); + } finally { + releasePublish?.(); + await publishFinished; + service.dispose(); + } }); }); diff --git a/apps/cli/src/lib/code-collab/file-index-scan-core.test.ts b/apps/cli/src/lib/code-collab/file-index-scan-core.test.ts new file mode 100644 index 000000000..1af9e667f --- /dev/null +++ b/apps/cli/src/lib/code-collab/file-index-scan-core.test.ts @@ -0,0 +1,38 @@ +import { setImmediate } from 'node:timers/promises'; +import { describe, expect, it, vi } from 'vitest'; + +const execGit = vi.hoisted(() => vi.fn()); +vi.mock('node:child_process', async () => { + const { promisify } = await import('node:util'); + return { execFile: Object.assign(vi.fn(), { [promisify.custom]: execGit }) }; +}); + +import { scanGitDirectoryEntries } from './file-index-scan-core'; + +describe('Git scan subprocess lifecycle', () => { + it('waits for the sibling Git process before returning a failed-listing fallback', async () => { + const { promise: deleted, resolve: finishDeleted } = Promise.withResolvers<{ + stdout: string; + }>(); + execGit.mockRejectedValueOnce(new Error('not a Git repository')).mockReturnValueOnce(deleted); + + let settled = false; + const scan = scanGitDirectoryEntries({ + directoryAbsolutePath: 'synthetic-workspace', + directoryWorkspacePath: '', + entryBudget: 100, + recursive: true, + }).finally(() => { + settled = true; + }); + try { + // Flush promise reactions through an event-loop boundary, with no clock + // delay. The sibling process remains held by the explicit deferred result. + await setImmediate(); + expect(settled).toBe(false); + } finally { + finishDeleted({ stdout: '' }); + } + await expect(scan).resolves.toBeNull(); + }); +}); diff --git a/apps/cli/src/lib/code-collab/file-index-scan-core.ts b/apps/cli/src/lib/code-collab/file-index-scan-core.ts index fd06062b0..ecca01aa0 100644 --- a/apps/cli/src/lib/code-collab/file-index-scan-core.ts +++ b/apps/cli/src/lib/code-collab/file-index-scan-core.ts @@ -56,7 +56,9 @@ async function runGitLsFiles( cwd: string ): Promise<{ readonly ok: true; readonly paths: readonly string[] } | { readonly ok: false }> { try { - const [{ stdout }, deleted] = await Promise.all([ + // Drain both child processes even if one fails: a non-Git fallback must not + // return while the other Git process still holds the workspace directory. + const [listingResult, deletedResult] = await Promise.allSettled([ execFileAsync( 'git', [ @@ -75,6 +77,11 @@ async function runGitLsFiles( ), runGit(cwd, ['ls-files', '--deleted', '-z', '--', '.']), ]); + if (listingResult.status === 'rejected' || deletedResult.status === 'rejected') { + return { ok: false }; + } + const { stdout } = listingResult.value; + const deleted = deletedResult.value; const deletedPaths = deleted.ok ? new Set(deleted.stdout.split('\0').map(normalizeGitPath).filter(isValidRelativeGitPath)) : new Set(); diff --git a/apps/cli/src/lib/code-collab/workspace-watch-worker-core.test.ts b/apps/cli/src/lib/code-collab/workspace-watch-worker-core.test.ts index 9fe833946..db99602a1 100644 --- a/apps/cli/src/lib/code-collab/workspace-watch-worker-core.test.ts +++ b/apps/cli/src/lib/code-collab/workspace-watch-worker-core.test.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import { EventEmitter } from 'node:events'; import type { FSWatcher } from 'node:fs'; import { describe, expect, it, vi } from 'vitest'; @@ -120,12 +121,14 @@ describe('workspace watch worker core', () => { expect(watched).toEqual([ { directory: '/workspace', recursive: false }, - { directory: '/workspace/apps', recursive: true }, - { directory: '/workspace/packages', recursive: true }, + { directory: path.join('/workspace', 'apps'), recursive: true }, + { directory: path.join('/workspace', 'packages'), recursive: true }, ]); // The whole point: no inotify tree over these. - expect(watched.map((entry) => entry.directory)).not.toContain('/workspace/node_modules'); - expect(watched.map((entry) => entry.directory)).not.toContain('/workspace/.git'); + expect(watched.map((entry) => entry.directory)).not.toContain( + path.join('/workspace', 'node_modules') + ); + expect(watched.map((entry) => entry.directory)).not.toContain(path.join('/workspace', '.git')); worker.close(); }); @@ -155,12 +158,12 @@ describe('workspace watch worker core', () => { revision: 1, roots: ['/workspace'], }); - expect(watched).toEqual(['/workspace', '/workspace/apps']); + expect(watched).toEqual(['/workspace', path.join('/workspace', 'apps')]); // A nested path cannot change the top-level set, so no re-plan. rootCallbacks[0]?.('change', 'apps/web/src/main.ts'); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(watched).toEqual(['/workspace', '/workspace/apps']); + expect(watched).toEqual(['/workspace', path.join('/workspace', 'apps')]); // A new top-level directory must get its own recursive watch. entries = [ @@ -172,10 +175,10 @@ describe('workspace watch worker core', () => { expect(watched).toEqual([ '/workspace', - '/workspace/apps', + path.join('/workspace', 'apps'), '/workspace', - '/workspace/apps', - '/workspace/services', + path.join('/workspace', 'apps'), + path.join('/workspace', 'services'), ]); worker.close(); }); diff --git a/apps/cli/src/lib/loro/doc.ts b/apps/cli/src/lib/loro/doc.ts index 9ba671e9f..f1a6c8cfc 100644 --- a/apps/cli/src/lib/loro/doc.ts +++ b/apps/cli/src/lib/loro/doc.ts @@ -274,6 +274,7 @@ export type LoroRepoPersistReason = | 'remote-meta-sync' | 'remote-flock-sync' | 'session-local-base-ref' + | 'session-account-handoff' /** One flush standing in for several remote sync events; see `scheduleRemoteSyncPersist`. */ | 'remote-sync-coalesced' | 'session-fork-prepare' diff --git a/apps/cli/src/lib/loro/sqlite-repo-store.test.ts b/apps/cli/src/lib/loro/sqlite-repo-store.test.ts index c8ba22795..26f8dcdd6 100644 --- a/apps/cli/src/lib/loro/sqlite-repo-store.test.ts +++ b/apps/cli/src/lib/loro/sqlite-repo-store.test.ts @@ -1,7 +1,7 @@ import fs from 'fs/promises'; import os from 'os'; import path from 'path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { JsonObject, RemoteCursor } from '@loro-dev/streams-crdt'; import { DEFAULT_LORO_STREAMS_BASE_URL, @@ -119,10 +119,9 @@ describe('SQLite Loro repo store', () => { }); it('creates a workspace-scoped SQLite repo store under the Lody storage directory', async () => { - const previousHome = process.env.HOME; const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'lody-sqlite-repo-home-')); createdPaths.add(tempHome); - process.env.HOME = tempHome; + const homeSpy = vi.spyOn(os, 'homedir').mockReturnValue(tempHome); try { const workspaceId = 'workspace-1' as WorkspaceId; @@ -134,11 +133,7 @@ describe('SQLite Loro repo store', () => { expect(getLoroRepoStorageBaseDir(workspaceId)).toBe(cliStore.baseDir); expect(getLoroRepoSqliteDbPath(workspaceId)).toBe(cliStore.dbPath); } finally { - if (previousHome === undefined) { - delete process.env.HOME; - } else { - process.env.HOME = previousHome; - } + homeSpy.mockRestore(); } }); }); diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index 807444bcc..350cc352d 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -1,3 +1,11 @@ +import { getRateLimitEntryKey, resolveAccountProfileId } from '@lody/shared'; +import type { + MachineAccountProfilesRequest, + MachineAccountProfilesResponse, + SessionAccountSwitchRequest, + SessionAccountSwitchResponse, +} from '@lody/shared'; +import { createAccountProfile, listAccountProfiles } from '@/agent/account-profiles'; import os from 'os'; import fs from 'fs'; import path from 'path'; @@ -674,13 +682,49 @@ type ConversationTurnGateContext = { deferACPUpdateTarget?: boolean; }; +async function openSessionUploadFile(absolutePath: string): Promise { + if (fs.constants.O_NOFOLLOW) { + return await fs.promises.open(absolutePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + } + + // Windows has no O_NOFOLLOW. Compare the path before/after opening with the + // descriptor, using bigint inode identities to avoid precision loss. + const before = await fs.promises.lstat(absolutePath, { bigint: true }); + if (before.isSymbolicLink()) { + throw Object.assign(new Error('Upload path is a symlink'), { code: 'ELOOP' }); + } + if (!before.isFile()) throw new Error('Upload path is not a file'); + const handle = await fs.promises.open(absolutePath, fs.constants.O_RDONLY); + try { + const opened = await handle.stat({ bigint: true }); + const after = await fs.promises.lstat(absolutePath, { bigint: true }); + if (after.isSymbolicLink()) { + throw Object.assign(new Error('Upload path became a symlink'), { code: 'ELOOP' }); + } + if ( + !opened.isFile() || + !after.isFile() || + before.dev !== opened.dev || + before.ino !== opened.ino || + after.dev !== opened.dev || + after.ino !== opened.ino + ) { + throw new Error('Upload file changed while opening'); + } + return handle; + } catch (error) { + await handle.close(); + throw error; + } +} + type UploadableImageFile = { absolutePath: string; fileName: string; mimeType: string; sizeBytes: number; // Bytes are read inside the validation function while the file is open with - // O_NOFOLLOW. Carrying them to upload avoids a second `readFile` that would + // symlink protection. Carrying them to upload avoids a second `readFile` that would // re-open the path and follow a symlink swapped in after validation (TOCTOU). bytes: Buffer; }; @@ -1748,15 +1792,12 @@ export class MessageHandler { } const absolutePath = path.resolve(trimmed); - // O_NOFOLLOW makes the open() fail with ELOOP if the final path component is a - // symlink. We then fstat / read through the same fd, so an attacker who swaps + // Reject a symlink final component. We then fstat / read through the same fd, + // so an attacker who swaps // the file after validation cannot redirect us at a different inode. let handle: fs.promises.FileHandle; try { - handle = await fs.promises.open( - absolutePath, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW - ); + handle = await openSessionUploadFile(absolutePath); } catch (error) { const code = (error as NodeJS.ErrnoException | undefined)?.code; if (code === 'ELOOP') { @@ -3036,6 +3077,7 @@ export class MessageHandler { runAutoPrompt: async (ctx) => await this.autoPromptRunner.run(ctx), }); this.executionService = new SessionExecutionService({ + resolveAccountSwitchUser: (userId) => this.sessionUserResolver.resolve(userId), logger: this.logger, sessionManager: this.sessionManager, workspaceDocument: this.workspaceDocument, @@ -3264,7 +3306,22 @@ export class MessageHandler { }, { onAcpBinaryProgress, signal } ), + accountProfiles: async (params) => + this.handleAccountProfiles({ + ...params, + type: 'machine/account-profiles', + machineId: this.machineId, + workspaceId: this.workspaceId, + }), + switchSessionAccount: async (params) => + this.handleAccountSwitch({ + ...params, + type: 'session/account-switch', + machineId: this.machineId, + workspaceId: this.workspaceId, + }), authenticateMachineAcp: async ({ + accountProfileId, requestId, action, authenticationRequestId, @@ -3280,6 +3337,7 @@ export class MessageHandler { await this.authenticateMachineAcpAndResumeSetup( { type: 'machine/acp-authenticate', + accountProfileId, machineId: this.machineId, workspaceId: this.workspaceId, requestId, @@ -3668,8 +3726,22 @@ export class MessageHandler { this.sessionManager.on( 'onRateLimitUpdate', - (machineId: MachineId, cliType: CliType, limits: RateLimit) => { - void this.workspaceDocument.updateRateLimits(machineId, cliType, limits); + ( + machineId: MachineId, + cliType: CliType, + limits: RateLimit, + accountProfileId?: string, + sessionId?: SessionId + ) => { + if (resolveAccountProfileId(accountProfileId) === 'system-default') { + void this.workspaceDocument.updateRateLimits(machineId, cliType, limits); + } else if (sessionId && accountProfileId) { + this.enqueueSessionNoticeHistoryPersist(sessionId, () => + this.persistAccountRateLimit(sessionId, accountProfileId, cliType, limits).catch( + () => {} + ) + ); + } } ); @@ -6682,6 +6754,12 @@ export class MessageHandler { case 'machine/acp-capabilities-refresh': await this.handleMachineAcpCapabilitiesRefresh(message, context); break; + case 'machine/account-profiles': + context.send(await this.handleAccountProfiles(message)); + break; + case 'session/account-switch': + context.send(await this.handleAccountSwitch(message)); + break; case 'machine/acp-authenticate': await this.handleMachineAcpAuthenticate(message, context); break; @@ -6983,8 +7061,8 @@ export class MessageHandler { } /** - * Validate a file path for the agent-send upload: open with O_NOFOLLOW (no - * symlink final component), reject non-files/empty/oversize, compute sha256 + * Validate a file path for the agent-send upload: reject a symlink final + * component, reject non-files/empty/oversize, compute sha256 * and text-previewability by streaming (bounded memory). */ private async validateSessionFileUploadPath( @@ -7010,10 +7088,7 @@ export class MessageHandler { let handle: fs.promises.FileHandle; try { - handle = await fs.promises.open( - absolutePath, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW - ); + handle = await openSessionUploadFile(absolutePath); } catch (error) { const code = (error as NodeJS.ErrnoException | undefined)?.code; if (code === 'ELOOP') { @@ -8234,6 +8309,100 @@ export class MessageHandler { dispatchContext.send(response); } + private async persistAccountRateLimit( + sessionId: SessionId, + accountProfileId: string, + cliType: CliType, + limits: RateLimit + ): Promise { + const doc = await this.workspaceDocument.getOrCreateSessionDoc(sessionId); + const meta = await doc.getMetaState(); + if (meta?.accountProfileId !== accountProfileId) return; + const previous = + meta.accountRateLimits?.accountProfileId === accountProfileId + ? meta.accountRateLimits.limits + : {}; + await this.workspaceDocument.repo.upsertDocMeta(getSessionRoomId(sessionId), { + accountRateLimits: { + accountProfileId, + limits: { ...previous, [getRateLimitEntryKey(cliType, limits.limitId)]: limits }, + }, + }); + } + + private async handleAccountProfiles( + message: MachineAccountProfilesRequest + ): Promise { + const base = { + type: 'machine/account-profiles_response' as const, + machineId: this.machineId, + requestId: message.requestId, + }; + try { + if (message.machineId !== this.machineId || message.workspaceId !== this.workspaceId) + throw new Error('Account profile machine or workspace mismatch'); + const config = message.configId + ? await this.workspaceDocument.getAgentConfigById(message.configId) + : undefined; + if ( + message.configId && + (!config || + config.agentType !== message.agentType || + config.cliType !== 'builtin' || + config.machineId !== this.machineId) + ) + throw new Error('Account provider configuration is unavailable'); + const input = { + cliType: message.cliType, + agentType: message.agentType, + env: config?.env, + runtimeOverrides: config?.runtimeOverrides, + logger: this.logger, + }; + if (message.action === 'create') { + if (!message.label?.trim()) throw new Error('A new account requires a label'); + const profile = await createAccountProfile({ + ...input, + label: message.label, + operationId: `${this.workspaceId}/${message.requestId}`, + }); + return { ...base, success: true, profiles: [profile] }; + } + return { ...base, success: true, profiles: await listAccountProfiles(input) }; + } catch { + return { + ...base, + success: false, + error: + 'Account profiles could not be read or created. Check the provider configuration and retry.', + }; + } + } + + private async handleAccountSwitch( + message: SessionAccountSwitchRequest + ): Promise { + const base = { + type: 'session/account-switch_response' as const, + machineId: this.machineId, + requestId: message.requestId, + sessionId: message.sessionId, + }; + try { + if (message.machineId !== this.machineId || message.workspaceId !== this.workspaceId) + throw new Error('Account switch machine or workspace mismatch'); + const result = await this.executionService.switchAccount(message); + return { + ...base, + success: true, + accountProfileId: result.accountProfileId, + continuation: result.continuation, + }; + } catch (error) { + return { ...base, success: false, error: formatErrorMessage(error) }; + } + } + private async handleMachineAcpAuthenticate( message: MachineAcpAuthenticateRequestValidated, dispatchContext: MessageDispatchContext = this.createRuntimeDispatchContext() @@ -8251,6 +8420,7 @@ export class MessageHandler { const response = await this.executionService.authenticateMachineAcp(message, options); if ( message.action === 'start' && + (!message.accountProfileId || message.accountProfileId === 'system-default') && message.configId && response.success && response.disposition === 'authenticated' @@ -8858,6 +9028,7 @@ export class MessageHandler { logger: this.logger, env, titleConfig: resolvedTitleConfig, + accountProfileId: meta?.accountProfileId, }); if (!title) { this.logger.debug(`[${sessionId}] Session title generation returned empty result`); @@ -9520,12 +9691,14 @@ export class MessageHandler { let metaCustomAcp: CustomAcpLaunchSpec | undefined; let metaRuntimeOverrides: BuiltinRuntimeOverrides | undefined; let metaAgentConfigId: AgentConfigId | undefined; + let accountProfileId: string | undefined; let reusableTitlePromise: Promise | undefined; try { const sessionDoc = await this.workspaceDocument.getOrCreateSessionDoc(sessionId); const meta = await sessionDoc.getMetaState(); metaBranchName = meta?.branchName?.trim() || null; metaAgentConfigId = meta?.agentConfigId; + accountProfileId = meta?.accountProfileId; const generatedMetaTitle = meta?.titleSource === 'generated' ? meta.title?.trim() : ''; reusableTitlePromise = generatedMetaTitle ? Promise.resolve(generatedMetaTitle) @@ -9563,7 +9736,8 @@ export class MessageHandler { resolvedTitleConfig, metaCustomAcp, metaRuntimeOverrides, - reusableTitlePromise + reusableTitlePromise, + accountProfileId ); if (!branchName) { this.logger.debug(`[${sessionId}] Skipping branch rename: name generation timed out`); @@ -9617,7 +9791,8 @@ export class MessageHandler { titleConfig?: TitleGenerationConfig, customAcp?: CustomAcpLaunchSpec, runtimeOverrides?: BuiltinRuntimeOverrides, - reusableTitlePromise?: Promise + reusableTitlePromise?: Promise, + accountProfileId?: string ): Promise { let timeoutHandle: NodeJS.Timeout | null = null; const timeoutPromise = new Promise((resolve) => { @@ -9636,6 +9811,7 @@ export class MessageHandler { logger: this.logger, env, titleConfig, + accountProfileId, }); const base = title ?? taskPrompt; return ensureValidBranchName(base, 'task'); diff --git a/apps/cli/src/lib/pr-poller/pr-poller-state.test.ts b/apps/cli/src/lib/pr-poller/pr-poller-state.test.ts index cecd28acc..0821127f0 100644 --- a/apps/cli/src/lib/pr-poller/pr-poller-state.test.ts +++ b/apps/cli/src/lib/pr-poller/pr-poller-state.test.ts @@ -162,6 +162,8 @@ describe('PrPollerStateStore', () => { expect(store.load()).toEqual(emptyPrPollerState()); store.upsertTarget('t1', { lastSuccessAtMs: 1 }); expect(store.load().targets['t1']).toEqual({ lastSuccessAtMs: 1 }); + store.close(); + expect(makeStore().load().targets['t1']).toEqual({ lastSuccessAtMs: 1 }); }); it('close() is idempotent and the store reopens lazily afterwards', () => { diff --git a/apps/cli/src/lib/pr-poller/pr-poller-state.ts b/apps/cli/src/lib/pr-poller/pr-poller-state.ts index a739c84f1..275373f2b 100644 --- a/apps/cli/src/lib/pr-poller/pr-poller-state.ts +++ b/apps/cli/src/lib/pr-poller/pr-poller-state.ts @@ -127,9 +127,10 @@ export class PrPollerStateStore { lastErrorKind: row.last_error_kind, }; } - for (const row of db - .prepare('SELECT key, last_success_at_ms FROM targets') - .all() as Array<{ key: string; last_success_at_ms: number }>) { + for (const row of db.prepare('SELECT key, last_success_at_ms FROM targets').all() as Array<{ + key: string; + last_success_at_ms: number; + }>) { state.targets[row.key] = { lastSuccessAtMs: row.last_success_at_ms }; } for (const row of db @@ -250,9 +251,10 @@ export class PrPollerStateStore { private open(): Database.Database { mkdirSync(path.dirname(this.dbPath), { recursive: true }); const db = new Database(this.dbPath); - db.pragma('busy_timeout = 5000'); - db.pragma('journal_mode = WAL'); - db.exec(` + try { + db.pragma('busy_timeout = 5000'); + db.pragma('journal_mode = WAL'); + db.exec(` CREATE TABLE IF NOT EXISTS scopes ( scope TEXT PRIMARY KEY, tokens REAL NOT NULL, @@ -274,7 +276,13 @@ export class PrPollerStateStore { fingerprint TEXT NOT NULL ); `); - return db; + return db; + } catch (error) { + // Initialization can fail after SQLite has opened a corrupt file. Release + // that handle before ensureDb attempts to replace it, including on Windows. + db.close(); + throw error; + } } /** diff --git a/apps/cli/src/lib/session-file-attachments.test.ts b/apps/cli/src/lib/session-file-attachments.test.ts index fd6650306..8526fc19a 100644 --- a/apps/cli/src/lib/session-file-attachments.test.ts +++ b/apps/cli/src/lib/session-file-attachments.test.ts @@ -182,10 +182,10 @@ describe('resolveContainedUploadPath', () => { const inside = path.join(nested, 'a.txt'); fs.writeFileSync(inside, 'x'); await expect(resolveContainedUploadPath(inside, root)).resolves.toBe( - path.join(fs.realpathSync(nested), 'a.txt') + await fs.promises.realpath(inside) ); await expect(resolveContainedUploadPath(path.join('sub', 'a.txt'), root)).resolves.toBe( - path.join(fs.realpathSync(nested), 'a.txt') + await fs.promises.realpath(inside) ); }); diff --git a/apps/cli/src/lib/session-file-blob-store.test.ts b/apps/cli/src/lib/session-file-blob-store.test.ts index fbc7e0338..41acb1ed9 100644 --- a/apps/cli/src/lib/session-file-blob-store.test.ts +++ b/apps/cli/src/lib/session-file-blob-store.test.ts @@ -99,8 +99,11 @@ describe('session file blob store', () => { const { destPath: dest, warn } = await copyIntoSessionFileBlobStore(args); expect(warn).toBe(false); expect(await readFile(dest, 'utf8')).toBe('hello'); - expect((await stat(getSessionFileBlobDir(args))).mode & 0o777).toBe(0o700); - expect((await stat(dest)).mode & 0o777).toBe(0o600); + // POSIX modes do not establish or verify Windows ACLs. + if (process.platform !== 'win32') { + expect((await stat(getSessionFileBlobDir(args))).mode & 0o777).toBe(0o700); + expect((await stat(dest)).mode & 0o777).toBe(0o600); + } expect( await sessionFileBlobExists({ workspaceId: 'ws', diff --git a/apps/cli/src/mcp/lody-mcp-server.test.ts b/apps/cli/src/mcp/lody-mcp-server.test.ts index 045588eba..bd3ee147d 100644 --- a/apps/cli/src/mcp/lody-mcp-server.test.ts +++ b/apps/cli/src/mcp/lody-mcp-server.test.ts @@ -179,8 +179,9 @@ describe('lody_upload_files input schema', () => { describe('resolveUploadPath', () => { it('keeps absolute paths and resolves relative ones against the workdir', () => { - const workdir = '/tmp/workspace'; - expect(resolveUploadPath('/abs/x.txt', workdir)).toBe('/abs/x.txt'); + const workdir = path.resolve('/tmp/workspace'); + const absolutePath = path.resolve('/abs/x.txt'); + expect(resolveUploadPath(absolutePath, workdir)).toBe(absolutePath); expect(resolveUploadPath('sub/x.txt', workdir)).toBe(path.join(workdir, 'sub/x.txt')); }); }); diff --git a/apps/cli/src/orchestration/operation-store.test.ts b/apps/cli/src/orchestration/operation-store.test.ts index 2575012d2..6619b2a89 100644 --- a/apps/cli/src/orchestration/operation-store.test.ts +++ b/apps/cli/src/orchestration/operation-store.test.ts @@ -58,19 +58,23 @@ afterEach(async () => { }); describe('LodyOperationStore', () => { - it('restricts the store directory and database to the local account', async () => { - const root = await mkdtemp(path.join(os.tmpdir(), 'lody-operation-store-permissions-')); - roots.add(root); - await chmod(root, 0o755); - const dbPath = path.join(root, 'operations.sqlite3'); - const store = new LodyOperationStore(dbPath); - try { - expect((await stat(root)).mode & 0o777).toBe(0o700); - expect((await stat(dbPath)).mode & 0o777).toBe(0o600); - } finally { - store.close(); + // POSIX modes do not establish or verify Windows ACLs. + it.skipIf(process.platform === 'win32')( + 'restricts the store directory and database with POSIX modes', + async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'lody-operation-store-permissions-')); + roots.add(root); + await chmod(root, 0o755); + const dbPath = path.join(root, 'operations.sqlite3'); + const store = new LodyOperationStore(dbPath); + try { + expect((await stat(root)).mode & 0o777).toBe(0o700); + expect((await stat(dbPath)).mode & 0o777).toBe(0o600); + } finally { + store.close(); + } } - }); + ); it('accepts once and returns the same Operation for canonical-equivalent retries', async () => { const store = await makeStore(); diff --git a/apps/cli/src/session/AGENTS.md b/apps/cli/src/session/AGENTS.md index 923430a13..f6e66112c 100644 --- a/apps/cli/src/session/AGENTS.md +++ b/apps/cli/src/session/AGENTS.md @@ -190,6 +190,21 @@ delegation proofs or a shared-machine gate without a new product and security de session/preparation producers but deliberately leaves the document manager and credentials alive so MessageHandler can flush final ACP/Code Collab evidence; the later plain `cleanUp()` closes shared resources. Never restore document teardown ahead of session termination. +- `session-account-handoff.ts` owns explicit account switching between requests. The committed + `SessionMeta.accountProfileId` plus `acpSessionId` is authoritative; missing account ids resolve + to `system-default`, and pending `accountHandoff` intent never overrides that pair on restart. + Hold the existing execution rewrite barrier through validation, local checkpoint, provider + teardown and replacement, and commit with `persistPendingChanges`. Candidate starts defer + ACP id persistence. A fresh provider session keeps an `accountContinuation` marker until its + next successful prompt consumes the existing bounded history replay; full Lody history stays + intact. Provider auth resolution stays in `agent/account-profiles.ts`; System Default follows + the existing environment unchanged. Prepared default processes cannot satisfy managed-account + launches, and account rate-limit events carry both session and account identity. + The handoff also refuses pending/running exec and ACP terminals after a turn ends. Candidate + notifications and interactive requests stay suppressed until commit. Managed processes hold + the provider's process-wide account-use lease across startup and lifetime; sign-in holds the + matching exclusive lease. Completed switch request ids remain in transition receipts so a + replayed older RPC cannot undo a later account choice. - `session-preparation-service.ts` — process-local speculative ACP lease/state owner. Peek/claim are synchronous published-resource snapshots and must never delay cold fallback; peek never transfers ownership. A prepared resource may reuse its open diff --git a/apps/cli/src/session/session-account-handoff.test.ts b/apps/cli/src/session/session-account-handoff.test.ts new file mode 100644 index 000000000..f4c88a7f7 --- /dev/null +++ b/apps/cli/src/session/session-account-handoff.test.ts @@ -0,0 +1,295 @@ +import { describe, expect, it } from 'vitest'; +import { + resolveAccountProfileId, + type ACPSessionId, + type SessionId, + type MachineId, + type SessionMeta, +} from '@lody/shared'; +import { switchSessionAccount, type AccountHandoffDeps } from './session-account-handoff'; + +const sessionId = 'session-a' as SessionId; +const oldProviderId = 'provider-old' as ACPSessionId; +const newProviderId = 'provider-new' as ACPSessionId; +const accountB = '00000000-0000-4000-8000-00000000000b'; +const accountC = '00000000-0000-4000-8000-00000000000c'; + +function harness(overrides: Partial = {}, initial: Partial = {}) { + let meta: SessionMeta = { + id: sessionId, + machineId: 'machine' as MachineId, + userId: 'user', + createdAt: '2026-01-01', + cliType: 'builtin', + agentType: 'codex', + acpSessionId: oldProviderId, + title: 'Original transcript', + branchName: 'original-branch', + ...initial, + }; + let durable = structuredClone(meta); + const events: string[] = []; + let locked = false; + const deps: AccountHandoffDeps = { + acquire: () => { + if (locked) return null; + locked = true; + return () => { + locked = false; + events.push('release'); + }; + }, + assertIdle: async () => {}, + read: async () => structuredClone(meta), + validate: async () => { + events.push('validate'); + }, + checkpoint: async (patch) => { + meta = { ...meta, ...patch }; + durable = structuredClone(meta); + events.push('checkpoint'); + }, + stop: async () => { + events.push('stop'); + }, + launch: async (_meta, target, resume) => { + events.push(`launch:${target}:${resume ?? 'fresh'}`); + return resume ?? newProviderId; + }, + isResumeFailure: (error) => error instanceof Error && error.message === 'ACP_RESUME_FAILED', + now: () => 42, + ...overrides, + }; + return { deps, events, read: () => meta, durable: () => durable, locked: () => locked }; +} + +describe('account handoff transaction', () => { + it('does not replay an older completed switch after a newer switch', async () => { + const h = harness(); + await switchSessionAccount( + { sessionId, accountProfileId: accountB, requestId: 'op-1' }, + h.deps + ); + await switchSessionAccount( + { sessionId, accountProfileId: accountC, requestId: 'op-2' }, + h.deps + ); + const beforeReplay = h.events.length; + const replay = await switchSessionAccount( + { sessionId, accountProfileId: accountB, requestId: 'op-1' }, + h.deps + ); + expect(replay.accountProfileId).toBe(accountC); + expect(h.read().accountProfileId).toBe(accountC); + expect(h.read().accountTransitions).toHaveLength(2); + expect(h.events.slice(beforeReplay)).toEqual(['release']); + await expect( + switchSessionAccount( + { sessionId, accountProfileId: 'system-default', requestId: 'op-1' }, + h.deps + ) + ).rejects.toThrow('different account'); + }); + it('commits native resume with legacy default binding without changing workspace metadata', async () => { + const h = harness(); + const result = await switchSessionAccount( + { sessionId, requestId: 'switch-1', accountProfileId: accountB }, + h.deps + ); + expect(result).toEqual({ + sessionId, + accountProfileId: accountB, + acpSessionId: oldProviderId, + continuation: false, + }); + expect(h.events).toEqual([ + 'validate', + 'checkpoint', + 'stop', + `launch:${accountB}:${oldProviderId}`, + 'checkpoint', + 'release', + ]); + expect(h.read()).toMatchObject({ + title: 'Original transcript', + branchName: 'original-branch', + accountProfileId: accountB, + accountHandoff: null, + }); + expect(h.read().accountTransitions?.[0]?.fromAccountProfileId).toBe('system-default'); + }); + + it('leaves legacy single-account bindings untouched when default is selected again', async () => { + const h = harness(); + await switchSessionAccount( + { sessionId, requestId: 'switch-1', accountProfileId: 'system-default' }, + h.deps + ); + expect(h.events).toEqual(['checkpoint', 'release']); + expect(h.read().accountProfileId).toBeUndefined(); + }); + + it('rejects a busy session before auth validation or process teardown', async () => { + const h = harness({ + assertIdle: async () => { + throw new Error('busy'); + }, + }); + await expect( + switchSessionAccount({ sessionId, requestId: 'switch-1', accountProfileId: accountB }, h.deps) + ).rejects.toThrow('busy'); + expect(h.events).toEqual(['release']); + expect(h.read().acpSessionId).toBe(oldProviderId); + }); + + it('preserves the existing process and binding when target authentication is invalid', async () => { + const h = harness({ + validate: async () => { + throw new Error('invalid auth'); + }, + }); + await expect( + switchSessionAccount({ sessionId, requestId: 'switch-1', accountProfileId: accountB }, h.deps) + ).rejects.toThrow('invalid auth'); + expect(h.events).toEqual(['release']); + expect(h.read().accountHandoff).toBeUndefined(); + }); + + it('uses a new provider session and durable continuation marker after classified resume failure', async () => { + const h = harness({ + launch: async (_meta, _target, resume) => { + if (resume) throw new Error('ACP_RESUME_FAILED'); + return newProviderId; + }, + }); + const result = await switchSessionAccount( + { sessionId, requestId: 'switch-1', accountProfileId: accountB }, + h.deps + ); + expect(result.continuation).toBe(true); + expect(h.durable()).toMatchObject({ + accountProfileId: accountB, + acpSessionId: newProviderId, + accountContinuation: { acpSessionId: newProviderId }, + }); + expect(h.durable().accountTransitions?.[0]).toMatchObject({ + fromAcpSessionId: oldProviderId, + toAcpSessionId: newProviderId, + }); + }); + + it('does not treat provider authentication, usage, or model failures as resume failures', async () => { + for (const failure of ['invalid auth', 'usage exhausted', 'model unavailable', 'CLI crash']) { + const h = harness({ + launch: async () => { + throw new Error(failure); + }, + }); + await expect( + switchSessionAccount( + { sessionId, requestId: 'switch-1', accountProfileId: accountB }, + h.deps + ) + ).rejects.toThrow(failure); + expect(h.durable()).toMatchObject({ + accountProfileId: 'system-default', + acpSessionId: oldProviderId, + accountHandoff: null, + }); + expect(h.read().accountTransitions).toEqual([]); + } + }); + + it('retains committed source identity at every uncommitted restart boundary', async () => { + const h = harness(); + const baseLaunch = h.deps.launch; + h.deps.launch = async (...args) => { + const recovered = h.durable(); + expect(resolveAccountProfileId(recovered.accountProfileId)).toBe('system-default'); + expect(recovered.acpSessionId).toBe(oldProviderId); + expect(recovered.accountHandoff?.targetAccountProfileId).toBe(accountB); + return await baseLaunch(...args); + }; + await switchSessionAccount( + { sessionId, requestId: 'switch-1', accountProfileId: accountB }, + h.deps + ); + expect(h.durable().accountProfileId).toBe(accountB); + }); + + it('retries an interrupted intent from the committed source, ignoring the abandoned target', async () => { + const h = harness( + {}, + { + accountHandoff: { + sourceAccountProfileId: 'system-default', + sourceAcpSessionId: oldProviderId, + targetAccountProfileId: accountC, + }, + } + ); + await switchSessionAccount( + { sessionId, requestId: 'switch-1', accountProfileId: accountB }, + h.deps + ); + expect(h.events).toContain(`launch:${accountB}:${oldProviderId}`); + expect(h.read().accountTransitions).toHaveLength(1); + }); + + it('stops the candidate and restores the pair if the final local commit fails', async () => { + const h = harness(); + const checkpoint = h.deps.checkpoint; + h.deps.checkpoint = async (patch) => { + await checkpoint(patch); + if (patch.accountProfileId === accountB) throw new Error('disk failure'); + }; + await expect( + switchSessionAccount({ sessionId, requestId: 'switch-1', accountProfileId: accountB }, h.deps) + ).rejects.toThrow('disk failure'); + expect(h.durable()).toMatchObject({ + accountProfileId: 'system-default', + acpSessionId: oldProviderId, + accountTransitions: [], + }); + expect(h.events.filter((event) => event === 'stop')).toHaveLength(2); + expect(h.locked()).toBe(false); + }); + + it('keeps concurrent sessions independent while rejecting a second switch on the same session', async () => { + const h = harness(); + let releaseValidation: () => void = () => {}; + h.deps.validate = async () => + await new Promise((resolve) => { + releaseValidation = resolve; + }); + const first = switchSessionAccount( + { sessionId, requestId: 'switch-1', accountProfileId: accountB }, + h.deps + ); + await Promise.resolve(); + await Promise.resolve(); + await expect( + switchSessionAccount({ sessionId, requestId: 'switch-1', accountProfileId: accountC }, h.deps) + ).rejects.toThrow('busy'); + const other = harness(); + await switchSessionAccount( + { sessionId, requestId: 'switch-1', accountProfileId: accountC }, + other.deps + ); + releaseValidation(); + await first; + expect(h.read().accountProfileId).toBe(accountB); + expect(other.read().accountProfileId).toBe(accountC); + }); + + it('rejects malformed account IDs before touching the session', async () => { + const h = harness(); + await expect( + switchSessionAccount( + { sessionId, requestId: 'switch-1', accountProfileId: '../../native-auth' }, + h.deps + ) + ).rejects.toThrow(); + expect(h.events).toEqual([]); + }); +}); diff --git a/apps/cli/src/session/session-account-handoff.ts b/apps/cli/src/session/session-account-handoff.ts new file mode 100644 index 000000000..16a6cf904 --- /dev/null +++ b/apps/cli/src/session/session-account-handoff.ts @@ -0,0 +1,157 @@ +import { + AccountProfileIdSchema, + resolveAccountProfileId, + type ACPSessionId, + type SessionId, + type SessionMeta, +} from '@lody/shared'; + +export type AccountHandoffResult = { + sessionId: SessionId; + accountProfileId: string; + acpSessionId: ACPSessionId; + continuation: boolean; +}; + +export type AccountHandoffDeps = { + acquire: () => (() => void) | null; + assertIdle: () => Promise; + read: () => Promise; + validate: (meta: SessionMeta, accountProfileId: string) => Promise; + checkpoint: (patch: Partial) => Promise; + stop: () => Promise; + launch: ( + meta: SessionMeta, + accountProfileId: string, + resume?: ACPSessionId + ) => Promise; + isResumeFailure: (error: unknown) => boolean; + now: () => number; +}; + +/** The account/provider pair changes only after the candidate has established a session. */ +export async function switchSessionAccount( + request: { sessionId: SessionId; accountProfileId: string; requestId: string }, + deps: AccountHandoffDeps +): Promise { + const target = AccountProfileIdSchema.parse(request.accountProfileId); + if (!request.requestId.trim()) throw new Error('Account switch request id is required.'); + const release = deps.acquire(); + if (!release) throw new Error('Session is busy; retry the account switch between requests.'); + let source: SessionMeta | undefined; + let stopped = false; + let intentWritten = false; + try { + await deps.assertIdle(); + source = await deps.read(); + const from = resolveAccountProfileId(source.accountProfileId); + const prior = source.accountTransitions?.find( + (transition) => transition.requestId === request.requestId + ); + if (prior) { + if (prior.toAccountProfileId !== target) + throw new Error('Account switch request id was already used for a different account.'); + if (!source.acpSessionId) throw new Error('The committed provider binding is unavailable.'); + return { + sessionId: request.sessionId, + accountProfileId: from, + acpSessionId: source.acpSessionId, + continuation: Boolean(source.accountContinuation), + }; + } + if (from === target && source.acpSessionId && !source.accountHandoff) { + // Even a no-op needs a receipt: replay after a later switch must remain a no-op. + intentWritten = true; + await deps.checkpoint({ + accountTransitions: [ + ...(source.accountTransitions ?? []), + { + requestId: request.requestId, + fromAccountProfileId: from, + fromAcpSessionId: source.acpSessionId, + toAccountProfileId: from, + toAcpSessionId: source.acpSessionId, + continuation: Boolean(source.accountContinuation), + committedAt: deps.now(), + }, + ], + }); + return { + sessionId: request.sessionId, + accountProfileId: target, + acpSessionId: source.acpSessionId, + continuation: Boolean(source.accountContinuation), + }; + } + await deps.validate(source, target); + await deps.assertIdle(); + // Set before awaiting: a failed disk flush may still have changed the in-memory mirror. + intentWritten = true; + await deps.checkpoint({ + accountHandoff: { + requestId: request.requestId, + sourceAccountProfileId: from, + sourceAcpSessionId: source.acpSessionId, + targetAccountProfileId: target, + }, + }); + await deps.assertIdle(); + stopped = true; + await deps.stop(); + let next: ACPSessionId; + let continuation = !source.acpSessionId; + try { + next = await deps.launch(source, target, source.acpSessionId); + continuation ||= next !== source.acpSessionId; + } catch (error) { + if (!source.acpSessionId || !deps.isResumeFailure(error)) throw error; + await deps.stop(); + next = await deps.launch(source, target); + continuation = true; + } + await deps.checkpoint({ + accountProfileId: target, + acpSessionId: next, + accountHandoff: null, + accountRateLimits: null, + accountContinuation: continuation ? { acpSessionId: next } : source.accountContinuation, + accountTransitions: [ + ...(source.accountTransitions ?? []), + { + requestId: request.requestId, + fromAccountProfileId: from, + fromAcpSessionId: source.acpSessionId, + toAccountProfileId: target, + toAcpSessionId: next, + continuation, + committedAt: deps.now(), + }, + ], + }); + return { + sessionId: request.sessionId, + accountProfileId: target, + acpSessionId: next, + continuation, + }; + } catch (error) { + // Failed candidates must not survive with a different durable binding. + try { + if (stopped) await deps.stop(); + } finally { + if (source && intentWritten) { + await deps.checkpoint({ + accountProfileId: resolveAccountProfileId(source.accountProfileId), + acpSessionId: source.acpSessionId, + accountHandoff: null, + accountContinuation: source.accountContinuation ?? null, + accountRateLimits: source.accountRateLimits ?? null, + accountTransitions: source.accountTransitions ?? [], + }); + } + } + throw error; + } finally { + release(); + } +} diff --git a/apps/cli/src/session/session-edit-and-resend-service.ts b/apps/cli/src/session/session-edit-and-resend-service.ts index dd50fb482..8098130f6 100644 --- a/apps/cli/src/session/session-edit-and-resend-service.ts +++ b/apps/cli/src/session/session-edit-and-resend-service.ts @@ -518,6 +518,7 @@ export class SessionEditAndResendService { machineId: meta.machineId, agentConfigId: meta.agentConfigId, agentCliType: meta.cliType, + accountProfileId: meta.accountProfileId, agentType: meta.agentType, mcpServerIds, taskToolsEnabled, diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 024faec66..146a6b086 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -55,6 +55,12 @@ import { getManagedBuiltinRuntimeByAgentType, getManagedBuiltinRuntimeByRuntimeName, serializeCustomAcpLaunchSpec, + resolveAccountProfileId, + resolveSessionConversationConfig, + resolveLatestSessionGoalFromHistory, + isSessionGoalActive, + isLoroRepoDocDeleted, + type SessionLegacyMetaFields, } from '@lody/shared'; import type { ContentBlock } from '@agentclientprotocol/sdk'; import type { ModelInfo } from '@lody/shared'; @@ -103,6 +109,8 @@ import { buildPrompt, normalizeSessionInputBlocks } from './session-execution-he import type { MemoryPressureEvictionResult } from '@/lib/session-gc-manager'; import { resolveResumableAcpSessionId } from './session-dispatch-logic'; import { resolveSessionLaunchConfig } from './session-launch-config-resolver'; +import { switchSessionAccount, type AccountHandoffResult } from './session-account-handoff'; +import { applyAcpSessionRunConfig } from './acp-session-config-applier'; import type { MachineAccessVerification } from './session-access-retry'; import { GIT_EXECUTABLE_NOT_FOUND_CODE, @@ -385,6 +393,7 @@ function truncateAnalyticsString(value: string, maxLength = 1_000): string { } export type SessionExecutionServiceDeps = { + resolveAccountSwitchUser?: (userId: string) => Promise<{ name: string; email: string }>; logger: Logger; sessionManager: SessionManager; workspaceDocument: LoroDocumentManager; @@ -1015,6 +1024,186 @@ export class SessionExecutionService { }; } + async switchAccount(request: { + sessionId: SessionId; + accountProfileId: string; + requestId: string; + }): Promise { + const { sessionId } = request; + const existing = await this.deps.workspaceDocument.repo.getDocMeta(getSessionRoomId(sessionId)); + if (!existing || isLoroRepoDocDeleted(existing)) throw new Error('Session was not found.'); + const doc = await this.deps.workspaceDocument.getOrCreateSessionDoc(sessionId); + let launchConfig: SessionConfig | undefined; + let conversation: ReturnType = {}; + let activateCandidateEvents: (() => void) | undefined; + const result = await switchSessionAccount(request, { + acquire: () => this.tryAcquireSessionRewriteBarrier(sessionId), + assertIdle: async () => { + const meta = await doc.getMetaState(); + const history = await doc.getHistory(); + const execution = this.getExecutionSnapshot(sessionId); + const legacyMeta = meta as (SessionMeta & SessionLegacyMetaFields) | undefined; + if ( + execution.hasActiveTurn || + execution.hasBlockingPendingCreate || + execution.hasActiveAutomation || + this.deps.sessionManager.getPendingSession(sessionId) || + this.deps.sessionManager.getSession(sessionId)?.hasActiveToolExecution?.() || + meta?.awaitingUserSince || + meta?.autoReview || + isSessionGoalActive( + resolveLatestSessionGoalFromHistory(history) ?? legacyMeta?.latestGoal + ) + ) { + throw new Error('Session is busy; retry the account switch between requests.'); + } + }, + read: async () => { + const meta = await doc.getMetaState(); + if (!meta || meta.machineId !== this.deps.machineId || meta.isArchived) { + throw new Error('Session is unavailable on this machine.'); + } + if ( + meta.cliType !== 'builtin' || + (meta.agentType !== 'codex' && meta.agentType !== 'claude') + ) { + throw new Error('Account switching is available for builtin Codex and Claude only.'); + } + return meta; + }, + validate: async (meta, accountProfileId) => { + const resolved = await resolveSessionLaunchConfig({ + workspaceDocument: this.deps.workspaceDocument, + workspaceId: this.deps.workspaceId, + machineId: this.deps.machineId, + sessionId, + sessionMeta: meta, + logger: this.deps.logger, + }); + const { validateAccountProfile } = await import('@/agent/account-profiles'); + await validateAccountProfile({ + cliType: meta.cliType, + agentType: meta.agentType, + accountProfileId, + env: resolved.config?.env, + runtimeOverrides: resolved.config?.runtimeOverrides, + logger: this.deps.logger, + }); + const resident = this.deps.sessionManager.getSession(sessionId); + const user = + resident?.getGitIdentityForUser?.(meta.userId) ?? + (await this.deps.resolveAccountSwitchUser?.(meta.userId)); + if (!user) + throw new Error('Session user identity is unavailable; retry the account switch.'); + conversation = resolveSessionConversationConfig(await doc.getHistory()); + launchConfig = { + sessionId, + workspaceId: this.deps.workspaceId, + machineId: meta.machineId, + requesterUserId: meta.userId, + userName: user.name, + userEmail: user.email, + agentConfigId: meta.agentConfigId, + agentCliType: meta.cliType, + agentType: meta.agentType, + accountProfileId, + configOptionValues: conversation.configOptionValues, + mcpServerIds: conversation.mcpServerIds ?? [], + taskToolsEnabled: conversation.taskToolsEnabled === true, + customAcp: resolved.config?.customAcp, + runtimeOverrides: resolved.config?.runtimeOverrides, + env: resolved.config?.env, + project: meta.project, + githubRepo: meta.repoFullName, + restoreBranchName: meta.branchName, + parentSessionId: meta.parentSessionId, + workdir: await this.deps.sessionManager.resolveSessionWorkdir(sessionId), + assumeDocExisting: true, + resume: true, + }; + }, + checkpoint: async (patch) => { + await this.deps.workspaceDocument.repo.upsertDocMeta(getSessionRoomId(sessionId), patch); + await this.deps.workspaceDocument.persistPendingChanges('session-account-handoff'); + }, + stop: async () => { + await this.deps.sessionManager.requestSessionTerminate(sessionId, false); + }, + launch: async (_meta, _accountProfileId, resumeSessionId) => { + if (!launchConfig) throw new Error('Account launch configuration is unavailable.'); + this.deps.beginACPReplaySuppression(sessionId); + try { + let candidateCommitted = false; + const candidate = await this.deps.sessionManager.createSession(launchConfig, { + resumeSessionId, + deferAcpSessionIdPersistence: true, + dispatchEvent: (event) => { + if (candidateCommitted) event(); + }, + allowInteractiveRequest: () => candidateCommitted, + }); + const applied = await applyAcpSessionRunConfig({ + session: candidate, + config: { + ...conversation, + cliType: launchConfig.agentCliType, + agentType: launchConfig.agentType, + }, + logger: this.deps.logger, + }); + if (applied.rejectedSelections.length) + throw new Error('The target account cannot use the selected model or configuration.'); + if (!candidate.acpSessionId) + throw new Error('Target provider did not establish a session.'); + try { + const usageResult = await Effect.runPromiseExit( + Effect.tryPromise({ + try: async () => + await candidate.agentClient?.getRateLimits({ + sessionId: candidate.acpSessionId ?? undefined, + modelId: conversation.modelId, + }), + catch: (error) => error, + }).pipe(Effect.timeout('10 seconds')) + ); + if (Exit.isFailure(usageResult)) throw Cause.squash(usageResult.cause); + const usage = usageResult.value; + if ( + usage?.rateLimits.some( + (limit) => + (!limit.scope.modelId || limit.scope.modelId === conversation.modelId) && + limit.windows.some( + (window) => + window.usedPercent >= 100 && + (window.resetsAtEpochSeconds === null || + window.resetsAtEpochSeconds > getServerNow() / 1000) + ) + ) + ) { + throw new Error('The target account has exhausted its usage allowance.'); + } + } catch (error) { + // Providers without usage queries still support explicit manual selection. + if (!formatErrorMessage(error).includes('ACP_RATE_LIMITS_UNSUPPORTED')) throw error; + } + activateCandidateEvents = () => { + candidateCommitted = true; + }; + return candidate.acpSessionId; + } finally { + this.deps.endACPReplaySuppression(sessionId); + } + }, + isResumeFailure: (error) => { + const message = formatErrorMessage(error).toLowerCase(); + return message.includes('acp_resume_unsupported') || message.includes('acp_resume_failed'); + }, + now: getServerNow, + }); + activateCandidateEvents?.(); + return result; + } + tryAcquireSessionRewriteBarrier(sessionId: SessionId): (() => void) | null { if ( this.rewriteBarrierSessions.has(sessionId) || @@ -3370,7 +3559,11 @@ export class SessionExecutionService { `[${sessionId}] Resume env resolved (agentConfigId=${meta?.agentConfigId ?? 'none'} source=${storedLaunchConfig.source} keys=${agentConfigEnv ? Object.keys(agentConfigEnv).length : 0})` ); - const requestedResumeSessionId = acpSessionConfig.resume; + // A pre-switch input config can still name the former provider session. + const requestedResumeSessionId = + meta?.accountTransitions?.length || meta?.accountHandoff + ? undefined + : acpSessionConfig.resume; const storedResumeSessionId = resolveResumableAcpSessionId(meta); const resumeSessionId = requestedResumeSessionId ?? storedResumeSessionId; const resumeSource = requestedResumeSessionId @@ -3404,6 +3597,7 @@ export class SessionExecutionService { const restoreBranch = project?.branch?.trim() || undefined; const restoreConfig: SessionConfig = { sessionId, + accountProfileId: resolveAccountProfileId(meta?.accountProfileId), workspaceId: message.workspaceId, agentCliType: acpSessionConfig.cliType, agentType: acpSessionConfig.agentType, @@ -3803,6 +3997,16 @@ export class SessionExecutionService { ); bindReadySession(readySession); + const bindingMeta = yield* self.tryPromise(() => sessionDoc.getMetaState()); + if (bindingMeta?.accountContinuation) { + const history = yield* self.tryPromise(() => sessionDoc.getHistory()); + replayPromptResult = buildReplayPromptFromHistory({ + history, + excludeTurnId: message.userTurnId, + }); + replayPromptResult.promptText += `\n\nThis is a continuation of the same Lody session (${sessionId}). The replay above is bounded and may omit older messages or tool output. The full visible transcript remains available through the lody_session_history MCP tool for this session; use its nextCursor to read older pages whenever missing context matters. Preserve the existing task, workspace, worktree, and files; do not repeat completed actions merely because the provider session changed.`; + usedHistoryReplay = true; + } yield* acpReplaySuppression.release; self.deps.setSessionActivePresencePhase(sessionId, 'thinking'); yield* self.tryPromise(() => sessionDoc.setStatus(SessionStatusFactory.running())); @@ -3834,7 +4038,32 @@ export class SessionExecutionService { yield* abortIfCancelled(); - yield* promptWithStaleACPRecovery(promptBlocks); + yield* promptWithStaleACPRecovery(promptBlocks).pipe( + Effect.onExit((exit) => { + const consumed = Exit.isSuccess(exit) + ? self.turnProducedVisibleOutput(sessionId, turnId) + : self.deps.hasPromptOutputForTurn?.(sessionId, turnId) === true; + if (!bindingMeta?.accountContinuation || !consumed) return Effect.void; + // Visible output proves the replacement accepted its context, even if the + // request later fails. Do not prepend that context again on the next turn. + return self + .tryPromise(async () => { + await self.deps.workspaceDocument.repo.upsertDocMeta(getSessionRoomId(sessionId), { + accountContinuation: null, + }); + await self.deps.workspaceDocument.persistPendingChanges('session-account-handoff'); + }) + .pipe( + Effect.catchAll(() => + Effect.sync(() => { + self.deps.logger.warn( + `[${sessionId}] Could not persist account continuation acknowledgement` + ); + }) + ) + ); + }) + ); yield* self.tryPromise(() => runtime.yieldedFinalization); const completedTurnId = runtime.turnId; @@ -4191,6 +4420,7 @@ export class SessionExecutionService { this.deps.logger.debug(`[${sessionId}] session/create summary`, configForLog); const sessionConfig: SessionConfig = { sessionId, + accountProfileId: resolveAccountProfileId(existingMeta?.accountProfileId), workspaceId, agentCliType: acpSessionConfig.cliType, agentType: acpSessionConfig.agentType, @@ -4220,7 +4450,9 @@ export class SessionExecutionService { // Fold the dispatch-start meta fields into the status transition so the // latency-critical create path performs one doc-meta upsert instead of five // sequential ones. - const dispatchStartPatch: Partial = {}; + const dispatchStartPatch: Partial = { + accountProfileId: sessionConfig.accountProfileId, + }; if (project) { dispatchStartPatch.project = project; } @@ -4886,17 +5118,42 @@ export class SessionExecutionService { ...event, }); }; - const result = await this.acpAuthenticationManager.authenticate({ - requestId: message.requestId, - cliType: message.cliType, - agentType: message.agentType, - customAcp: message.customAcp, - runtimeOverrides: message.runtimeOverrides, - env: message.env, - onProgress, - }); + const managedAccount = resolveAccountProfileId(message.accountProfileId) !== 'system-default'; + const releaseAccount = managedAccount + ? this.deps.sessionManager.beginAccountProfileAuthentication( + message.agentType, + resolveAccountProfileId(message.accountProfileId) + ) + : undefined; + if (managedAccount && !releaseAccount) { + return { + ...base, + success: false, + disposition: 'error', + error: + 'This account is in use by a session or another sign-in. Detach its sessions before signing in again.', + }; + } + const result = await this.acpAuthenticationManager + .authenticate({ + requestId: message.requestId, + onAccountLeaseReleased: releaseAccount ?? undefined, + accountProfileId: message.accountProfileId, + cliType: message.cliType, + agentType: message.agentType, + customAcp: message.customAcp, + runtimeOverrides: message.runtimeOverrides, + env: message.env, + onProgress, + }) + .finally(() => releaseAccount?.()); - if (result.success && result.disposition === 'authenticated' && message.configId) { + if ( + result.success && + result.disposition === 'authenticated' && + message.configId && + resolveAccountProfileId(message.accountProfileId) === 'system-default' + ) { const refresh = await this.refreshMachineAcpCapabilities({ type: 'machine/acp-capabilities-refresh', machineId: message.machineId, diff --git a/apps/cli/src/session/session-fork-operation-store.test.ts b/apps/cli/src/session/session-fork-operation-store.test.ts index cbc1397ce..37803910f 100644 --- a/apps/cli/src/session/session-fork-operation-store.test.ts +++ b/apps/cli/src/session/session-fork-operation-store.test.ts @@ -33,11 +33,11 @@ describe('file session fork operation store', () => { beforeEach(() => { tempHome = mkdtempSync(path.join(os.tmpdir(), 'lody-fork-operation-store-')); - vi.stubEnv('HOME', tempHome); + vi.spyOn(os, 'homedir').mockReturnValue(tempHome); }); afterEach(() => { - vi.unstubAllEnvs(); + vi.restoreAllMocks(); rmSync(tempHome, { recursive: true, force: true }); }); @@ -79,13 +79,16 @@ describe('file session fork operation store', () => { expect(await store.read('unknown-session' as SessionId)).toBeNull(); }); - it('creates the store root with owner-only permissions', async () => { - const store = createFileSessionForkOperationStore(); - await store.record(marker); - const root = path.join(tempHome, '.lody', 'session-fork-operations'); - const { stat } = await import('node:fs/promises'); - expect((await stat(root)).mode & 0o777).toBe(0o700); - }); + it.skipIf(process.platform === 'win32')( + 'creates the store root with owner-only POSIX permissions', + async () => { + const store = createFileSessionForkOperationStore(); + await store.record(marker); + const root = path.join(tempHome, '.lody', 'session-fork-operations'); + const { stat } = await import('node:fs/promises'); + expect((await stat(root)).mode & 0o777).toBe(0o700); + } + ); it('lists nothing when the store directory does not exist', async () => { expect(await createFileSessionForkOperationStore().list()).toEqual([]); diff --git a/apps/cli/src/session/session-fork-service.ts b/apps/cli/src/session/session-fork-service.ts index 06f3ab80b..2e4e65d2b 100644 --- a/apps/cli/src/session/session-fork-service.ts +++ b/apps/cli/src/session/session-fork-service.ts @@ -691,6 +691,7 @@ export class SessionForkService { cliType: source.cliType, agentType: source.agentType, agentConfigId: source.agentConfigId, + accountProfileId: source.accountProfileId ?? 'system-default', project: targetProject, repoFullName: targetRepoFullName, baseBranch: source.branchName ?? source.baseBranch, @@ -796,6 +797,7 @@ export class SessionForkService { cliType: source.cliType, agentType: source.agentType, agentConfigId: source.agentConfigId, + accountProfileId: source.accountProfileId ?? 'system-default', project: source.project, repoFullName: source.repoFullName, baseBranch: source.baseBranch, @@ -819,6 +821,7 @@ export class SessionForkService { requesterUserId: spec.requestedByUserId, machineId: source.machineId, agentConfigId: source.agentConfigId, + accountProfileId: source.accountProfileId ?? 'system-default', agentCliType: source.cliType, agentType: source.agentType, mcpServerIds: resolveSessionMcpSelection(historyResult.history), @@ -940,6 +943,7 @@ export class SessionForkService { requesterUserId: spec.requestedByUserId, machineId: source.machineId, agentConfigId: source.agentConfigId, + accountProfileId: source.accountProfileId ?? 'system-default', agentCliType: source.cliType, agentType: source.agentType, mcpServerIds: resolveSessionMcpSelection(historyResult.history), diff --git a/apps/cli/src/session/session-manager.test.ts b/apps/cli/src/session/session-manager.test.ts index e656e1b3d..3fbd26269 100644 --- a/apps/cli/src/session/session-manager.test.ts +++ b/apps/cli/src/session/session-manager.test.ts @@ -186,6 +186,35 @@ const createSessionInner = async ( ).createSessionInner(config, undefined, preparedWorktree); describe('SessionManager cleanup phases', () => { + it('prevents a process launch while its managed account is signing in', async () => { + const manager = new SessionManager( + createLogger(), + 'token', + 'machine-1' as MachineId, + 'workspace-1' as WorkspaceId, + createWorkspaceDocument(new Map()), + { + sessionSandboxFactory: async () => createNoopSessionSandbox(), + cloudPort: createTestCloudPort(), + } + ); + const accountProfileId = '00000000-0000-4000-8000-00000000000b'; + const release = manager.beginAccountProfileAuthentication('codex', accountProfileId); + expect(release).not.toBeNull(); + expect(manager.beginAccountProfileAuthentication('codex', accountProfileId)).toBeNull(); + await expect( + manager.createSession( + createSessionConfig({ sessionId: 'account-auth-busy' as SessionId, accountProfileId }) + ) + ).rejects.toThrow('sign-in is in progress'); + release?.(); + const next = manager.beginAccountProfileAuthentication('codex', accountProfileId); + expect(next).not.toBeNull(); + release?.(); + expect(manager.beginAccountProfileAuthentication('codex', accountProfileId)).toBeNull(); + next?.(); + await manager.cleanUp(); + }); it('stops session producers before closing the workspace document', async () => { const workspaceDocument = createWorkspaceDocument(new Map()); const manager = new SessionManager( diff --git a/apps/cli/src/session/session-manager.ts b/apps/cli/src/session/session-manager.ts index dc4caec22..019c57634 100644 --- a/apps/cli/src/session/session-manager.ts +++ b/apps/cli/src/session/session-manager.ts @@ -298,6 +298,7 @@ export type PreparedSessionLaunchConfigSnapshot = { }; export interface ISession { + hasActiveToolExecution?(): boolean; agentClient: AgentClient | null; acpSessionId: ACPSessionId | null; sessionId: SessionId; @@ -397,6 +398,9 @@ export interface CreateAgentConfig { } export type AgentStartConfig = { + /** A handoff candidate must not publish title/history/goal changes before commit. */ + dispatchEvent?: (event: () => void) => void; + allowInteractiveRequest?: () => boolean; /** * ACP session id to resume when starting the agent, if the ACP agent supports it. * Kept separate from SessionConfig because it only applies to a single ACP startup attempt. @@ -429,7 +433,13 @@ interface SessionManagerEvents { usage: SessionUsageUpdate; }) => void; onContextWindowUsageUpdate: (sessionId: SessionId, usage: SessionContextWindowUsage) => void; - onRateLimitUpdate: (machineId: MachineId, cliType: CliType, limits: RateLimit) => void; + onRateLimitUpdate: ( + machineId: MachineId, + cliType: CliType, + limits: RateLimit, + accountProfileId?: string, + sessionId?: SessionId + ) => void; onThreadGoalUpdated: ( sessionId: SessionId, goal: Extract @@ -452,6 +462,8 @@ export class SessionManager extends EventEmitter { private gitCredentialBroker: GitCredentialBroker | null = null; private readonly sessions = new Map(); private readonly pendingSessionCreates = new Map>(); + private readonly pendingAccountBindings = new Map(); + private readonly authenticatingAccountProfiles = new Set(); private readonly pendingTerminationPromises = new Map>(); private readonly preparationSessions = new Map(); private readonly sessionSandboxFactory: SessionSandboxFactory; @@ -612,6 +624,10 @@ export class SessionManager extends EventEmitter { } async createSession(config: SessionConfig, agentStart?: AgentStartConfig): Promise { + const accountKey = `${config.agentType}:${config.accountProfileId ?? 'system-default'}`; + if (this.authenticatingAccountProfiles.has(accountKey)) { + throw new Error('Account sign-in is in progress; retry after it finishes.'); + } if (!config.assumeDocExisting) { const sessionId = await this.workspaceDocument.createSession( config.machineId, @@ -620,6 +636,9 @@ export class SessionManager extends EventEmitter { config.title ); config.sessionId = sessionId; + await this.workspaceDocument.repo.upsertDocMeta(getSessionRoomId(sessionId), { + accountProfileId: config.accountProfileId ?? 'system-default', + }); } const sessionId = config.sessionId; @@ -631,6 +650,10 @@ export class SessionManager extends EventEmitter { if (existing) { return await existing; } + if (this.authenticatingAccountProfiles.has(accountKey)) { + throw new Error('Account sign-in is in progress; retry after it finishes.'); + } + this.pendingAccountBindings.set(sessionId, accountKey); // Register durable ownership before claim/cold-start work begins. Besides // deduplicating concurrent creates, this prevents an abandoned preparation @@ -640,17 +663,50 @@ export class SessionManager extends EventEmitter { .finally(() => { if (this.pendingSessionCreates.get(sessionId) === promise) { this.pendingSessionCreates.delete(sessionId); + this.pendingAccountBindings.delete(sessionId); } }); this.pendingSessionCreates.set(sessionId, promise); return await promise; } + beginAccountProfileAuthentication( + agentType: string, + accountProfileId: string + ): (() => void) | null { + const key = `${agentType}:${accountProfileId}`; + if ( + this.authenticatingAccountProfiles.has(key) || + [...this.pendingAccountBindings.values()].includes(key) || + [...this.sessions.values()].some( + (session) => + session.agentType === agentType && session.accountProfileId === accountProfileId + ) + ) { + return null; + } + this.authenticatingAccountProfiles.add(key); + let released = false; + return () => { + if (released) return; + released = true; + this.authenticatingAccountProfiles.delete(key); + }; + } + private async createSessionFromPreparationOrCold( config: SessionConfig, agentStart?: AgentStartConfig ): Promise { const sessionId = config.sessionId!; + // Prepared agents use native auth. A bound account must start its own process. + if ( + (config.accountProfileId && config.accountProfileId !== 'system-default') || + agentStart?.deferAcpSessionIdPersistence + ) { + await this.preparationService.discard(sessionId); + return await this.createSessionInnerWithAgent(config, agentStart); + } const preparationIdentity = config.agentConfigId ? { requestedByUserId: config.requesterUserId, @@ -1271,7 +1327,14 @@ export class SessionManager extends EventEmitter { onRateLimitUpdate: (limits: RateLimit) => { dispatchEvent(() => { if (config.agentCliType === 'builtin' && isManagedBuiltinAgentType(config.agentType)) { - this.emit('onRateLimitUpdate', this.machineId, config.agentType, limits); + this.emit( + 'onRateLimitUpdate', + this.machineId, + config.agentType, + limits, + config.accountProfileId, + sessionId + ); } }); }, @@ -1406,6 +1469,8 @@ export class SessionManager extends EventEmitter { session.createAgent( this.buildCreateAgentConfig(session, config, launch, { resumeSessionId: requestedResumeSessionId, + dispatchEvent: agentStart?.dispatchEvent, + allowInteractiveRequest: agentStart?.allowInteractiveRequest, forkSessionId: requestedForkSessionId, forkSessionTurnId: requestedForkSessionTurnId, onStartupStage: (event) => { diff --git a/apps/cli/src/session/session.ts b/apps/cli/src/session/session.ts index 22cf09fe6..749521be5 100644 --- a/apps/cli/src/session/session.ts +++ b/apps/cli/src/session/session.ts @@ -16,6 +16,7 @@ import * as fs from 'fs'; import type { AcpStartupTimeoutOptions, AgentClient } from '@/agent/agent-client'; import { createAcpClient } from '@/agent/acp-runner'; import { withAcpSessionStartSlot } from '@/agent/acp-session-start-gate'; +import { acquireAccountProfileUse } from '@/agent/account-profiles'; import { AcpStartupProcessError, AcpStartupProcessExitError, @@ -103,12 +104,20 @@ function createAbortPromise(signal?: AbortSignal): export class Session extends EventEmitter implements ISession { readonly sessionId: SessionId; + get accountProfileId(): string { + return this.config.accountProfileId ?? 'system-default'; + } + get agentType(): string { + return this.config.agentType; + } private readonly config: SessionConfig; private readonly logger: Logger; private fixedWorkdir?: string; private status: SessionStatus['status'] = 'created'; private readonly startedAtMs = getServerNow(); private activeProcess: SessionProcessHandle | null = null; + private activeExecCount = 0; + private accountProfileLease: { release: () => void; established: boolean } | undefined; private agentProcess: SessionProcessHandle | null = null; private readonly sandbox: SessionSandbox; private gitIdentity: { id: string; name: string; email: string }; @@ -233,8 +242,16 @@ export class Session extends EventEmitter implements ISession { if (this.status === 'failed' || this.status === 'stopping' || this.status === 'terminated') { throw new Error(`Session ${this.sessionId} is not running`); } - const execPromise = await this.runCommand(command, args, workdir, isAI); - return execPromise; + this.activeExecCount += 1; + try { + return await this.runCommand(command, args, workdir, isAI); + } finally { + this.activeExecCount -= 1; + } + } + + hasActiveToolExecution(): boolean { + return this.activeExecCount > 0 || this.terminalManager.hasRunningTerminals?.() === true; } async terminate(force: boolean = false): Promise { @@ -298,6 +315,8 @@ export class Session extends EventEmitter implements ISession { this.agentClient = null; this.acpSessionId = null; this.acpCapabilities = null; + this.accountProfileLease?.release(); + this.accountProfileLease = undefined; this.status = 'terminated'; @@ -478,13 +497,46 @@ export class Session extends EventEmitter implements ISession { } async createAgent(callbacks: CreateAgentConfig): Promise { + const lease = { + release: acquireAccountProfileUse({ + cliType: callbacks.cliType, + agentType: callbacks.agentType, + accountProfileId: this.config.accountProfileId, + }), + established: false, + }; + this.accountProfileLease = lease; + try { + const result = await this.createAgentWithAccount(callbacks); + lease.established = true; + if (!this.agentProcess) lease.release(); + return result; + } catch (error) { + lease.release(); + if (this.accountProfileLease === lease) this.accountProfileLease = undefined; + throw error; + } + } + + private async createAgentWithAccount(callbacks: CreateAgentConfig): Promise { this.acpCapabilitySourceVersion = callbacks.capabilitySourceVersion ?? null; const loginShellEnv = await getLoginShellEnv(); callbacks.abortSignal?.throwIfAborted(); - const env = withLodyNpmCacheForNpx( + const baseEnv = withLodyNpmCacheForNpx( callbacks.command, this.buildShellEnv(callbacks.env, loginShellEnv) ); + // Resolve only after shell/config merging; inherited auth must not defeat isolation. + let env = baseEnv; + if (this.config.accountProfileId && this.config.accountProfileId !== 'system-default') { + const { resolveAccountProfileEnv } = await import('@/agent/account-profiles'); + env = await resolveAccountProfileEnv({ + cliType: callbacks.cliType, + agentType: callbacks.agentType, + accountProfileId: this.config.accountProfileId, + env: baseEnv, + }); + } const launcher: AcpLauncher = resolveAcpLauncher(callbacks.command); const spawnAnalyticsProps = { cliType: callbacks.cliType, @@ -554,6 +606,10 @@ export class Session extends EventEmitter implements ISession { this.logger.debug( `[${this.sessionId}] ACP agent process exited with code ${code} signal ${signal}` ); + if (this.agentProcess === agentProcessHandle && this.accountProfileLease?.established) { + this.accountProfileLease.release(); + this.accountProfileLease = undefined; + } this.agentProcess = null; void agentProcessHandle .inspectExit(code, signal) diff --git a/apps/cli/src/session/terminal-manager.ts b/apps/cli/src/session/terminal-manager.ts index 8ccf1c0a1..f56cc8213 100644 --- a/apps/cli/src/session/terminal-manager.ts +++ b/apps/cli/src/session/terminal-manager.ts @@ -11,6 +11,7 @@ import type { export type TerminalExitStatus = { exitCode: number | null; signal?: string | null }; export interface TerminalManager { + hasRunningTerminals?(): boolean; createTerminal( acpSessionId: string, command: string, @@ -60,6 +61,13 @@ const DEFAULT_TERMINAL_BYTE_LIMIT = 1024 * 1024; // 1MB of retained output abstract class BaseTerminalManager implements TerminalManager { protected terminals = new Map>(); + private pendingStarts = 0; + hasRunningTerminals(): boolean { + return ( + this.pendingStarts > 0 || + [...this.terminals.values()].some((terminal) => terminal.exitStatus === null) + ); + } protected readonly logger: Logger; protected readonly sessionLabel: string; private readonly getActiveSessionId: () => string | null; @@ -101,18 +109,23 @@ abstract class BaseTerminalManager implements TerminalManager { }, }; - state.handle = await this.startProcess( - { - terminalId, - command, - args: args ?? [], - cwd, - env, - }, - hooks - ); + this.pendingStarts += 1; + try { + state.handle = await this.startProcess( + { + terminalId, + command, + args: args ?? [], + cwd, + env, + }, + hooks + ); - this.terminals.set(terminalId, state); + this.terminals.set(terminalId, state); + } finally { + this.pendingStarts -= 1; + } this.logger.debug(`[${this.sessionLabel}] Terminal ${terminalId} started: ${command}`); return terminalId; } diff --git a/apps/cli/src/session/types.ts b/apps/cli/src/session/types.ts index 6f3adb2fa..26f90c1e3 100644 --- a/apps/cli/src/session/types.ts +++ b/apps/cli/src/session/types.ts @@ -21,6 +21,8 @@ export interface SessionConfig { requesterUserId: string; machineId: string; agentConfigId?: AgentConfigId; + /** Provider-neutral binding; absence preserves native CLI authentication. */ + accountProfileId?: string; agentCliType: AgentConfigCliType; agentType: string; /** Config selected by the driving turn and carried into ACP session startup. */ diff --git a/apps/cli/src/session/worktree/speculative-worktree.test.ts b/apps/cli/src/session/worktree/speculative-worktree.test.ts index 5fd3820e0..18e309a8c 100644 --- a/apps/cli/src/session/worktree/speculative-worktree.test.ts +++ b/apps/cli/src/session/worktree/speculative-worktree.test.ts @@ -79,6 +79,7 @@ describe('speculative worktree ownership', () => { beforeEach(() => { tempHome = mkdtempSync(path.join(os.tmpdir(), 'lody-speculative-worktree-')); vi.stubEnv('HOME', tempHome); + vi.stubEnv('USERPROFILE', tempHome); }); afterEach(() => { diff --git a/apps/cli/src/session/worktree/worktree-manager.ts b/apps/cli/src/session/worktree/worktree-manager.ts index f4a78538a..24621baae 100644 --- a/apps/cli/src/session/worktree/worktree-manager.ts +++ b/apps/cli/src/session/worktree/worktree-manager.ts @@ -721,10 +721,22 @@ export class WorktreeManager { const worktreeName = path.basename(currentGitdir) as SessionId; const expectedGitdir = path.join(this.bareGitDir, 'worktrees', worktreeName); - const relative = path.relative(worktreePath, expectedGitdir); + const relative = path.relative( + realpathIfExists(worktreePath), + realpathIfExists(expectedGitdir) + ); lines[gitdirIndex] = `gitdir: ${toGitPath(relative)}`; try { - fs.writeFileSync(gitFilePath, lines.join('\n')); + // Git marks .git hidden on Windows, where opening it with 'w' fails. + // Update the existing file without changing its attributes. + const updatedContent = Buffer.from(lines.join('\n'), 'utf8'); + const fd = fs.openSync(gitFilePath, 'r+'); + try { + fs.writeFileSync(fd, updatedContent); + fs.ftruncateSync(fd, updatedContent.byteLength); + } finally { + fs.closeSync(fd); + } } catch { // ignore } diff --git a/apps/cli/src/utils/index.test.ts b/apps/cli/src/utils/index.test.ts index 2fb796804..94be9ac77 100644 --- a/apps/cli/src/utils/index.test.ts +++ b/apps/cli/src/utils/index.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import path from 'node:path'; import { __test__ } from './index'; describe('cli tool detection helpers', () => { @@ -14,7 +15,9 @@ describe('cli tool detection helpers', () => { const originalCodexHome = process.env.CODEX_HOME; process.env.CODEX_HOME = '/tmp/custom-codex'; try { - expect(__test__.getCodexCredentialsPath('/tmp/home')).toBe('/tmp/custom-codex/auth.json'); + expect(__test__.getCodexCredentialsPath('/tmp/home')).toBe( + path.join('/tmp/custom-codex', 'auth.json') + ); } finally { if (originalCodexHome === undefined) { delete process.env.CODEX_HOME; diff --git a/apps/cli/tests/account-profiles-platform-check.ts b/apps/cli/tests/account-profiles-platform-check.ts new file mode 100644 index 000000000..ab2c6af01 --- /dev/null +++ b/apps/cli/tests/account-profiles-platform-check.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { createAccountProfile, resolveAccountProfileEnv } from '../src/agent/account-profiles'; + +const root = await fs.mkdtemp(path.join(os.tmpdir(), 'lody-account-platform-')); +try { + const nativeHome = path.join(root, 'native'); + await fs.mkdir(nativeHome); + const original = '{"synthetic":"native-credential"}'; + await fs.writeFile(path.join(nativeHome, 'auth.json'), original); + const profilesRoot = path.join(root, 'profiles'); + for (const agentType of ['codex', 'claude']) { + const input = { cliType: 'builtin' as const, agentType, profilesRoot }; + const base = { CODEX_HOME: nativeHome, CLAUDE_CONFIG_DIR: nativeHome }; + assert.equal(await resolveAccountProfileEnv({ ...input, env: base }), base); + assert.equal( + await resolveAccountProfileEnv({ ...input, env: base, accountProfileId: 'system-default' }), + base + ); + const first = await createAccountProfile({ ...input, label: 'First', operationId: 'first' }); + const second = await createAccountProfile({ ...input, label: 'Second', operationId: 'second' }); + const firstEnv = await resolveAccountProfileEnv({ + ...input, + env: base, + accountProfileId: first.accountProfileId, + }); + const secondEnv = await resolveAccountProfileEnv({ + ...input, + env: base, + accountProfileId: second.accountProfileId, + }); + const key = agentType === 'codex' ? 'CODEX_HOME' : 'CLAUDE_CONFIG_DIR'; + const firstHome = firstEnv[key]; + const secondHome = secondEnv[key]; + assert.ok(firstHome && secondHome); + assert.notEqual(firstHome, nativeHome); + assert.notEqual(firstHome, secondHome); + assert.equal( + path.dirname(firstHome), + path.join(profilesRoot, agentType, first.accountProfileId) + ); + await fs.writeFile(path.join(firstHome, 'synthetic-auth'), 'first'); + await fs.writeFile(path.join(secondHome, 'synthetic-auth'), 'second'); + const replay = await createAccountProfile({ ...input, label: 'First', operationId: 'first' }); + assert.equal(replay.accountProfileId, first.accountProfileId); + assert.equal(await fs.readFile(path.join(firstHome, 'synthetic-auth'), 'utf8'), 'first'); + assert.equal(await fs.readFile(path.join(secondHome, 'synthetic-auth'), 'utf8'), 'second'); + assert.equal(await fs.readFile(path.join(nativeHome, 'auth.json'), 'utf8'), original); + assert.deepEqual(base, { CODEX_HOME: nativeHome, CLAUDE_CONFIG_DIR: nativeHome }); + } + console.log( + `PASS ${process.platform}: System Default unchanged; Codex/Claude homes isolated; creation replay preserves files.` + ); +} finally { + await fs.rm(root, { recursive: true, force: true }); +} diff --git a/apps/cli/tests/agent-setting.test.ts b/apps/cli/tests/agent-setting.test.ts index 4d355d189..7f02abb05 100644 --- a/apps/cli/tests/agent-setting.test.ts +++ b/apps/cli/tests/agent-setting.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { homedir } from 'node:os'; import { tmpdir } from 'node:os'; -import { delimiter, join } from 'node:path'; +import { delimiter, join, resolve } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; import { @@ -148,7 +148,7 @@ describe('resolveBuiltinACPSetting', () => { extraArgs: ['--login'], }) ).resolves.toEqual({ - command: '/opt/kimi', + command: resolve('/opt/kimi'), args: ['acp', '--login'], env: { KIMI_CODE_NO_AUTO_UPDATE: '1', @@ -181,7 +181,7 @@ describe('resolveBuiltinACPSetting', () => { action: 'login', }) ).resolves.toEqual({ - command: agentType === 'claude' ? '/opt/claude' : '/opt/codex', + command: resolve(agentType === 'claude' ? '/opt/claude' : '/opt/codex'), args: loginArgs, }); await expect( @@ -192,7 +192,7 @@ describe('resolveBuiltinACPSetting', () => { action: 'status', }) ).resolves.toEqual({ - command: agentType === 'claude' ? '/opt/claude' : '/opt/codex', + command: resolve(agentType === 'claude' ? '/opt/claude' : '/opt/codex'), args: statusArgs, }); } @@ -207,7 +207,7 @@ describe('resolveBuiltinACPSetting', () => { action: 'login', }) ).resolves.toEqual({ - command: '/opt/kimi', + command: resolve('/opt/kimi'), args: ['acp', '--login'], env: { KIMI_CODE_NO_AUTO_UPDATE: '1' }, }); @@ -231,7 +231,7 @@ describe('resolveBuiltinACPSetting', () => { ).resolves.toEqual({ command: process.execPath, args: [expect.stringMatching(/grok-acp\.js$/u)], - env: { GROK_PATH: '/opt/grok', GROK_DISABLE_AUTOUPDATER: '1' }, + env: { GROK_PATH: resolve('/opt/grok'), GROK_DISABLE_AUTOUPDATER: '1' }, capabilitySourceVersion: `${BUILTIN_GROK_CAPABILITY_SOURCE_VERSION}+override:{"grokPath":"/opt/grok"}`, }); await expect( @@ -242,7 +242,7 @@ describe('resolveBuiltinACPSetting', () => { action: 'login', }) ).resolves.toEqual({ - command: '/opt/grok', + command: resolve('/opt/grok'), args: ['login', '--device-auth'], env: { GROK_DISABLE_AUTOUPDATER: '1' }, }); @@ -532,8 +532,8 @@ describe('mergeLoginShellEnv', () => { it('prepends login-shell PATH entries so user-installed tools resolve first', () => { // A GUI-launched daemon inherits a minimal PATH; the login shell knows where // tools like opencode actually live (homebrew, cargo, ~/.local/bin, ...). - const base = { PATH: '/usr/bin:/bin' }; - const shell = { PATH: '/opt/homebrew/bin:/home/u/.local/bin:/usr/bin' }; + const base = { PATH: ['/usr/bin', '/bin'].join(delimiter) }; + const shell = { PATH: ['/opt/homebrew/bin', '/home/u/.local/bin', '/usr/bin'].join(delimiter) }; expect(splitPath(mergeLoginShellEnv(base, shell).PATH)).toEqual([ '/opt/homebrew/bin', @@ -544,8 +544,8 @@ describe('mergeLoginShellEnv', () => { }); it('keeps base-only PATH entries (e.g. runtime-injected node_modules/.bin)', () => { - const base = { PATH: '/proj/node_modules/.bin:/usr/bin' }; - const shell = { PATH: '/home/u/.local/bin:/usr/bin' }; + const base = { PATH: ['/proj/node_modules/.bin', '/usr/bin'].join(delimiter) }; + const shell = { PATH: ['/home/u/.local/bin', '/usr/bin'].join(delimiter) }; expect(splitPath(mergeLoginShellEnv(base, shell).PATH)).toEqual([ '/home/u/.local/bin', diff --git a/apps/cli/tests/gh-shim-script.test.ts b/apps/cli/tests/gh-shim-script.test.ts index d4ee14907..94b9ef90a 100644 --- a/apps/cli/tests/gh-shim-script.test.ts +++ b/apps/cli/tests/gh-shim-script.test.ts @@ -32,6 +32,26 @@ beforeEach(() => { vi.stubEnv('PATH', `${fakeBinDir}${path.delimiter}${originalPath}`); brokerRequestCount = 0; + if (process.platform === 'win32') { + writeFakeGhNamed( + 'gh.cmd', + `@echo off +if "%~1"=="auth" if "%~2"=="status" ( + if "%FAKE_GH_AUTHED%"=="1" exit /b 0 + exit /b 1 +) +if "%~1"=="print-token" ( + echo GH_TOKEN=%GH_TOKEN% + echo GITHUB_TOKEN=%GITHUB_TOKEN% + echo MARKER=%${LODY_MANAGED_GH_TOKEN_SHA256_ENV}% + exit /b 0 +) +echo %* +` + ); + return; + } + writeFakeGh( `#!/bin/sh if [ "$1" = "auth" ] && [ "$2" = "status" ]; then @@ -72,7 +92,7 @@ describe('ensureGhShimScript', () => { it('generates a gh wrapper without PR association behavior', () => { ensureGhShimScript(); - const source = readFileSync(getGhShimHostPath(), 'utf8'); + const source = readFileSync(path.join(getGhShimHostBinDir(), 'gh'), 'utf8'); expect(source).toContain('/github-token'); expect(source).not.toContain('associatePullRequestForCli'); @@ -94,7 +114,7 @@ describe('ensureGhShimScript', () => { expect(launcherSource).toContain(process.execPath); expect(launcherSource).toContain('%~dp0gh'); expect(nodeShimSource).toContain('/github-token'); - expect(nodeShimSource).toContain(path.join(fakeBinDir!, 'gh.cmd')); + expect(nodeShimSource).toContain(path.join(fakeBinDir!, 'gh.cmd').replace(/\\/g, '\\\\')); } finally { restorePlatform(); } @@ -239,7 +259,7 @@ const setPlatformForTest = (platform: NodeJS.Platform): (() => void) => { const runShim = async ( env: Record ): Promise<{ status: number | null; stdout: string; stderr: string }> => { - const shimPath = getGhShimHostPath(); + const shimPath = path.join(getGhShimHostBinDir(), 'gh'); const shimBinDir = getGhShimHostBinDir(); if (!fakeBinDir) { throw new Error('fakeBinDir is not initialized'); @@ -253,6 +273,8 @@ const runShim = async ( PATH: [shimBinDir, fakeBinDir, path.dirname(process.execPath)].join(path.delimiter), }; if (process.platform === 'win32') { + if (!process.env.ComSpec) throw new Error('Windows shim tests require ComSpec'); + childEnv.PATH += path.delimiter + path.dirname(process.env.ComSpec); childEnv.USERPROFILE = tempHomeDir; childEnv.SystemRoot = process.env.SystemRoot; childEnv.ComSpec = process.env.ComSpec; diff --git a/apps/cli/tests/history-session-catalog-client.test.ts b/apps/cli/tests/history-session-catalog-client.test.ts index a03c29990..85e0ca8af 100644 --- a/apps/cli/tests/history-session-catalog-client.test.ts +++ b/apps/cli/tests/history-session-catalog-client.test.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import { describe, expect, it, vi } from 'vitest'; import * as acp from '@agentclientprotocol/sdk'; import type { SessionInfo } from '@agentclientprotocol/sdk'; @@ -198,10 +199,11 @@ describe('requestHistorySessionReplay', () => { describe('resolveHistoryACPProcessLaunch', () => { it('uses the same builtin Codex bundled-adapter launch as normal sessions', async () => { + const codexPath = path.resolve('/opt/lody/codex'); const provider = { cliType: 'builtin', agentType: 'codex', - runtimeOverrides: { codexPath: '/opt/lody/codex' }, + runtimeOverrides: { codexPath }, } as const; const sessionLaunch = await resolveACPProcessLaunchAsync(provider); const historyLaunch = await resolveHistoryACPProcessLaunch({ @@ -213,7 +215,7 @@ describe('resolveHistoryACPProcessLaunch', () => { expect(historyLaunch.args).toEqual(sessionLaunch.args); expect(historyLaunch.command).toBe(process.execPath); expect(historyLaunch.args[0]).toContain('codex-acp.js'); - expect(historyLaunch.env.CODEX_PATH).toBe('/opt/lody/codex'); + expect(historyLaunch.env.CODEX_PATH).toBe(codexPath); expect(historyLaunch.env.PATH).toBe('/usr/bin'); }); diff --git a/apps/cli/tests/lody-mcp-server.test.ts b/apps/cli/tests/lody-mcp-server.test.ts index 8c7be3b4f..9ce8558fb 100644 --- a/apps/cli/tests/lody-mcp-server.test.ts +++ b/apps/cli/tests/lody-mcp-server.test.ts @@ -252,11 +252,15 @@ describe('lody MCP server internals', () => { }); it('resolves relative image paths against the MCP workdir and passes absolute paths through', () => { - expect(resolveUploadPath('screenshots/home.png', '/repo/worktree')).toBe( - '/repo/worktree/screenshots/home.png' + const workdir = path.resolve('/repo/worktree'); + const absolutePath = path.resolve('/tmp/home.png'); + expect(resolveUploadPath('screenshots/home.png', workdir)).toBe( + path.join(workdir, 'screenshots/home.png') + ); + expect(resolveUploadPath(absolutePath, workdir)).toBe(absolutePath); + expect(resolveUploadPath('../outside.png', workdir)).toBe( + path.join(path.dirname(workdir), 'outside.png') ); - expect(resolveUploadPath('/tmp/home.png', '/repo/worktree')).toBe('/tmp/home.png'); - expect(resolveUploadPath('../outside.png', '/repo/worktree')).toBe('/repo/outside.png'); }); it('loads session context from env vars', () => { diff --git a/apps/cli/tests/login-shell-env.test.ts b/apps/cli/tests/login-shell-env.test.ts index 7f8285aa0..39ac59ed3 100644 --- a/apps/cli/tests/login-shell-env.test.ts +++ b/apps/cli/tests/login-shell-env.test.ts @@ -31,6 +31,7 @@ describe('login-shell-env opt-out', () => { describe('login-shell-env slow-probe recovery', () => { beforeEach(() => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); delete process.env.LODY_DISABLE_SHELL_ENV; resetLoginShellEnvCache(); vi.mocked(shellEnv).mockReset(); @@ -38,9 +39,17 @@ describe('login-shell-env slow-probe recovery', () => { afterEach(() => { vi.useRealTimers(); + vi.restoreAllMocks(); resetLoginShellEnvCache(); }); + it('skips the shell probe on Windows', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + expect(getCachedLoginShellEnvSync()).toEqual({}); + await expect(getLoginShellEnv()).resolves.toEqual({}); + expect(shellEnv).not.toHaveBeenCalled(); + }); + it('serves the real env to later awaiters once a slow probe lands after the timeout', async () => { vi.useFakeTimers(); let resolveProbe!: (env: NodeJS.ProcessEnv) => void; diff --git a/apps/cli/tests/message-handler-image-upload.test.ts b/apps/cli/tests/message-handler-image-upload.test.ts index 76349974f..8e24c83b1 100644 --- a/apps/cli/tests/message-handler-image-upload.test.ts +++ b/apps/cli/tests/message-handler-image-upload.test.ts @@ -580,23 +580,77 @@ describe('MessageHandler image upload flow', () => { expect(harness.history).toHaveLength(0); }); - it('rejects symlinked image paths before upload', async () => { - const harness = createHarness(); - handlers.push(harness.handler); + it.each(['validateSessionImageUploadPath', 'validateSessionFileUploadPath'])( + '%s rejects symlinked paths before upload', + async (method) => { + const harness = createHarness(); + handlers.push(harness.handler); + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lody-image-upload-')); + try { + const targetPath = path.join(tempDir, 'secret.txt'); + const linkPath = path.join(tempDir, 'innocent.png'); + await fs.writeFile(targetPath, 'secret'); + await fs.symlink(targetPath, linkPath); + + const validatePath = harness.host[method] as (filePath: string) => Promise; + await expect(validatePath(linkPath)).rejects.toThrow(/must not be a symlink/); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + } + ); - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lody-image-upload-')); - try { - const targetPath = path.join(tempDir, 'secret.txt'); - const linkPath = path.join(tempDir, 'innocent.png'); - await fs.writeFile(targetPath, 'secret'); - await fs.symlink(targetPath, linkPath); - - const validatePath = harness.host.validateSessionImageUploadPath as ( - filePath: string - ) => Promise; - await expect(validatePath(linkPath)).rejects.toThrow(/must not be a symlink/); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); + it.each(['validateSessionImageUploadPath', 'validateSessionFileUploadPath'])( + '%s accepts a stable regular file', + async (method) => { + const harness = createHarness(); + handlers.push(harness.handler); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lody-upload-regular-')); + try { + const filePath = path.join(tempDir, 'image.png'); + await fs.writeFile(filePath, 'image bytes'); + const validatePath = harness.host[method] as (filePath: string) => Promise; + await expect(validatePath(filePath)).resolves.toMatchObject({ + absolutePath: filePath, + fileName: 'image.png', + sizeBytes: 11, + }); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } } - }); + ); + + it + .skipIf(process.platform !== 'win32') + .each(['validateSessionImageUploadPath', 'validateSessionFileUploadPath'])( + '%s rejects a file replaced while opening and closes its handle', + async (method) => { + const harness = createHarness(); + handlers.push(harness.handler); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lody-upload-swap-')); + const originalOpen = fs.open.bind(fs); + let close: ReturnType | undefined; + const filePath = path.join(tempDir, 'image.png'); + await fs.writeFile(filePath, 'original'); + const open = vi.spyOn(fs, 'open').mockImplementation(async (target, flags, mode) => { + if (target === filePath) { + await fs.rename(filePath, path.join(tempDir, 'original.png')); + await fs.writeFile(filePath, 'replacement'); + } + const handle = await originalOpen(target, flags, mode); + if (target === filePath) close = vi.spyOn(handle, 'close'); + return handle; + }); + try { + const validatePath = harness.host[method] as (filePath: string) => Promise; + await expect(validatePath(filePath)).rejects.toThrow(); + expect(close).toHaveBeenCalledOnce(); + } finally { + open.mockRestore(); + await fs.rm(tempDir, { recursive: true, force: true }); + } + } + ); }); diff --git a/apps/cli/tests/message-handler-machine-registration.test.ts b/apps/cli/tests/message-handler-machine-registration.test.ts index 6fd83fa9d..dd7cb26e1 100644 --- a/apps/cli/tests/message-handler-machine-registration.test.ts +++ b/apps/cli/tests/message-handler-machine-registration.test.ts @@ -182,6 +182,7 @@ describe('MessageHandler machine registration', () => { expect(registeredMeta.protocolCapabilities).toEqual({ localProjectRemoval: 1, providerSetup: 1, + accountProfiles: 1, }); await handler.cleanup(); diff --git a/apps/cli/tests/session-env.test.ts b/apps/cli/tests/session-env.test.ts index b281f1264..fd1ed6a8b 100644 --- a/apps/cli/tests/session-env.test.ts +++ b/apps/cli/tests/session-env.test.ts @@ -22,6 +22,7 @@ import type { CreateAgentConfig } from '../src/session/session-manager'; import type { SessionSandbox } from '../src/session/session-sandbox'; import type { SessionConfig } from '../src/session/types'; import type { Logger } from '../src/utils/logger'; +import { acquireAccountProfileAuthentication } from '../src/agent/account-profiles'; const createSilentLogger = (): Logger => ({ info: () => {}, @@ -47,6 +48,50 @@ const createConfig = (overrides: Partial = {}): SessionConfig => }); describe('Session buildShellEnv', () => { + it('holds a managed-account process lease before startup awaits and releases it on abort', async () => { + const accountProfileId = '00000000-0000-4000-8000-00000000000b'; + const session = new Session(createConfig({ accountProfileId }), createSilentLogger()); + const pending = session.createAgent({ + cliType: 'builtin', + agentType: 'codex', + command: 'codex', + abortSignal: AbortSignal.abort(), + } as CreateAgentConfig); + expect(() => + acquireAccountProfileAuthentication({ + cliType: 'builtin', + agentType: 'codex', + accountProfileId, + }) + ).toThrow('in use'); + await expect(pending).rejects.toThrow(); + const release = acquireAccountProfileAuthentication({ + cliType: 'builtin', + agentType: 'codex', + accountProfileId, + }); + release(); + }); + + it('reports a pending exec as active tool execution until it settles', async () => { + const session = new Session(createConfig(), createSilentLogger()); + let finish: (output: string) => void = () => {}; + const output = new Promise((resolve) => { + finish = resolve; + }); + const run = vi + .spyOn(session as unknown as { runCommand: () => Promise }, 'runCommand') + .mockReturnValue(output); + try { + const command = session.exec('synthetic-command', [], '/workspace', false); + expect(session.hasActiveToolExecution()).toBe(true); + finish('done'); + await expect(command).resolves.toBe('done'); + expect(session.hasActiveToolExecution()).toBe(false); + } finally { + run.mockRestore(); + } + }); afterEach(() => { loginShellOverlay.value = {}; resolvedLoginShellOverlay.value = {}; diff --git a/apps/cli/tests/session-execution-service.test.ts b/apps/cli/tests/session-execution-service.test.ts index 298072c78..9d2229fa5 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -20,9 +20,11 @@ import { type SessionHistoryInput, type SessionId, type SessionInputBlock, + type SessionMeta, type WorkspaceId, } from '@lody/shared'; -import type { SessionManager } from '../src/session/session-manager'; +import type { SessionManager, AgentStartConfig } from '../src/session/session-manager'; +import type { SessionConfig } from '../src/session/types'; import type { LoroDocumentManager } from '../src/lib/loro/doc'; import { AcpAuthenticationRequiredError, @@ -186,6 +188,200 @@ const createBaseDeps = ( }; describe('SessionExecutionService', () => { + it.each([ + 'native', + 'continuation', + 'exhausted', + 'usage-failed', + 'deleted-profile', + 'cli-crash', + 'commit-failed', + 'model-unavailable', + ])('runs the account handoff through the existing manager with %s outcome', async (outcome) => { + const profiles = await import('../src/agent/account-profiles'); + const validate = vi.spyOn(profiles, 'validateAccountProfile').mockResolvedValue(undefined); + if (outcome === 'deleted-profile') validate.mockRejectedValue(new Error('profile deleted')); + const id = 'integrated-account-switch' as SessionId; + const oldId = 'old-provider' as ACPSessionId; + const newId = 'new-provider' as ACPSessionId; + const accountProfileId = '00000000-0000-4000-8000-00000000000b'; + let meta: SessionMeta = { + id, + machineId: 'machine-1' as MachineId, + userId: 'user-1', + createdAt: '2026-01-01', + cliType: 'builtin', + agentType: 'codex', + acpSessionId: oldId, + branchName: 'preserved-branch', + }; + const history: SessionHistoryInput[] = [ + { + id: 'prior-user', + role: 'user', + timestamp: '2026-01-01', + fileDiff: [], + items: [{ type: 'text', text: 'original task' }], + inputConfig: outcome === 'model-unavailable' ? { modelId: 'unavailable-model' } : undefined, + }, + ]; + const doc = { getMetaState: async () => meta, getHistory: async () => history }; + let startupMutation = false; + let latestStart: AgentStartConfig | undefined; + let attempts = 0; + const candidate = { + sessionId: id, + acpSessionId: outcome === 'continuation' ? newId : oldId, + agentClient: { + isCreated: () => true, + unstable_setSessionModel: async () => { + throw new Error('model unavailable'); + }, + getRateLimits: async () => { + if (outcome === 'usage-failed') throw new Error('usage detection failed'); + if (outcome === 'exhausted') + return { + rateLimits: [ + { + limitId: 'requests', + scope: { providerId: 'codex' }, + windows: [ + { usedPercent: 100, windowDurationSeconds: 3600, resetsAtEpochSeconds: null }, + ], + }, + ], + }; + throw new Error('[ACP_RATE_LIMITS_UNSUPPORTED] unavailable'); + }, + }, + }; + let resident: unknown = { hasActiveToolExecution: () => false }; + const stop = vi.fn(async () => { + resident = null; + return 'terminated'; + }); + const createSession = vi.fn(async (config: SessionConfig, start?: AgentStartConfig) => { + expect(meta.accountProfileId).toBeUndefined(); + expect(meta.acpSessionId).toBe(oldId); + expect(config).toMatchObject({ + accountProfileId, + workdir: '/existing/worktree', + resume: true, + restoreBranchName: 'preserved-branch', + }); + expect(start?.deferAcpSessionIdPersistence).toBe(true); + expect(start?.allowInteractiveRequest?.()).toBe(false); + start?.dispatchEvent?.(() => { + startupMutation = true; + }); + latestStart = start; + if (++attempts === 1 && outcome === 'continuation') throw new Error('ACP_RESUME_FAILED'); + if (outcome === 'cli-crash') throw new Error('provider process crashed'); + resident = candidate; + return candidate; + }); + const deps = createBaseDeps({ + resolveAccountSwitchUser: async () => ({ name: 'User', email: 'user@example.com' }), + sessionManager: { + getSession: () => resident, + getPendingSession: () => null, + createSession, + resolveSessionWorkdir: async () => '/existing/worktree', + requestSessionTerminate: stop, + } as unknown as SessionManager, + workspaceDocument: { + repo: { + getDocMeta: async () => ({ meta }), + upsertDocMeta: async (_room: string, patch: Partial) => { + meta = { ...meta, ...patch }; + }, + }, + getOrCreateSessionDoc: async () => doc, + persistPendingChanges: vi.fn(async () => { + if (outcome === 'commit-failed' && meta.accountProfileId === accountProfileId) + throw new Error('checkpoint failed'); + }), + } as unknown as LoroDocumentManager, + }); + const service = new SessionExecutionService(deps); + try { + const result = service.switchAccount({ + sessionId: id, + accountProfileId, + requestId: 'switch-integrated', + }); + if (outcome === 'deleted-profile') { + await expect(result).rejects.toThrow('profile deleted'); + expect(stop).not.toHaveBeenCalled(); + expect(createSession).not.toHaveBeenCalled(); + expect(meta.accountProfileId).toBeUndefined(); + expect(meta.acpSessionId).toBe(oldId); + expect(resident).not.toBeNull(); + } else if ( + ['exhausted', 'usage-failed', 'cli-crash', 'commit-failed', 'model-unavailable'].includes( + outcome + ) + ) { + await expect(result).rejects.toThrow( + outcome === 'exhausted' + ? 'exhausted' + : outcome === 'usage-failed' + ? 'usage detection failed' + : outcome === 'cli-crash' + ? 'provider process crashed' + : outcome === 'commit-failed' + ? 'checkpoint failed' + : 'cannot use the selected model' + ); + expect(meta.accountProfileId).toBe('system-default'); + expect(meta.acpSessionId).toBe(oldId); + expect(resident).toBeNull(); + expect(createSession).toHaveBeenCalledTimes(1); + } else { + await expect(result).resolves.toMatchObject({ + accountProfileId, + continuation: outcome === 'continuation', + }); + expect(meta.accountProfileId).toBe(accountProfileId); + expect(meta.accountContinuation).toEqual( + outcome === 'continuation' ? { acpSessionId: newId } : undefined + ); + expect(startupMutation).toBe(false); + expect(latestStart?.allowInteractiveRequest?.()).toBe(true); + latestStart?.dispatchEvent?.(() => { + startupMutation = true; + }); + expect(startupMutation).toBe(true); + } + expect(service.getExecutionSnapshot(id).hasRewriteBarrier).toBe(false); + expect(history).toHaveLength(1); + expect(meta.branchName).toBe('preserved-branch'); + } finally { + validate.mockRestore(); + } + }); + + it('refuses account handoff while a tool continues after the visible turn', async () => { + const deps = createBaseDeps({}); + const meta = { machineId: 'machine-1', cliType: 'builtin', agentType: 'codex' }; + vi.mocked(deps.workspaceDocument.repo.getDocMeta).mockResolvedValue({ meta } as never); + vi.mocked(deps.workspaceDocument.getOrCreateSessionDoc).mockResolvedValue({ + getMetaState: async () => meta, + getHistory: async () => [], + } as never); + vi.mocked(deps.sessionManager.getSession).mockReturnValue({ + hasActiveToolExecution: () => true, + } as never); + const service = new SessionExecutionService(deps); + await expect( + service.switchAccount({ + sessionId: 'tool-session' as SessionId, + accountProfileId: '00000000-0000-4000-8000-00000000000b', + requestId: 'tool-switch', + }) + ).rejects.toThrow('busy'); + expect(deps.sessionManager.terminateSession).not.toHaveBeenCalled(); + }); it('advances one session owner through consecutive prompt handoffs', async () => { const steerPrompt = vi.fn(() => ({ completion: new Promise(() => {}), @@ -2348,107 +2544,263 @@ describe('SessionExecutionService', () => { expect(textBlocks[0]?.text).toContain('inspect the attached trace'); }); - it('restores a missing session for chat using stored ACP session id', async () => { - const meta = { - repoFullName: 'owner/repo', - acpSessionId: 'acp-1' as ACPSessionId, - branchName: 'feat/resume', - parentSessionId: 'parent-session-1' as SessionId, - isArchived: false, - }; - let history: unknown[] = []; - const sessionDoc = { - getMetaState: vi.fn(async () => meta), - setStatus: vi.fn(async () => {}), - setBaseBranch: vi.fn(async () => {}), - getHistory: vi.fn(async () => history), - updateHistory: vi.fn(async (updater: (prev: unknown[]) => unknown[]) => { - history = updater(history); - }), - }; + it.each([undefined, '00000000-0000-4000-8000-00000000000b'])( + 'restores a missing session with committed account binding %s', + async (accountProfileId) => { + const meta = { + repoFullName: 'owner/repo', + acpSessionId: 'acp-1' as ACPSessionId, + branchName: 'feat/resume', + parentSessionId: 'parent-session-1' as SessionId, + isArchived: false, + accountProfileId, + ...(accountProfileId + ? { + accountHandoff: { + sourceAccountProfileId: accountProfileId, + targetAccountProfileId: 'system-default', + }, + } + : {}), + }; + let history: unknown[] = []; + const sessionDoc = { + getMetaState: vi.fn(async () => meta), + setStatus: vi.fn(async () => {}), + setBaseBranch: vi.fn(async () => {}), + getHistory: vi.fn(async () => history), + updateHistory: vi.fn(async (updater: (prev: unknown[]) => unknown[]) => { + history = updater(history); + }), + }; - const agentClient = { - isCreated: vi.fn(() => true), - cancel: vi.fn(async () => {}), - prompt: vi.fn(async () => ({})), - currentModel: undefined, - }; - const restoredSession = { - sessionId: 'session-1' as SessionId, - acpSessionId: 'acp-1' as ACPSessionId, - agentClient, - terminalManager: {} as unknown, - getWorkdir: () => '/tmp', - getHostWorkdir: () => '/tmp', - getParentSessionId: () => undefined, - exec: vi.fn(async () => ''), - terminate: vi.fn(async () => {}), - updateGitIdentity: vi.fn(), - createAgent: vi.fn(async () => 'acp-1'), - applyExecutionPlaneLimits: vi.fn(async () => {}), - }; + const agentClient = { + isCreated: vi.fn(() => true), + cancel: vi.fn(async () => {}), + prompt: vi.fn(async () => ({})), + currentModel: undefined, + }; + const restoredSession = { + sessionId: 'session-1' as SessionId, + acpSessionId: 'acp-1' as ACPSessionId, + agentClient, + terminalManager: {} as unknown, + getWorkdir: () => '/tmp', + getHostWorkdir: () => '/tmp', + getParentSessionId: () => undefined, + exec: vi.fn(async () => ''), + terminate: vi.fn(async () => {}), + updateGitIdentity: vi.fn(), + createAgent: vi.fn(async () => 'acp-1'), + applyExecutionPlaneLimits: vi.fn(async () => {}), + }; - const sessionManager = { - getSession: vi.fn(() => null), - getPendingSession: vi.fn(() => null), - createSession: vi.fn(async (config, agentStart) => { - expect(config.sessionId).toBe('session-1'); - expect(config.resume).toBe(true); - expect(config.githubRepo).toBe('owner/repo'); - expect(config.restoreBranchName).toBe('feat/resume'); - expect(config.parentSessionId).toBe('parent-session-1'); - expect(agentStart?.resumeSessionId).toBe('acp-1'); - return restoredSession as unknown; - }), - setSessionError: vi.fn(), - terminateSession: vi.fn(), - refreshGhTokenForSession: vi.fn(async () => {}), - } as unknown as SessionManager; + const sessionManager = { + getSession: vi.fn(() => null), + getPendingSession: vi.fn(() => null), + createSession: vi.fn(async (config, agentStart) => { + expect(config.sessionId).toBe('session-1'); + expect(config.resume).toBe(true); + expect(config.accountProfileId).toBe(accountProfileId ?? 'system-default'); + expect(config.githubRepo).toBe('owner/repo'); + expect(config.restoreBranchName).toBe('feat/resume'); + expect(config.parentSessionId).toBe('parent-session-1'); + expect(agentStart?.resumeSessionId).toBe('acp-1'); + return restoredSession as unknown; + }), + setSessionError: vi.fn(), + terminateSession: vi.fn(), + refreshGhTokenForSession: vi.fn(async () => {}), + } as unknown as SessionManager; - const deps = createBaseDeps({ - sessionManager, - workspaceDocument: { - repo: { - upsertDocMeta: vi.fn(async () => {}), - getDocMeta: vi.fn(async () => undefined), + const deps = createBaseDeps({ + sessionManager, + workspaceDocument: { + repo: { + upsertDocMeta: vi.fn(async () => {}), + getDocMeta: vi.fn(async () => undefined), + }, + getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + updateAcpCapabilities: vi.fn(async () => {}), + } as unknown as LoroDocumentManager, + buildAcpPromptBlocks: vi.fn(async () => [{ type: 'text', text: 'hi' }] as any), + }); + + const service = new SessionExecutionService(deps); + await service.continueSession({ + type: 'session/chat', + sessionId: 'session-1' as SessionId, + machineId: 'machine-1', + workspaceId: 'workspace-1' as WorkspaceId, + project: { kind: 'github', repoFullName: 'owner/repo', branch: 'main' }, + acpSessionConfig: { + prompt: 'hi', + cliType: 'builtin', + agentType: 'codex', + ...(accountProfileId ? { resume: 'obsolete-provider-id' as ACPSessionId } : {}), }, - getOrCreateSessionDoc: vi.fn(async () => sessionDoc), - updateAcpCapabilities: vi.fn(async () => {}), - } as unknown as LoroDocumentManager, - buildAcpPromptBlocks: vi.fn(async () => [{ type: 'text', text: 'hi' }] as any), - }); + userTurnId: 'turn-user-1', + userId: 'user-1', + userName: 'User', + userEmail: 'user@example.com', + }); - const service = new SessionExecutionService(deps); - await service.continueSession({ - type: 'session/chat', - sessionId: 'session-1' as SessionId, - machineId: 'machine-1', - workspaceId: 'workspace-1' as WorkspaceId, - project: { kind: 'github', repoFullName: 'owner/repo', branch: 'main' }, - acpSessionConfig: { prompt: 'hi', cliType: 'builtin', agentType: 'codex' }, - userTurnId: 'turn-user-1', - userId: 'user-1', - userName: 'User', - userEmail: 'user@example.com', - }); + expect(sessionDoc.setStatus).toHaveBeenCalledWith( + SessionStatusFactory.initializing('resuming') + ); + expect(sessionDoc.setStatus.mock.calls.map(([status]) => status)).toEqual([ + SessionStatusFactory.initializing(), + SessionStatusFactory.initializing('resuming'), + SessionStatusFactory.running(), + SessionStatusFactory.idle(), + ]); + expect(sessionDoc.setBaseBranch).toHaveBeenCalledWith('main'); + expect(agentClient.prompt).toHaveBeenCalledWith('acp-1', [{ type: 'text', text: 'hi' }], { + signal: expect.any(AbortSignal), + }); + expect(deps.startSessionActivePresence).toHaveBeenCalledTimes(1); + expect(deps.startSessionActivePresence).toHaveBeenCalledWith('session-1', 'initializing'); + expect(deps.clearSessionActivePresence).toHaveBeenCalledTimes(1); + } + ); - expect(sessionDoc.setStatus).toHaveBeenCalledWith( - SessionStatusFactory.initializing('resuming') - ); - expect(sessionDoc.setStatus.mock.calls.map(([status]) => status)).toEqual([ - SessionStatusFactory.initializing(), - SessionStatusFactory.initializing('resuming'), - SessionStatusFactory.running(), - SessionStatusFactory.idle(), - ]); - expect(sessionDoc.setBaseBranch).toHaveBeenCalledWith('main'); - expect(agentClient.prompt).toHaveBeenCalledWith('acp-1', [{ type: 'text', text: 'hi' }], { - signal: expect.any(AbortSignal), - }); - expect(deps.startSessionActivePresence).toHaveBeenCalledTimes(1); - expect(deps.startSessionActivePresence).toHaveBeenCalledWith('session-1', 'initializing'); - expect(deps.clearSessionActivePresence).toHaveBeenCalledTimes(1); - }); + it.each([ + { resident: true, outcome: 'success', output: true }, + { resident: false, outcome: 'success', output: true }, + { resident: true, outcome: 'failure', output: false }, + { resident: true, outcome: 'failure', output: true }, + { resident: true, outcome: 'success', output: false }, + ])( + 'preserves continuation context with resident=$resident outcome=$outcome output=$output', + async ({ resident, outcome, output }) => { + const id = 'account-continuation-session' as SessionId; + const acpSessionId = 'account-continuation-provider' as ACPSessionId; + let meta: Partial = { + acpSessionId, + accountProfileId: '00000000-0000-4000-8000-00000000000b', + accountContinuation: { acpSessionId }, + isArchived: false, + }; + let history: SessionHistoryInput[] = [ + { + id: 'prior-user', + role: 'user', + timestamp: '2026-01-01T00:00:00Z', + fileDiff: [], + items: [{ type: 'text', text: 'Preserve the original task context.' }], + }, + { + id: 'current-user', + role: 'user', + timestamp: '2026-01-01T00:01:00Z', + fileDiff: [], + items: [{ type: 'text', text: 'Continue now.' }], + }, + ]; + const sessionDoc = { + getMetaState: vi.fn(async () => meta), + setStatus: vi.fn(async () => {}), + setBaseBranch: vi.fn(async () => {}), + getHistory: vi.fn(async () => history), + updateHistory: vi.fn( + async (update: (previous: SessionHistoryInput[]) => SessionHistoryInput[]) => { + history = update(history); + } + ), + }; + const prompt = vi.fn(async () => { + if (outcome === 'failure') throw new Error('provider request failed'); + return {}; + }); + const runtime = { + sessionId: id, + acpSessionId, + agentClient: { + isCreated: () => true, + prompt, + cancel: vi.fn(async () => {}), + currentModel: undefined, + }, + terminalManager: {}, + getWorkdir: () => '/tmp', + getHostWorkdir: () => '/tmp', + getParentSessionId: () => undefined, + exec: vi.fn(async () => ''), + terminate: vi.fn(async () => {}), + updateGitIdentity: vi.fn(), + createAgent: vi.fn(async () => acpSessionId), + applyExecutionPlaneLimits: vi.fn(async () => {}), + }; + let created = resident; + const createSession = vi.fn(async () => { + created = true; + return runtime; + }); + const deps = createBaseDeps({ + sessionManager: { + getSession: () => (created ? runtime : null), + getPendingSession: () => null, + createSession, + setSessionError: vi.fn(), + terminateSession: vi.fn(), + refreshGhTokenForSession: vi.fn(async () => {}), + } as unknown as SessionManager, + workspaceDocument: { + repo: { + upsertDocMeta: vi.fn(async (_room: string, patch: Partial) => { + meta = { ...meta, ...patch }; + }), + getDocMeta: vi.fn(async () => undefined), + }, + getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + updateAcpCapabilities: vi.fn(async () => {}), + persistPendingChanges: vi.fn(async () => {}), + } as unknown as LoroDocumentManager, + buildAcpPromptBlocks: vi.fn( + async (): Promise => [{ type: 'text', text: 'prompt with context' }] + ), + hasPromptOutputForTurn: () => output, + observePromptOutputForTurn: () => output, + }); + const service = new SessionExecutionService(deps); + const request = { + type: 'session/chat' as const, + sessionId: id, + machineId: 'machine-1', + workspaceId: 'workspace-1' as WorkspaceId, + acpSessionConfig: { + prompt: 'Continue now.', + cliType: 'builtin' as const, + agentType: 'codex', + }, + userTurnId: 'current-user', + userId: 'user-1', + userName: 'User', + userEmail: 'user@example.com', + }; + await service.continueSession(request); + expect(deps.buildAcpPromptBlocks).toHaveBeenCalledWith( + expect.objectContaining({ + replayPromptText: expect.stringContaining('Preserve the original task context.'), + }) + ); + expect(prompt).toHaveBeenCalledTimes(1); + expect(meta.accountContinuation).toEqual(output ? null : { acpSessionId }); + if (!resident) + expect(createSession).toHaveBeenCalledWith( + expect.objectContaining({ accountProfileId: meta.accountProfileId }), + expect.objectContaining({ resumeSessionId: acpSessionId }) + ); + if (output) { + prompt.mockResolvedValue({}); + await service.continueSession({ ...request, userTurnId: 'next-user' }); + expect(deps.buildAcpPromptBlocks).toHaveBeenLastCalledWith( + expect.objectContaining({ replayPromptText: undefined }) + ); + } + expect(history.some((entry) => entry.id === 'prior-user')).toBe(true); + } + ); it('replays durable history when a fresh ACP restore has no resumable session id', async () => { const meta = { @@ -5412,6 +5764,81 @@ describe('SessionExecutionService', () => { } }); + it('rejects managed-account sign-in while its process binding is in use', async () => { + const authenticate = vi + .spyOn(AcpAuthenticationManager.prototype, 'authenticate') + .mockResolvedValue({ success: true, disposition: 'authenticated' }); + const deps = createBaseDeps({}); + deps.sessionManager.beginAccountProfileAuthentication = vi.fn(() => null); + const service = new SessionExecutionService(deps); + try { + const response = await service.authenticateMachineAcp({ + type: 'machine/acp-authenticate', + machineId: 'machine-1' as MachineId, + workspaceId: 'workspace-1' as WorkspaceId, + requestId: 'managed-auth', + action: 'start', + cliType: 'builtin', + agentType: 'codex', + accountProfileId: '00000000-0000-4000-8000-00000000000b', + }); + expect(response).toMatchObject({ + success: false, + disposition: 'error', + error: expect.stringContaining('in use'), + }); + expect(authenticate).not.toHaveBeenCalled(); + } finally { + authenticate.mockRestore(); + } + }); + + it('holds managed-account sign-in exclusion until authentication completes without refreshing default', async () => { + const pending = createDeferred<{ success: boolean; disposition: 'authenticated' }>(); + const authenticate = vi + .spyOn(AcpAuthenticationManager.prototype, 'authenticate') + .mockReturnValue(pending.promise); + const release = vi.fn(); + const deps = createBaseDeps({}); + deps.sessionManager.beginAccountProfileAuthentication = vi.fn(() => release); + const service = new SessionExecutionService(deps); + const refresh = vi.spyOn(service, 'refreshMachineAcpCapabilities'); + try { + const result = service.authenticateMachineAcp({ + type: 'machine/acp-authenticate', + machineId: 'machine-1' as MachineId, + workspaceId: 'workspace-1' as WorkspaceId, + requestId: 'managed-auth', + action: 'start', + cliType: 'builtin', + agentType: 'codex', + configId: capabilityConfigId, + accountProfileId: '00000000-0000-4000-8000-00000000000b', + }); + expect(release).not.toHaveBeenCalled(); + pending.resolve({ success: true, disposition: 'authenticated' }); + await expect(result).resolves.toMatchObject({ success: true }); + expect(release).toHaveBeenCalledOnce(); + expect(refresh).not.toHaveBeenCalled(); + } finally { + authenticate.mockRestore(); + refresh.mockRestore(); + } + }); + + it('does not materialize a missing session for an account switch', async () => { + const deps = createBaseDeps({}); + const service = new SessionExecutionService(deps); + await expect( + service.switchAccount({ + sessionId: 'missing-session' as SessionId, + accountProfileId: 'system-default', + requestId: 'missing-switch', + }) + ).rejects.toThrow('not found'); + expect(deps.workspaceDocument.getOrCreateSessionDoc).not.toHaveBeenCalled(); + }); + it('forwards a browser authorization code to the active login process', async () => { const submitAuthorizationCode = vi .spyOn(AcpAuthenticationManager.prototype, 'submitAuthorizationCode') diff --git a/apps/cli/tests/terminal-manager.test.ts b/apps/cli/tests/terminal-manager.test.ts index ee82859a6..e2bd64388 100644 --- a/apps/cli/tests/terminal-manager.test.ts +++ b/apps/cli/tests/terminal-manager.test.ts @@ -48,6 +48,37 @@ function createProcessHandle(terminate: SessionProcessHandle['terminate']): Sess } describe('ShellTerminalManager', () => { + it('reports pending and running terminals so an account handoff cannot terminate them', async () => { + const processHandle = createProcessHandle(async () => {}); + let finishSpawn: (handle: SessionProcessHandle) => void = () => {}; + const spawned = new Promise((resolve) => { + finishSpawn = resolve; + }); + const sandbox: SessionSandbox = { + enabled: false, + description: 'noop', + applyLimits: async () => {}, + spawn: async () => await spawned, + terminate: async () => {}, + cleanup: async () => {}, + }; + const manager = new ShellTerminalManager({ + logger: createSilentLogger(), + sessionLabel: 'test-session', + getActiveAcpSessionId: () => 'acp-1', + resolveWorkdir: (cwd) => cwd ?? process.cwd(), + buildEnv: () => process.env, + sandbox, + }); + expect(manager.hasRunningTerminals()).toBe(false); + const pending = manager.createTerminal('acp-1', 'node', ['-v']); + expect(manager.hasRunningTerminals()).toBe(true); + finishSpawn(processHandle); + const terminalId = await pending; + expect(manager.hasRunningTerminals()).toBe(true); + await manager.releaseTerminal('acp-1', terminalId); + expect(manager.hasRunningTerminals()).toBe(false); + }); it('preserves a Windows executable path and structured arguments', async () => { const processHandle = createProcessHandle(async () => {}); const sandbox: SessionSandbox = { diff --git a/apps/cli/tests/worktree-manager.create.test.ts b/apps/cli/tests/worktree-manager.create.test.ts index 2f9f56904..bb688f571 100644 --- a/apps/cli/tests/worktree-manager.create.test.ts +++ b/apps/cli/tests/worktree-manager.create.test.ts @@ -106,6 +106,7 @@ describe('WorktreeManager', () => { const gitFile = fs.readFileSync(path.join(info.hostPath, '.git'), 'utf8'); expect(gitFile).toMatch(/^gitdir:\s*\.\./m); expect(gitFile).not.toMatch(/^gitdir:\s*\//m); + expect(runGit(info.hostPath, ['rev-parse', '--is-inside-work-tree'])).toBe('true'); }); it('should reject unsafe session ids', async () => { @@ -263,8 +264,10 @@ describe('WorktreeManager', () => { expect(info.branch).toBe('lody/local001-ses'); expect(fs.existsSync(info.hostPath)).toBe(true); - expect(runGit(info.hostPath, ['rev-parse', '--show-toplevel'])).toBe(info.hostPath); - expect(runGit(sourceDir, ['worktree', 'list'])).toContain(info.hostPath); + expect(path.normalize(runGit(info.hostPath, ['rev-parse', '--show-toplevel']))).toBe( + info.hostPath + ); + expect(runGit(sourceDir, ['worktree', 'list'])).toContain(info.hostPath.replace(/\\/g, '/')); }); it('should suffix a stale generated shared-local branch instead of restoring it', async () => { diff --git a/apps/cli/tests/worktree-manager.remove.test.ts b/apps/cli/tests/worktree-manager.remove.test.ts index bd6f4ebc6..4cee180b6 100644 --- a/apps/cli/tests/worktree-manager.remove.test.ts +++ b/apps/cli/tests/worktree-manager.remove.test.ts @@ -186,6 +186,7 @@ describe('WorktreeManager', () => { const sessionId = 'archive01-session-restore' as SessionId; const info = await manager.createWorktree(sessionId); + runGit(info.hostPath, ['config', 'core.autocrlf', 'false']); fs.writeFileSync(path.join(info.hostPath, '.gitignore'), 'dist/\n', 'utf8'); gitCommit(info.hostPath, 'add ignore rules'); diff --git a/locales/en.json b/locales/en.json index ed9e6096f..9929db496 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1,4 +1,18 @@ { + "agents.accounts.account": "Account", + "agents.accounts.add": "+ Add account", + "agents.accounts.additional": "Account {{number}}", + "agents.accounts.authenticated": "Signed in", + "agents.accounts.createFailed": "Could not add account", + "agents.accounts.defaultHint": "System Default follows your normal CLI login.", + "agents.accounts.signIn": "Sign in", + "agents.accounts.statusUnavailable": "Account status is unavailable", + "agents.accounts.switchFailed": "Could not switch account", + "agents.accounts.switching": "Switching account…", + "agents.accounts.systemDefault": "System Default", + "agents.accounts.unauthenticated": "Sign-in required", + "agents.accounts.unavailable": "Unavailable account", + "agents.accounts.unknown": "Status unavailable", "agents.acpCapabilities.refreshError": "Refresh failed", "agents.acpCapabilities.refreshModelsAndModes": "Refresh models and modes", "agents.acpCapabilities.refreshSuccess": "Capabilities refreshed: {{modelCount}} models, {{modeCount}} modes", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index f9527939d..84353fc75 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -1,4 +1,18 @@ { + "agents.accounts.account": "账户", + "agents.accounts.add": "+ 添加账户", + "agents.accounts.additional": "账户 {{number}}", + "agents.accounts.authenticated": "已登录", + "agents.accounts.createFailed": "无法添加账户", + "agents.accounts.defaultHint": "系统默认账户跟随您通常使用的 CLI 登录。", + "agents.accounts.signIn": "登录", + "agents.accounts.statusUnavailable": "无法获取账户状态", + "agents.accounts.switchFailed": "无法切换账户", + "agents.accounts.switching": "正在切换账户…", + "agents.accounts.systemDefault": "系统默认", + "agents.accounts.unauthenticated": "需要登录", + "agents.accounts.unavailable": "账户不可用", + "agents.accounts.unknown": "状态不可用", "agents.acpCapabilities.refreshError": "刷新失败", "agents.acpCapabilities.refreshModelsAndModes": "刷新模型和模式", "agents.acpCapabilities.refreshSuccess": "能力已刷新:{{modelCount}} 个模型,{{modeCount}} 个模式", diff --git a/packages/components/AGENTS.md b/packages/components/AGENTS.md index 36bd3ee93..6ee241e81 100644 --- a/packages/components/AGENTS.md +++ b/packages/components/AGENTS.md @@ -294,3 +294,11 @@ mobile surfaces. `[data-lody-dialog-content]`; a body portal is outside Radix remove-scroll handling. - Keep optional three.js/R3F usage behind the lazy usage-calendar module so lightweight and SSR consumers do not evaluate its renderer graph. + +## Provider account profiles + +Codex/Claude account controls and their background requests require the machine's +account-profiles protocol capability. System Default follows native CLI auth; UI +never writes credentials or optimistically changes a session account binding. +Additional-account quota must match the durable session account id; never fall +back to machine-wide System Default quota. Preserve legacy login controls. diff --git a/packages/components/src/atoms/runtime.ts b/packages/components/src/atoms/runtime.ts index b95434613..f9dcf2085 100644 --- a/packages/components/src/atoms/runtime.ts +++ b/packages/components/src/atoms/runtime.ts @@ -29,6 +29,10 @@ import type { MachineUpgradeResponse, MachineAcpCapabilitiesRefreshResponse, MachineAcpAuthenticateResponse, + MachineAccountProfilesRequest, + MachineAccountProfilesResponse, + SessionAccountSwitchRequest, + SessionAccountSwitchResponse, MachineAcpAuthenticationProgressMessage, MachineAcpBinaryStatusResponse, MachineAcpBinaryInstallResponse, @@ -251,6 +255,12 @@ export type WorkspaceRuntime = { onProgress?: (progress: MachineAcpBinaryProgressMessage) => void; } ) => Promise; + requestAccountProfiles: ( + request: MachineAccountProfilesRequest + ) => Promise; + requestSessionAccountSwitch: ( + request: SessionAccountSwitchRequest + ) => Promise; waitForMachineAcpAuthenticateResponse: ( machineId: MachineId, requestId: string, diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index 693f36c7b..57296a2f1 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -1,3 +1,5 @@ +import { machineSupportsAccountProfilesProtocol } from '@lody/shared'; +import { SessionAccountSelector } from '../settings/account-profiles'; import { startTransition, forwardRef, @@ -2121,6 +2123,7 @@ export const SessionChatInterface = memo( // judge on the full provider identity. `cliType`/`agentType` alone would let // a Codex-compatible provider behind a custom key show OpenAI's forecast. const showCodexResetForecast = + (session.accountProfileId ?? 'system-default') === 'system-default' && (!session.agentConfigId || !!sessionAgentConfig) && canShowCodexResetForecast({ cliType: session.cliType, @@ -2134,7 +2137,12 @@ export const SessionChatInterface = memo( agentType: session.agentType, config: sessionAgentConfig, }) - ? sessionMachine?.raceLimits + ? (session.accountProfileId ?? 'system-default') === 'system-default' + ? sessionMachine?.raceLimits + : session.accountRateLimits && + session.accountRateLimits.accountProfileId === session.accountProfileId + ? session.accountRateLimits.limits + : undefined : undefined; const sessionDividerLabel = useMemo(() => { if (!session) return ''; @@ -5741,6 +5749,25 @@ export const SessionChatInterface = memo( and work context, glued to the composer shell. It replaced the mobile status strip / goal banner / in-composer scheduled panel. */} + {machineSupportsAccountProfilesProtocol(sessionMachine) && + session.cliType === 'builtin' && + (session.agentType === 'codex' || session.agentType === 'claude') && + (!session.agentConfigId || !!sessionAgentConfig) && + canShowSubscriptionRateLimits({ + cliType: session.cliType, + agentType: session.agentType, + config: sessionAgentConfig, + }) ? ( + + ) : null} void; +}) { + const { t } = useTranslation(); + const rows: AccountProfileSummary[] = profiles.length + ? profiles + : [ + { + accountProfileId: 'system-default', + label: 'System Default', + status: 'unknown', + }, + ]; + return ( + <> + {rows.map((profile) => ( + +
+ + {profile.accountProfileId === 'system-default' + ? t('agents.accounts.systemDefault', 'System Default') + : profile.label} + {profile.identity ? ` — ${profile.identity}` : ''} + + {profile.status === 'authenticated' + ? t('agents.accounts.authenticated', 'Signed in') + : profile.status === 'unauthenticated' + ? t('agents.accounts.unauthenticated', 'Sign-in required') + : t('agents.accounts.unknown', 'Status unavailable')} + + + {profile.accountProfileId !== 'system-default' && ( + + )} +
+ {profile.accountProfileId === 'system-default' ? systemDefaultAuthentication : null} +
+ ))} + + ); +} diff --git a/packages/components/src/components/settings/account-profiles.tsx b/packages/components/src/components/settings/account-profiles.tsx new file mode 100644 index 000000000..25c5b6c36 --- /dev/null +++ b/packages/components/src/components/settings/account-profiles.tsx @@ -0,0 +1,242 @@ +import type { ReactNode } from 'react'; +import { AccountProfileList } from './account-profile-list'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useAtomValue } from 'jotai'; +import { useTranslation } from 'react-i18next'; +import type { + AccountProfileSummary, + AgentConfigId, + BuiltinRuntimeOverrides, + MachineId, + SessionId, +} from '@lody/shared'; +import { activeWorkspaceRuntimeAtom } from '@/atoms/runtime'; +import { currentWorkspaceIdAtom } from '@/atoms/workspace-context'; +import { Button } from '@/ui/button'; +import { AcpAuthenticationPanel } from './acp-authentication-panel'; + +type AccountTarget = { + machineId: MachineId; + agentType: 'codex' | 'claude'; + configId?: AgentConfigId; +}; + +export function useAccountProfiles(target: AccountTarget) { + const runtime = useAtomValue(activeWorkspaceRuntimeAtom); + const workspaceId = useAtomValue(currentWorkspaceIdAtom); + const [profileSnapshot, setProfileSnapshot] = useState<{ + key: string; + profiles: AccountProfileSummary[]; + } | null>(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const { t } = useTranslation(); + const requestStateRef = useRef({ revision: 0 }); + const { machineId, agentType, configId } = target; + const targetKey = JSON.stringify([workspaceId, machineId, agentType, configId]); + const profiles = profileSnapshot?.key === targetKey ? profileSnapshot.profiles : []; + const refresh = useCallback(async () => { + if (!runtime || !workspaceId) return; + const requestState = requestStateRef.current; + const requestRevision = ++requestState.revision; + setLoading(true); + setError(null); + try { + const response = await runtime.requestAccountProfiles({ + type: 'machine/account-profiles', + workspaceId, + machineId, + cliType: 'builtin', + agentType, + configId, + requestId: crypto.randomUUID(), + action: 'list', + }); + if (requestRevision !== requestState.revision) return; + if (!response?.success) + throw new Error( + response?.error ?? t('agents.accounts.statusUnavailable', 'Account status is unavailable') + ); + setProfileSnapshot({ key: targetKey, profiles: response.profiles ?? [] }); + } catch (cause) { + if (requestRevision !== requestState.revision) return; + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + if (requestRevision === requestState.revision) setLoading(false); + } + }, [runtime, workspaceId, machineId, agentType, configId, t, targetKey]); + useEffect(() => { + const requestState = requestStateRef.current; + void refresh(); + return () => { + requestState.revision++; + }; + }, [refresh]); + return { runtime, workspaceId, profiles, error, setError, loading, refresh }; +} + +export function AccountProfilesPanel( + target: AccountTarget & { + systemDefaultAuthentication?: ReactNode; + runtimeOverrides?: BuiltinRuntimeOverrides; + env?: Record; + } +) { + const { t } = useTranslation(); + const { runtime, workspaceId, profiles, error, setError, loading, refresh } = + useAccountProfiles(target); + const [loginProfile, setLoginProfile] = useState(null); + const [creating, setCreating] = useState(false); + const add = async () => { + if (!runtime || !workspaceId || creating) return; + setCreating(true); + setError(null); + try { + const response = await runtime.requestAccountProfiles({ + type: 'machine/account-profiles', + workspaceId, + machineId: target.machineId, + cliType: 'builtin', + agentType: target.agentType, + configId: target.configId, + requestId: crypto.randomUUID(), + action: 'create', + label: t('agents.accounts.additional', 'Account {{number}}', { number: profiles.length }), + }); + if (!response?.success) + throw new Error( + response?.error ?? t('agents.accounts.createFailed', 'Could not add account') + ); + const added = response.profiles?.find( + (profile) => + profile.accountProfileId !== 'system-default' && + !profiles.some((existing) => existing.accountProfileId === profile.accountProfileId) + ); + if (added) setLoginProfile(added.accountProfileId); + await refresh(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setCreating(false); + } + }; + return ( +
+

+ {t('agents.accounts.defaultHint', 'System Default follows your normal CLI login.')} +

+ + {error && ( +

+ {error} +

+ )} + + {error && ( + + )} + {loginProfile && ( + { + setLoginProfile(null); + await refresh(); + }} + /> + )} +
+ ); +} + +export function SessionAccountSelector( + target: AccountTarget & { sessionId: SessionId; accountProfileId?: string; busy?: boolean } +) { + const { t } = useTranslation(); + const { runtime, workspaceId, profiles, error, setError, loading, refresh } = + useAccountProfiles(target); + const [switching, setSwitching] = useState(false); + const currentId = target.accountProfileId ?? 'system-default'; + const change = async (accountProfileId: string) => { + if (!runtime || !workspaceId || switching || target.busy || accountProfileId === currentId) + return; + setSwitching(true); + setError(null); + try { + const response = await runtime.requestSessionAccountSwitch({ + type: 'session/account-switch', + workspaceId, + machineId: target.machineId, + sessionId: target.sessionId, + requestId: crypto.randomUUID(), + accountProfileId, + }); + if (!response?.success) + throw new Error( + response?.error ?? t('agents.accounts.switchFailed', 'Could not switch account') + ); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setSwitching(false); + } + }; + return ( +
+ + {switching && ( + {t('agents.accounts.switching', 'Switching account…')} + )} + {error && ( + <> + + {error} + + + + )} +
+ ); +} diff --git a/packages/components/src/components/settings/acp-authentication-panel.tsx b/packages/components/src/components/settings/acp-authentication-panel.tsx index 6fe619a56..ba4569ddd 100644 --- a/packages/components/src/components/settings/acp-authentication-panel.tsx +++ b/packages/components/src/components/settings/acp-authentication-panel.tsx @@ -32,6 +32,7 @@ export type AcpAuthorizationDetails = Pick< export function AcpAuthenticationPanel({ machineId, configId, + accountProfileId, cliType, agentType, customAcp, @@ -43,6 +44,7 @@ export function AcpAuthenticationPanel({ }: { machineId: MachineId | null; configId?: AgentConfigId; + accountProfileId?: string; cliType: AgentConfigCliType; agentType: string; customAcp?: CustomAcpLaunchSpec; @@ -70,7 +72,16 @@ export function AcpAuthenticationPanel({ const provider = getAcpAuthenticationAccountName(agentType); const authArgs = machineId - ? { machineId, configId, cliType, agentType, customAcp, runtimeOverrides, env } + ? { + machineId, + configId, + accountProfileId, + cliType, + agentType, + customAcp, + runtimeOverrides, + env, + } : null; const closePendingAuthorizationWindow = (): void => { diff --git a/packages/components/src/components/settings/agent-config-dialog.tsx b/packages/components/src/components/settings/agent-config-dialog.tsx index 8b730a024..10ee8cbfe 100644 --- a/packages/components/src/components/settings/agent-config-dialog.tsx +++ b/packages/components/src/components/settings/agent-config-dialog.tsx @@ -1,3 +1,5 @@ +import { machineSupportsAccountProfilesProtocol } from '@lody/shared'; +import { AccountProfilesPanel } from './account-profiles'; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { useAtomValue } from 'jotai'; @@ -1824,6 +1826,27 @@ export function AgentConfigDialog(props: AgentConfigDialogProps) { ); + const systemDefaultAuthentication = ( + { + setAuthRequired(false); + setProbeError(null); + setManuallyTested(true); + if (requiresBuiltinCreationVerification) { + setVerifiedBuiltinContext(builtinVerificationContext); + } + }} + /> + ); + const formPane = (