mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Desktop sessions were missing a visible request_access approval path and could mis-detect their own app window as an unapproved frontmost target, which caused Computer Use clicks to fail even after opening the intended app. On macOS, text entry was also split across inconsistent clipboard and keystroke paths, making Electron inputs unreliable for Chinese and short strings. This change adds a desktop approval bridge over the existing session websocket, renders a dedicated desktop approval modal, threads the real desktop bundle id into the Computer Use executor, and switches macOS clipboard typing onto the native pasteboard plus system paste shortcut path. It also makes tool error results expandable in the desktop chat UI so frontmost-gate failures are fully visible during debugging. Constraint: Desktop sessions run the CLI over the SDK websocket path, so Ink tool JSX dialogs are not visible there Constraint: macOS IME and Electron text inputs are unreliable with pyautogui.write and generic hotkey synthesis Rejected: Reuse CLI setToolJSX dialogs in desktop mode | no transport for mid-call Ink UI over the SDK bridge Rejected: Keep shell pbcopy/pbpaste for clipboard typing | inconsistent with NSPasteboard path and less reliable for Chinese text Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep desktop Computer Use approvals and macOS text-entry behavior on a single bridge/path; avoid reintroducing separate CLI-only and desktop-only codepaths for the same action Tested: python3 -m unittest runtime/test_helpers.py Tested: bun test src/utils/computerUse/permissions.test.ts src/server/__tests__/conversation-service.test.ts Tested: cd desktop && bun run test ComputerUsePermissionModal chatStore Tested: cd desktop && bun run test chatBlocks Tested: cd desktop && bun run lint Not-tested: End-to-end manual Computer Use interaction against a live Electron target app on macOS
174 lines
6.8 KiB
TypeScript
174 lines
6.8 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
import * as fs from 'node:fs/promises'
|
|
import * as os from 'node:os'
|
|
import * as path from 'node:path'
|
|
import { ConversationService } from '../services/conversationService.js'
|
|
|
|
describe('ConversationService', () => {
|
|
let tmpDir: string
|
|
let originalConfigDir: string | undefined
|
|
let originalAuthToken: string | undefined
|
|
let originalBaseUrl: string | undefined
|
|
let originalModel: string | undefined
|
|
let originalEntrypoint: string | undefined
|
|
let originalOAuthToken: string | undefined
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-conversation-service-'))
|
|
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
|
originalAuthToken = process.env.ANTHROPIC_AUTH_TOKEN
|
|
originalBaseUrl = process.env.ANTHROPIC_BASE_URL
|
|
originalModel = process.env.ANTHROPIC_MODEL
|
|
originalEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT
|
|
originalOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN
|
|
|
|
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
|
process.env.ANTHROPIC_AUTH_TOKEN = 'test-token'
|
|
process.env.ANTHROPIC_BASE_URL = 'https://example.invalid/anthropic'
|
|
process.env.ANTHROPIC_MODEL = 'test-model'
|
|
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'inherited-parent-oauth-token'
|
|
// Clear inherited CLAUDE_CODE_ENTRYPOINT so tests can assert whether
|
|
// buildChildEnv injects it or not without interference from the shell env.
|
|
delete process.env.CLAUDE_CODE_ENTRYPOINT
|
|
})
|
|
|
|
afterEach(async () => {
|
|
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
|
|
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
|
|
|
if (originalAuthToken === undefined) delete process.env.ANTHROPIC_AUTH_TOKEN
|
|
else process.env.ANTHROPIC_AUTH_TOKEN = originalAuthToken
|
|
|
|
if (originalBaseUrl === undefined) delete process.env.ANTHROPIC_BASE_URL
|
|
else process.env.ANTHROPIC_BASE_URL = originalBaseUrl
|
|
|
|
if (originalModel === undefined) delete process.env.ANTHROPIC_MODEL
|
|
else process.env.ANTHROPIC_MODEL = originalModel
|
|
|
|
if (originalEntrypoint === undefined) delete process.env.CLAUDE_CODE_ENTRYPOINT
|
|
else process.env.CLAUDE_CODE_ENTRYPOINT = originalEntrypoint
|
|
|
|
if (originalOAuthToken === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN
|
|
else process.env.CLAUDE_CODE_OAUTH_TOKEN = originalOAuthToken
|
|
|
|
await fs.rm(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
test('keeps inherited provider env when no desktop provider config exists', async () => {
|
|
const service = new ConversationService() as any
|
|
const env = (await service.buildChildEnv('D:\\workspace\\code\\myself_code\\cc-haha')) as Record<string, string>
|
|
|
|
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('test-token')
|
|
expect(env.ANTHROPIC_BASE_URL).toBe('https://example.invalid/anthropic')
|
|
expect(env.ANTHROPIC_MODEL).toBe('test-model')
|
|
})
|
|
|
|
test('strips inherited provider env when desktop provider config exists', async () => {
|
|
const ccHahaDir = path.join(tmpDir, 'cc-haha')
|
|
await fs.mkdir(ccHahaDir, { recursive: true })
|
|
await fs.writeFile(
|
|
path.join(ccHahaDir, 'providers.json'),
|
|
JSON.stringify({ activeId: null, providers: [] }),
|
|
'utf-8',
|
|
)
|
|
|
|
const service = new ConversationService() as any
|
|
const env = (await service.buildChildEnv('D:\\workspace\\code\\myself_code\\cc-haha')) as Record<string, string>
|
|
|
|
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
|
|
expect(env.ANTHROPIC_BASE_URL).toBeUndefined()
|
|
expect(env.ANTHROPIC_MODEL).toBeUndefined()
|
|
})
|
|
|
|
test('buildChildEnv injects CLAUDE_CODE_OAUTH_TOKEN when official mode + haha oauth token exists', async () => {
|
|
const ccHahaDir = path.join(tmpDir, 'cc-haha')
|
|
await fs.mkdir(ccHahaDir, { recursive: true })
|
|
await fs.writeFile(
|
|
path.join(ccHahaDir, 'settings.json'),
|
|
JSON.stringify({ env: {} }),
|
|
'utf-8',
|
|
)
|
|
|
|
const { hahaOAuthService } = await import('../services/hahaOAuthService.js')
|
|
await hahaOAuthService.saveTokens({
|
|
accessToken: 'haha-fresh-token',
|
|
refreshToken: 'haha-refresh-xxx',
|
|
expiresAt: Date.now() + 30 * 60_000,
|
|
scopes: ['user:inference'],
|
|
subscriptionType: 'max',
|
|
})
|
|
|
|
const service = new ConversationService() as any
|
|
const env = (await service.buildChildEnv('/tmp')) as Record<string, string>
|
|
|
|
expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('claude-desktop')
|
|
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('haha-fresh-token')
|
|
})
|
|
|
|
test('buildChildEnv does NOT inject CLAUDE_CODE_OAUTH_TOKEN when not official mode', async () => {
|
|
const ccHahaDir = path.join(tmpDir, 'cc-haha')
|
|
await fs.mkdir(ccHahaDir, { recursive: true })
|
|
await fs.writeFile(
|
|
path.join(ccHahaDir, 'settings.json'),
|
|
JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: 'custom-provider-token' } }),
|
|
'utf-8',
|
|
)
|
|
|
|
const { hahaOAuthService } = await import('../services/hahaOAuthService.js')
|
|
await hahaOAuthService.saveTokens({
|
|
accessToken: 'haha-token-should-not-be-used',
|
|
refreshToken: null,
|
|
expiresAt: null,
|
|
scopes: [],
|
|
subscriptionType: null,
|
|
})
|
|
|
|
const service = new ConversationService() as any
|
|
const env = (await service.buildChildEnv('/tmp')) as Record<string, string>
|
|
|
|
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()
|
|
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined()
|
|
})
|
|
|
|
test('buildChildEnv does not leak inherited CLAUDE_CODE_OAUTH_TOKEN when official token is unavailable', async () => {
|
|
const ccHahaDir = path.join(tmpDir, 'cc-haha')
|
|
await fs.mkdir(ccHahaDir, { recursive: true })
|
|
await fs.writeFile(
|
|
path.join(ccHahaDir, 'settings.json'),
|
|
JSON.stringify({ env: {} }),
|
|
'utf-8',
|
|
)
|
|
|
|
const service = new ConversationService() as any
|
|
const env = (await service.buildChildEnv('/tmp')) as Record<string, string>
|
|
|
|
expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('claude-desktop')
|
|
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()
|
|
})
|
|
|
|
test('buildChildEnv injects desktop Computer Use host bundle id for sdk sessions', async () => {
|
|
const service = new ConversationService() as any
|
|
const env = (await service.buildChildEnv(
|
|
'/tmp',
|
|
'ws://127.0.0.1:3456/sdk/test-session?token=test-token',
|
|
)) as Record<string, string>
|
|
|
|
expect(env.CC_HAHA_COMPUTER_USE_HOST_BUNDLE_ID).toBe(
|
|
'com.claude-code-haha.desktop',
|
|
)
|
|
expect(env.CC_HAHA_DESKTOP_SERVER_URL).toBe('http://127.0.0.1:3456')
|
|
})
|
|
|
|
test('uses bun entrypoint fallback on Windows dev mode', () => {
|
|
const service = new ConversationService() as any
|
|
const args = service.resolveCliArgs(['--print'])
|
|
|
|
if (process.platform === 'win32') {
|
|
expect(args[0]).toBe(process.execPath)
|
|
expect(args[1]).toContain(path.join('src', 'entrypoints', 'cli.tsx'))
|
|
} else {
|
|
expect(args[0]).toContain(path.join('bin', 'claude-haha'))
|
|
}
|
|
})
|
|
})
|