fix(computer-use): harden native macOS automation runtime

This commit is contained in:
程序员阿江(Relakkes)
2026-09-01 20:06:25 +08:00
parent ad7321f7cc
commit 8a37865536
44 changed files with 2317 additions and 281 deletions
+58 -8
View File
@@ -40,11 +40,11 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WIN_CSC_LINK: ${{ secrets.WINDOWS_CERTIFICATE }}
WIN_CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
RELEASE_DRAFT: ${{ github.event_name == 'workflow_dispatch' && inputs.draft == true }}
run: |
# macOS signing + notarization is preferred: Squirrel.Mac auto-update and
# first-launch Gatekeeper approval work best with a notarized Developer ID build.
# Drafts may still build unsigned while Apple Developer ID setup is being tested.
# first-launch Gatekeeper approval and Computer Use client attestation
# require a consistent Developer ID build. Drafts use the same native
# runtime, so an unsigned macOS lane would be a knowingly broken app.
missing=()
[ -n "$CSC_LINK" ] || missing+=("MACOS_CERTIFICATE")
[ -n "$CSC_KEY_PASSWORD" ] || missing+=("MACOS_CERTIFICATE_PASSWORD")
@@ -52,12 +52,9 @@ jobs:
[ -n "$APPLE_APP_SPECIFIC_PASSWORD" ] || missing+=("APPLE_APP_SPECIFIC_PASSWORD")
[ -n "$APPLE_TEAM_ID" ] || missing+=("APPLE_TEAM_ID")
if [ "${#missing[@]}" -gt 0 ]; then
printf '::warning::Missing macOS signing/notarization secrets (%s): macOS artifacts will be unsigned and users must use install-macos-unsigned.sh.\n' "${missing[*]}"
printf '::error::Missing macOS signing/notarization secrets (%s): refusing to build a macOS release whose Computer Use runtime cannot pass client attestation.\n' "${missing[*]}"
echo "macos_signed=false" >> "$GITHUB_OUTPUT"
if [ "$RELEASE_DRAFT" != "true" ]; then
echo "::error::Refusing to publish a non-draft desktop release without macOS signing/notarization secrets."
exit 1
fi
exit 1
else
echo "macos_signed=true" >> "$GITHUB_OUTPUT"
fi
@@ -173,6 +170,48 @@ jobs:
working-directory: desktop
run: bun run test:windows-storage-recovery
- name: Import macOS signing identity for native runtimes
if: matrix.smoke_platform == 'macos' && needs.signing-preflight.outputs.macos_signed == 'true'
shell: bash
env:
CSC_LINK: ${{ secrets.MACOS_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
certificate_path="${RUNNER_TEMP}/cc-haha-signing.p12"
keychain_path="${RUNNER_TEMP}/cc-haha-signing.keychain-db"
keychain_password="$(uuidgen)"
printf '%s' "$CSC_LINK" | base64 --decode > "$certificate_path"
security create-keychain -p "$keychain_password" "$keychain_path"
security set-keychain-settings -lut 21600 "$keychain_path"
security unlock-keychain -p "$keychain_password" "$keychain_path"
security import "$certificate_path" \
-k "$keychain_path" \
-P "$CSC_KEY_PASSWORD" \
-A \
-t cert \
-f pkcs12
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s \
-k "$keychain_password" \
"$keychain_path"
security list-keychains -d user -s "$keychain_path"
identity="$(
security find-identity -v -p codesigning "$keychain_path" \
| grep -E '"Developer ID Application:' \
| head -1 \
| sed -E 's/^[^"]*"([^"]+)".*$/\1/'
)"
if [ -z "$identity" ]; then
echo "::error::Imported certificate does not contain a Developer ID Application identity."
exit 1
fi
echo "CC_HAHA_SIGN_IDENTITY=$identity" >> "$GITHUB_ENV"
echo "CC_HAHA_CI_KEYCHAIN=$keychain_path" >> "$GITHUB_ENV"
echo "CC_HAHA_CI_CERTIFICATE=$certificate_path" >> "$GITHUB_ENV"
echo "Imported native-runtime signing identity: $identity"
- name: Prepare bundled ripgrep
working-directory: desktop
env:
@@ -485,6 +524,17 @@ jobs:
exit 1
fi
- name: Remove temporary macOS signing keychain
if: always() && matrix.smoke_platform == 'macos' && needs.signing-preflight.outputs.macos_signed == 'true'
shell: bash
run: |
if [ -n "${CC_HAHA_CI_KEYCHAIN:-}" ]; then
security delete-keychain "$CC_HAHA_CI_KEYCHAIN" 2>/dev/null || true
fi
if [ -n "${CC_HAHA_CI_CERTIFICATE:-}" ]; then
rm -f "$CC_HAHA_CI_CERTIFICATE"
fi
- name: Namespace update metadata assets
shell: bash
working-directory: desktop/build-artifacts/electron
+1 -1
View File
@@ -216,7 +216,7 @@ Built at: $(date '+%Y-%m-%d %H:%M:%S %z')
EOF
if [[ "${SKIP_PACKAGE_SMOKE:-0}" != "1" ]]; then
PACKAGE_SMOKE_ARGS=(bun run test:package-smoke --platform macos --package-kind release --artifacts-dir desktop/build-artifacts/macos-arm64)
PACKAGE_SMOKE_ARGS=(bun run test:package-smoke --platform macos --arch arm64 --package-kind release --artifacts-dir desktop/build-artifacts/macos-arm64)
if [[ "${REQUIRE_MACOS_GATEKEEPER_SMOKE:-0}" == "1" ]]; then
PACKAGE_SMOKE_ARGS+=(--require-macos-gatekeeper)
fi
+2 -1
View File
@@ -661,7 +661,7 @@ describe('build-sidecars cu-helper macOS gating', () => {
// guard, so non-macOS sidecar builds keep using the Python helper instead of
// attempting a macOS-only Swift build.
const guarded = source.match(
/if \(process\.platform === 'darwin'\) \{\s*await buildCuHelper\(\)\s*\}/,
/if \(process\.platform === 'darwin' && cuHelperArch\) \{\s*await buildCuHelper\(cuHelperArch\)\s*\}/,
)
expect(guarded).not.toBeNull()
})
@@ -669,6 +669,7 @@ describe('build-sidecars cu-helper macOS gating', () => {
it('invokes native/cu-helper/build.sh from the cu-helper build step', () => {
const source = readBuildScript()
expect(source).toMatch(/'native',\s*'cu-helper',\s*'build\.sh'/)
expect(source).toContain('env: createCuHelperBuildEnv(targetTriple, process.env)')
})
it('copies the cu-helper binary and its resource bundle into the binaries dir', () => {
+13 -5
View File
@@ -6,8 +6,14 @@ import {
} from './prepare-ripgrep'
import {
SIDECAR_SIGNING_IDENTIFIER,
codesignTimestampArgument,
detectStableSigningIdentity,
} from './sign-identity'
import {
createCuHelperBuildEnv,
resolveCuHelperArch,
type CuHelperArch,
} from './cu-helper-build-target'
const desktopRoot = path.resolve(import.meta.dir, '..')
const repoRoot = path.resolve(desktopRoot, '..')
@@ -64,8 +70,9 @@ console.log(`[build-sidecars] Built desktop sidecar for ${targetTriple} (${bunTa
// re-sign cu-helper here: native/cu-helper/build.sh already signs it with a
// STABLE identity + hardened runtime, and re-signing would rotate its TCC
// identity, dropping the user's Accessibility + Screen Recording grants.
if (process.platform === 'darwin') {
await buildCuHelper()
const cuHelperArch = resolveCuHelperArch(targetTriple)
if (process.platform === 'darwin' && cuHelperArch) {
await buildCuHelper(cuHelperArch)
}
async function stageHostRipgrepForOfflineBuild() {
@@ -299,7 +306,7 @@ async function signMacBinary(outputPath: string) {
'--force',
'--identifier',
SIDECAR_SIGNING_IDENTIFIER,
'--timestamp=none',
codesignTimestampArgument(identity),
]
if (identity) {
// Hardened runtime + inherited entitlements match what electron-builder
@@ -333,12 +340,13 @@ async function signMacBinary(outputPath: string) {
* The copy is byte-preserving (`cp -R`) so cu-helper's stable `dev.cchaha.cu-helper`
* Mach-O signature is left intact — we never strip or re-sign it here.
*/
async function buildCuHelper() {
async function buildCuHelper(arch: CuHelperArch) {
const buildScript = path.join(repoRoot, 'native', 'cu-helper', 'build.sh')
console.log(`[build-sidecars] Building native cu-helper via ${buildScript} ...`)
console.log(`[build-sidecars] Building native cu-helper (${arch}) via ${buildScript} ...`)
const proc = Bun.spawn(['bash', buildScript], {
cwd: path.dirname(buildScript),
env: createCuHelperBuildEnv(targetTriple, process.env),
// build.sh prints all diagnostics to STDERR and the ONE machine-readable
// `built: <abs path>` line to STDOUT, so capture stdout and inherit stderr.
stdout: 'pipe',
@@ -0,0 +1,42 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
createCuHelperBuildEnv,
resolveCuHelperArch,
} from './cu-helper-build-target'
describe('cu-helper release target', () => {
it('maps both macOS release triples to their native Swift architecture', () => {
expect(resolveCuHelperArch('aarch64-apple-darwin')).toBe('arm64')
expect(resolveCuHelperArch('x86_64-apple-darwin')).toBe('x86_64')
})
it('overrides a stale host architecture while preserving the signing environment', () => {
expect(createCuHelperBuildEnv('x86_64-apple-darwin', {
CU_HELPER_ARCH: 'arm64',
CU_HELPER_IDENTITY: 'Apple Development: Stale Identity',
CC_HAHA_SIGN_IDENTITY: 'Developer ID Application: Example',
PATH: '/usr/bin',
})).toMatchObject({
CU_HELPER_ARCH: 'x86_64',
CU_HELPER_IDENTITY: 'Developer ID Application: Example',
CC_HAHA_SIGN_IDENTITY: 'Developer ID Application: Example',
PATH: '/usr/bin',
})
})
it('drops a helper-only identity when no build-wide identity was selected', () => {
expect(createCuHelperBuildEnv('aarch64-apple-darwin', {
CU_HELPER_IDENTITY: 'Apple Development: Stale Identity',
}).CU_HELPER_IDENTITY).toBeUndefined()
})
it('skips non-macOS targets and rejects unknown Apple architectures', () => {
expect(resolveCuHelperArch('x86_64-pc-windows-msvc')).toBeNull()
expect(resolveCuHelperArch('aarch64-unknown-linux-gnu')).toBeNull()
expect(() => resolveCuHelperArch('armv7-apple-darwin')).toThrow(
'unsupported macOS cu-helper target',
)
})
})
+37
View File
@@ -0,0 +1,37 @@
export type CuHelperArch = 'arm64' | 'x86_64'
/**
* Resolve the Swift helper architecture from the package target, never from
* the build host. macOS x64 releases are commonly cross-built on Apple Silicon,
* so `uname -m` is not a valid source of truth here.
*/
export function resolveCuHelperArch(targetTriple: string): CuHelperArch | null {
if (targetTriple === 'aarch64-apple-darwin') return 'arm64'
if (targetTriple === 'x86_64-apple-darwin') return 'x86_64'
if (targetTriple.endsWith('-apple-darwin')) {
throw new Error(`[build-sidecars] unsupported macOS cu-helper target: ${targetTriple}`)
}
return null
}
export function createCuHelperBuildEnv(
targetTriple: string,
inheritedEnv: NodeJS.ProcessEnv,
): NodeJS.ProcessEnv {
const arch = resolveCuHelperArch(targetTriple)
if (!arch) {
throw new Error(`[build-sidecars] cannot build cu-helper for non-macOS target: ${targetTriple}`)
}
const env: NodeJS.ProcessEnv = {
...inheritedEnv,
CU_HELPER_ARCH: arch,
}
// A helper-only override left in the developer's shell must never split the
// helper from the host/sidecar certificate selected for this build.
if (env.CC_HAHA_SIGN_IDENTITY) {
env.CU_HELPER_IDENTITY = env.CC_HAHA_SIGN_IDENTITY
} else {
delete env.CU_HELPER_IDENTITY
}
return env
}
+23
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
SIDECAR_SIGNING_IDENTIFIER,
codesignTimestampArgument,
resolveStableSigningIdentity,
} from './sign-identity'
@@ -42,6 +43,15 @@ describe('resolveStableSigningIdentity', () => {
).toBe('Developer ID Application: Other (AAAAAAAAAA)')
})
it('resolves a SHA-1 override to its Developer ID common name', () => {
expect(
resolveStableSigningIdentity(
BOTH_IDENTITIES,
'5145958D6E31AD0CD6BBACD804A0B357E3CEDEA7',
),
).toBe('Developer ID Application: Example Co., Ltd (D3RS24869F)')
})
it('ignores a blank override rather than treating it as "no identity"', () => {
// An unset env var arrives as '' or a stray space; that must not suppress
// auto-detection and silently produce an ad-hoc build.
@@ -84,3 +94,16 @@ describe('SIDECAR_SIGNING_IDENTIFIER', () => {
expect(SIDECAR_SIGNING_IDENTIFIER).toBe('com.claude-code-haha.desktop.sidecar')
})
})
describe('codesignTimestampArgument', () => {
it('requires a secure timestamp for Developer ID distribution', () => {
expect(codesignTimestampArgument('Developer ID Application: Example (TEAMID1234)'))
.toBe('--timestamp')
})
it('keeps local development and ad-hoc signing offline', () => {
expect(codesignTimestampArgument('Apple Development: Example (TEAMID1234)'))
.toBe('--timestamp=none')
expect(codesignTimestampArgument(null)).toBe('--timestamp=none')
})
})
+24 -6
View File
@@ -31,6 +31,12 @@
/** A code-signing identity's full common name, as `codesign --sign` wants it. */
export type SigningIdentity = string
export function codesignTimestampArgument(identity: SigningIdentity | null): '--timestamp' | '--timestamp=none' {
return identity?.startsWith('Developer ID Application:')
? '--timestamp'
: '--timestamp=none'
}
/** The fixed code-signing identifier the helper's attestation policy expects. */
export const SIDECAR_SIGNING_IDENTIFIER = 'com.claude-code-haha.desktop.sidecar'
@@ -46,16 +52,28 @@ export function resolveStableSigningIdentity(
securityOutput: string,
override?: string | null,
): SigningIdentity | null {
const rows: Array<{ hash: string; name: string }> = []
for (const line of securityOutput.split('\n')) {
const match = /^\s*\d+\)\s+([0-9A-F]{40})\s+"(.+)"\s*$/i.exec(line)
if (match) rows.push({ hash: match[1].toUpperCase(), name: match[2] })
}
const explicit = override?.trim()
if (explicit) return explicit
if (explicit) {
// `codesign --sign` accepts a SHA-1 fingerprint, but downstream timestamp
// policy needs the certificate kind. Resolve a matching hash to its common
// name so Developer ID sidecars cannot accidentally ship without a secure
// timestamp. Unknown overrides remain trusted verbatim and will fail at
// codesign if they truly do not exist.
if (/^[0-9A-F]{40}$/i.test(explicit)) {
return rows.find(row => row.hash === explicit.toUpperCase())?.name ?? explicit
}
return explicit
}
// Rows look like: 1) <40-hex-sha> "Developer ID Application: Name (TEAMID)"
// Only the quoted common name is meaningful to `codesign --sign`.
const names: string[] = []
for (const line of securityOutput.split('\n')) {
const match = /^\s*\d+\)\s+[0-9A-F]{40}\s+"(.+)"\s*$/i.exec(line)
if (match) names.push(match[1])
}
const names = rows.map(row => row.name)
return (
names.find(name => name.startsWith('Developer ID Application:')) ??
+12
View File
@@ -74,6 +74,9 @@ export function __resetAppIconCacheForTests(): void {
export type ComputerUseStatus = {
platform: string
supported: boolean
engine: 'macos-native' | 'windows-compat' | 'unsupported'
systemVersion: string | null
arch: string
/**
* Native cu-helper engine availability. `available` is true only on macOS AND
* when the Swift `cu-helper` binary resolves on the server. The settings page
@@ -83,6 +86,14 @@ export type ComputerUseStatus = {
*/
cuHelper: {
available: boolean
supported: boolean
minimumMacosVersion: string
reason:
| 'unsupported_platform'
| 'system_version_unknown'
| 'os_too_old'
| 'helper_missing'
| null
}
python: {
installed: boolean
@@ -102,6 +113,7 @@ export type ComputerUseStatus = {
permissions: {
accessibility: boolean | null
screenRecording: boolean | null
error?: string | null
}
}
+10
View File
@@ -1382,6 +1382,15 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'settings.computerUse.enabledToggle': 'Enabled',
'settings.computerUse.disabledHint': 'Computer Use is off. New sessions will not inject the computer-use MCP server or expose desktop-control tools to the Coding Agent.',
'settings.computerUse.notSupported': 'Computer Use is only supported on macOS and Windows.',
'settings.computerUse.macosUnsupportedTitle': 'Computer Use requires macOS {version} or later',
'settings.computerUse.macosUnsupportedDetail': 'This Mac is running macOS {current}. The rest of the app remains available.',
'settings.computerUse.unknownVersion': 'an unknown version',
'settings.computerUse.macosDetectionFailedTitle': 'Unable to verify the macOS version',
'settings.computerUse.macosDetectionFailedDetail': 'Computer Use could not verify that this Mac meets the macOS 14.4 requirement. Recheck the status.',
'settings.computerUse.nativeUnavailableTitle': 'Computer Use runtime is unavailable',
'settings.computerUse.nativeUnavailableDetail': 'The native runtime is missing or could not be prepared. Recheck after reinstalling or updating the app.',
'settings.computerUse.configLoadFailed': 'Could not load the saved Computer Use setting.',
'settings.computerUse.configSaveFailed': 'Could not save the Computer Use setting. The previous value was restored.',
'settings.computerUse.python': 'Python 3',
'settings.computerUse.pythonNotFound': 'Not installed. Please install Python 3 first.',
'settings.computerUse.pythonFound': 'Installed',
@@ -1441,6 +1450,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'settings.computerUse.openCardFailed': 'Could not open the authorization card. Please try again later.',
'settings.computerUse.permNeeded': 'Needs authorization',
'settings.computerUse.permChecking': 'Checking…',
'settings.computerUse.permCheckFailed': 'Check failed',
'settings.computerUse.allowedAppsTitle': 'Always-Allowed Apps',
'settings.computerUse.allowedAppsDesc': 'These apps are authorized — Claude can control them directly without confirming each time.',
'settings.computerUse.allowedAppsEmpty': 'No always-allowed apps yet. Click “Add App” to authorize your first one.',
+10
View File
@@ -1384,6 +1384,15 @@ export const jp: Record<TranslationKey, string> = {
'settings.computerUse.enabledToggle': '有効',
'settings.computerUse.disabledHint': 'コンピューター操作はオフです。新しいセッションでは、computer-use MCP サーバーを注入したり、デスクトップ操作ツールをコーディングエージェントに公開したりしません。',
'settings.computerUse.notSupported': 'コンピューター操作は macOS と Windows でのみサポートされます。',
'settings.computerUse.macosUnsupportedTitle': 'Computer Use には macOS {version} 以降が必要です',
'settings.computerUse.macosUnsupportedDetail': 'この Mac は macOS {current} を実行しています。その他の機能は引き続き利用できます。',
'settings.computerUse.unknownVersion': '不明なバージョン',
'settings.computerUse.macosDetectionFailedTitle': 'macOS のバージョンを確認できません',
'settings.computerUse.macosDetectionFailedDetail': 'この Mac が macOS 14.4 の要件を満たすか確認できませんでした。状態を再確認してください。',
'settings.computerUse.nativeUnavailableTitle': 'Computer Use ランタイムを利用できません',
'settings.computerUse.nativeUnavailableDetail': 'ネイティブランタイムが見つからないか、準備に失敗しました。アプリを再インストールまたは更新してから再確認してください。',
'settings.computerUse.configLoadFailed': '保存済みの Computer Use 設定を読み込めませんでした。',
'settings.computerUse.configSaveFailed': 'Computer Use 設定を保存できなかったため、以前の値に戻しました。',
'settings.computerUse.python': 'Python 3',
'settings.computerUse.pythonNotFound': 'インストールされていません。まず Python 3 をインストールしてください。',
'settings.computerUse.pythonFound': 'インストール済み',
@@ -1443,6 +1452,7 @@ export const jp: Record<TranslationKey, string> = {
'settings.computerUse.openCardFailed': '認証カードを開けませんでした。しばらくしてからもう一度お試しください。',
'settings.computerUse.permNeeded': '認証が必要',
'settings.computerUse.permChecking': '確認中…',
'settings.computerUse.permCheckFailed': '確認に失敗しました',
'settings.computerUse.allowedAppsTitle': '常に許可するアプリ',
'settings.computerUse.allowedAppsDesc': 'これらのアプリは認証済みで、Claude は毎回確認することなく直接操作できます。',
'settings.computerUse.allowedAppsEmpty': '常に許可するアプリはまだありません。「アプリを追加」をクリックして最初のアプリを認証してください。',
+10
View File
@@ -1384,6 +1384,15 @@ export const kr: Record<TranslationKey, string> = {
'settings.computerUse.enabledToggle': '사용',
'settings.computerUse.disabledHint': '컴퓨터 사용이 꺼져 있습니다. 새 세션은 computer-use MCP 서버를 주입하거나 데스크톱 제어 도구를 코딩 에이전트에 노출하지 않습니다.',
'settings.computerUse.notSupported': '컴퓨터 사용은 macOS와 Windows에서만 지원됩니다.',
'settings.computerUse.macosUnsupportedTitle': 'Computer Use에는 macOS {version} 이상이 필요합니다',
'settings.computerUse.macosUnsupportedDetail': '이 Mac은 macOS {current}을 실행 중입니다. 다른 기능은 계속 사용할 수 있습니다.',
'settings.computerUse.unknownVersion': '알 수 없는 버전',
'settings.computerUse.macosDetectionFailedTitle': 'macOS 버전을 확인할 수 없습니다',
'settings.computerUse.macosDetectionFailedDetail': '이 Mac이 macOS 14.4 요구 사항을 충족하는지 확인하지 못했습니다. 상태를 다시 확인하세요.',
'settings.computerUse.nativeUnavailableTitle': 'Computer Use 런타임을 사용할 수 없습니다',
'settings.computerUse.nativeUnavailableDetail': '네이티브 런타임이 없거나 준비하지 못했습니다. 앱을 다시 설치하거나 업데이트한 뒤 다시 확인하세요.',
'settings.computerUse.configLoadFailed': '저장된 Computer Use 설정을 불러올 수 없습니다.',
'settings.computerUse.configSaveFailed': 'Computer Use 설정을 저장하지 못해 이전 값으로 복원했습니다.',
'settings.computerUse.python': 'Python 3',
'settings.computerUse.pythonNotFound': '설치되어 있지 않습니다. 먼저 Python 3를 설치하세요.',
'settings.computerUse.pythonFound': '설치됨',
@@ -1443,6 +1452,7 @@ export const kr: Record<TranslationKey, string> = {
'settings.computerUse.openCardFailed': '인증 카드를 열 수 없습니다. 나중에 다시 시도하세요.',
'settings.computerUse.permNeeded': '인증 필요',
'settings.computerUse.permChecking': '확인 중…',
'settings.computerUse.permCheckFailed': '확인 실패',
'settings.computerUse.allowedAppsTitle': '항상 허용된 앱',
'settings.computerUse.allowedAppsDesc': '이 앱들은 인증되어 Claude가 매번 확인 없이 직접 제어할 수 있습니다.',
'settings.computerUse.allowedAppsEmpty': '항상 허용된 앱이 아직 없습니다. “앱 추가”를 클릭하여 첫 번째 앱을 인증하세요.',
+10
View File
@@ -1383,6 +1383,15 @@ export const zh: Record<TranslationKey, string> = {
'settings.computerUse.enabledToggle': '啟用',
'settings.computerUse.disabledHint': 'Computer Use 已關閉。新會話不會注入 computer-use MCP,也不會把桌面控制工具暴露給 Coding Agent。',
'settings.computerUse.notSupported': 'Computer Use 僅支援 macOS 和 Windows。',
'settings.computerUse.macosUnsupportedTitle': 'Computer Use 需要 macOS {version} 或更新版本',
'settings.computerUse.macosUnsupportedDetail': '這台 Mac 目前執行 macOS {current},其他功能仍可繼續使用。',
'settings.computerUse.unknownVersion': '未知版本',
'settings.computerUse.macosDetectionFailedTitle': '無法確認 macOS 系統版本',
'settings.computerUse.macosDetectionFailedDetail': 'Computer Use 暫時無法確認這台 Mac 是否符合 macOS 14.4 要求,請重新檢測。',
'settings.computerUse.nativeUnavailableTitle': 'Computer Use 執行元件無法使用',
'settings.computerUse.nativeUnavailableDetail': '原生執行元件缺失或準備失敗。請重新安裝或更新 App 後再次檢測。',
'settings.computerUse.configLoadFailed': '無法讀取已儲存的 Computer Use 設定。',
'settings.computerUse.configSaveFailed': '無法儲存 Computer Use 設定,已恢復為先前的狀態。',
'settings.computerUse.python': 'Python 3',
'settings.computerUse.pythonNotFound': '未安裝,請先安裝 Python 3。',
'settings.computerUse.pythonFound': '已安裝',
@@ -1442,6 +1451,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.computerUse.openCardFailed': '無法開啟授權卡片,請稍後重試。',
'settings.computerUse.permNeeded': '待授權',
'settings.computerUse.permChecking': '檢測中…',
'settings.computerUse.permCheckFailed': '檢測失敗',
'settings.computerUse.allowedAppsTitle': '始終允許的應用',
'settings.computerUse.allowedAppsDesc': '這些應用已獲授權,Claude 可直接控制,無需每次確認。',
'settings.computerUse.allowedAppsEmpty': '還沒有始終允許的應用。點選「新增應用」來授權第一個。',
+10
View File
@@ -1383,6 +1383,15 @@ export const zh: Record<TranslationKey, string> = {
'settings.computerUse.enabledToggle': '启用',
'settings.computerUse.disabledHint': 'Computer Use 已关闭。新会话不会注入 computer-use MCP,也不会把桌面控制工具暴露给 Coding Agent。',
'settings.computerUse.notSupported': 'Computer Use 仅支持 macOS 和 Windows。',
'settings.computerUse.macosUnsupportedTitle': 'Computer Use 需要 macOS {version} 或更高版本',
'settings.computerUse.macosUnsupportedDetail': '这台 Mac 当前运行 macOS {current},其他功能仍可继续使用。',
'settings.computerUse.unknownVersion': '未知版本',
'settings.computerUse.macosDetectionFailedTitle': '无法确认 macOS 系统版本',
'settings.computerUse.macosDetectionFailedDetail': 'Computer Use 暂时无法确认这台 Mac 是否满足 macOS 14.4 要求,请重新检测。',
'settings.computerUse.nativeUnavailableTitle': 'Computer Use 运行组件不可用',
'settings.computerUse.nativeUnavailableDetail': '原生运行组件缺失或准备失败。请重新安装或更新 App 后再次检测。',
'settings.computerUse.configLoadFailed': '无法读取已保存的 Computer Use 设置。',
'settings.computerUse.configSaveFailed': '无法保存 Computer Use 设置,已恢复为之前的状态。',
'settings.computerUse.python': 'Python 3',
'settings.computerUse.pythonNotFound': '未安装,请先安装 Python 3。',
'settings.computerUse.pythonFound': '已安装',
@@ -1442,6 +1451,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.computerUse.openCardFailed': '无法打开授权卡片,请稍后重试。',
'settings.computerUse.permNeeded': '待授权',
'settings.computerUse.permChecking': '检测中…',
'settings.computerUse.permCheckFailed': '检测失败',
'settings.computerUse.allowedAppsTitle': '始终允许的应用',
'settings.computerUse.allowedAppsDesc': '这些应用已获授权,Claude 可直接控制,无需每次确认。',
'settings.computerUse.allowedAppsEmpty': '还没有始终允许的应用。点击「添加应用」来授权第一个。',
+211 -5
View File
@@ -5,6 +5,7 @@ import '@testing-library/jest-dom'
import { ComputerUseSettings } from './ComputerUseSettings'
import { useSettingsStore } from '../stores/settingsStore'
import { browserHost } from '../lib/desktopHost/browserHost'
import type { ComputerUseStatus } from '../api/computerUse'
const computerUseApiMock = vi.hoisted(() => ({
getStatus: vi.fn(),
@@ -21,10 +22,18 @@ vi.mock('../api/computerUse', () => ({
computerUseApi: computerUseApiMock,
}))
const readyStatus = {
platform: 'darwin',
const readyStatus: ComputerUseStatus = {
platform: 'win32',
supported: true,
cuHelper: { available: false },
engine: 'windows-compat' as const,
systemVersion: null,
arch: 'x64',
cuHelper: {
available: false,
supported: false,
minimumMacosVersion: '14.4',
reason: 'unsupported_platform' as const,
},
python: {
installed: true,
version: '3.12.0',
@@ -307,9 +316,18 @@ describe('ComputerUseSettings', () => {
})
describe('native cu-helper branch', () => {
const nativeStatus = {
const nativeStatus: ComputerUseStatus = {
...readyStatus,
cuHelper: { available: true },
platform: 'darwin',
engine: 'macos-native' as const,
systemVersion: '15.6',
arch: 'arm64',
cuHelper: {
available: true,
supported: true,
minimumMacosVersion: '14.4',
reason: null,
},
permissions: {
accessibility: false,
screenRecording: true,
@@ -345,6 +363,77 @@ describe('ComputerUseSettings', () => {
expect(screen.queryByRole('heading', { name: 'Computer Control' })).not.toBeInTheDocument()
})
it('keeps the native page selected when the helper is temporarily missing', async () => {
computerUseApiMock.getStatus.mockResolvedValue({
...nativeStatus,
cuHelper: {
...nativeStatus.cuHelper,
available: false,
reason: 'helper_missing',
},
})
render(<ComputerUseSettings />)
expect(await screen.findByText('Computer Use runtime is unavailable')).toBeInTheDocument()
expect(screen.getByRole('switch', { name: 'Enabled' })).toBeInTheDocument()
expect(screen.queryByLabelText('Python Interpreter Path')).not.toBeInTheDocument()
expect(screen.queryByText('Install Environment')).not.toBeInTheDocument()
})
it('shows the macOS floor instead of falling back to the compatibility page', async () => {
computerUseApiMock.getStatus.mockResolvedValue({
...nativeStatus,
supported: false,
engine: 'unsupported',
systemVersion: '14.3.1',
cuHelper: {
...nativeStatus.cuHelper,
available: false,
supported: false,
reason: 'os_too_old',
},
})
render(<ComputerUseSettings />)
expect(await screen.findByText('Computer Use requires macOS 14.4 or later')).toBeInTheDocument()
expect(screen.queryByLabelText('Python Interpreter Path')).not.toBeInTheDocument()
})
it('offers a retry when the macOS version probe is temporarily unavailable', async () => {
computerUseApiMock.getStatus.mockResolvedValue({
...nativeStatus,
supported: false,
engine: 'unsupported',
systemVersion: null,
cuHelper: {
...nativeStatus.cuHelper,
available: false,
supported: false,
reason: 'system_version_unknown',
},
})
render(<ComputerUseSettings />)
expect(await screen.findByText('Unable to verify the macOS version')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Recheck Status/ })).toBeInTheDocument()
expect(screen.queryByText('Computer Use requires macOS 14.4 or later')).not.toBeInTheDocument()
})
it('never falls through to the old Python page for a legacy macOS response', async () => {
const legacyStatus = { ...nativeStatus } as Partial<ComputerUseStatus>
delete legacyStatus.engine
computerUseApiMock.getStatus.mockResolvedValue(legacyStatus)
render(<ComputerUseSettings />)
expect(await screen.findByText('Computer Use runtime is unavailable')).toBeInTheDocument()
expect(screen.queryByLabelText('Python Interpreter Path')).not.toBeInTheDocument()
expect(screen.queryByText('Install Environment')).not.toBeInTheDocument()
})
it('waits for persisted config before showing the native toggle state', async () => {
const configRequest = deferred<typeof enabledConfig>()
computerUseApiMock.getStatus.mockResolvedValue(nativeStatus)
@@ -364,6 +453,108 @@ describe('ComputerUseSettings', () => {
expect(await screen.findByRole('switch', { name: 'Enabled' })).not.toBeChecked()
})
it('does not invent an enabled state when the saved config cannot be loaded', async () => {
computerUseApiMock.getStatus.mockResolvedValue(nativeStatus)
computerUseApiMock.getAuthorizedApps.mockRejectedValue(new Error('corrupt config'))
render(<ComputerUseSettings />)
expect(
await screen.findByText('Could not load the saved Computer Use setting.'),
).toBeInTheDocument()
expect(screen.queryByRole('switch', { name: 'Enabled' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
})
it('restores the prior toggle and does not open permissions when saving fails', async () => {
computerUseApiMock.getStatus.mockResolvedValue(nativeStatus)
computerUseApiMock.getAuthorizedApps.mockResolvedValue({
...enabledConfig,
enabled: false,
})
computerUseApiMock.setAuthorizedApps.mockRejectedValueOnce(new Error('write failed'))
render(<ComputerUseSettings />)
const toggle = await screen.findByRole('switch', { name: 'Enabled' })
expect(toggle).not.toBeChecked()
fireEvent.click(toggle)
expect(
await screen.findByText(
'Could not save the Computer Use setting. The previous value was restored.',
),
).toBeInTheDocument()
expect(toggle).not.toBeChecked()
expect(computerUseApiMock.openPermissionCard).not.toHaveBeenCalled()
})
it('keeps the native header and toggle when a later status refresh fails', async () => {
computerUseApiMock.getStatus
.mockResolvedValueOnce(nativeStatus)
.mockRejectedValueOnce(new Error('probe failed'))
render(<ComputerUseSettings />)
await screen.findByRole('heading', { name: 'Computer Control' })
fireEvent.click(screen.getByRole('button', { name: /Recheck Status/ }))
expect(await screen.findByText('Failed to check status.')).toBeInTheDocument()
expect(screen.getByRole('switch', { name: 'Enabled' })).toBeInTheDocument()
})
it('shows a permission probe failure instead of permanent checking labels', async () => {
computerUseApiMock.getStatus.mockResolvedValue({
...nativeStatus,
permissions: {
accessibility: null,
screenRecording: null,
error: 'unauthorized_client',
},
})
render(<ComputerUseSettings />)
expect(await screen.findAllByText('Check failed')).toHaveLength(2)
expect(screen.queryByText('Checking…')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /Recheck Status/ })).toBeInTheDocument()
})
it('ignores an older status refresh that resolves after a newer one', async () => {
const older = deferred<typeof nativeStatus>()
const newer = deferred<typeof nativeStatus>()
computerUseApiMock.getStatus
.mockResolvedValueOnce(nativeStatus)
.mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise)
render(<ComputerUseSettings />)
await screen.findByRole('heading', { name: 'Computer Control' })
const recheck = screen.getByRole('button', { name: /Recheck Status/ })
fireEvent.click(recheck)
fireEvent.click(recheck)
await act(async () => {
newer.resolve(nativeStatus)
await newer.promise
})
await act(async () => {
older.resolve({
...nativeStatus,
cuHelper: {
...nativeStatus.cuHelper,
available: false,
reason: 'helper_missing',
},
})
await older.promise
})
expect(screen.queryByText('Computer Use runtime is unavailable')).not.toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Computer Control' })).toBeInTheDocument()
})
/**
* Rows show the application's own icon, served per bundle id. The letter
* tile is the fallback for bundles that ship no icon, so it must appear on
@@ -490,6 +681,21 @@ describe('ComputerUseSettings', () => {
expect(computerUseApiMock.openPermissionCard).toHaveBeenCalledTimes(1)
})
it('surfaces a permission-card command failure returned by the server', async () => {
computerUseApiMock.getStatus.mockResolvedValue(nativeStatus)
computerUseApiMock.openPermissionCard.mockResolvedValue({
ok: false,
reason: 'helper launch failed',
})
render(<ComputerUseSettings />)
fireEvent.click(await screen.findByText('Reopen authorization card'))
expect(
await screen.findByText('Could not open the authorization card. Please try again later.'),
).toBeInTheDocument()
})
it('removes an always-allowed app via the trash button', async () => {
computerUseApiMock.getStatus.mockResolvedValue(nativeStatus)
computerUseApiMock.getAuthorizedApps.mockResolvedValue({
+192 -41
View File
@@ -54,7 +54,8 @@ export function ComputerUseSettings() {
const t = useTranslation()
const [status, setStatus] = useState<ComputerUseStatus | null>(null)
const [checkState, setCheckState] = useState<CheckState>('loading')
const [configSettled, setConfigSettled] = useState(false)
const [configState, setConfigState] = useState<CheckState>('loading')
const [configError, setConfigError] = useState<string | null>(null)
const [setupRunning, setSetupRunning] = useState(false)
const [setupResult, setSetupResult] = useState<SetupResult | null>(null)
@@ -77,14 +78,18 @@ export function ComputerUseSettings() {
const [cardOpening, setCardOpening] = useState(false)
const [cardError, setCardError] = useState<string | null>(null)
const configMutationSeqRef = useRef(0)
const statusRequestSeqRef = useRef(0)
const fetchStatus = useCallback(async () => {
const requestSeq = ++statusRequestSeqRef.current
setCheckState('loading')
try {
const s = await computerUseApi.getStatus()
if (requestSeq !== statusRequestSeqRef.current) return
setStatus(s)
setCheckState('ready')
} catch {
if (requestSeq !== statusRequestSeqRef.current) return
setCheckState('error')
}
}, [])
@@ -105,12 +110,15 @@ export function ComputerUseSettings() {
const fetchConfig = useCallback(async () => {
const requestSeq = configMutationSeqRef.current
setConfigState('loading')
try {
applyConfig(await computerUseApi.getAuthorizedApps(), requestSeq)
const config = await computerUseApi.getAuthorizedApps()
if (requestSeq !== configMutationSeqRef.current) return
applyConfig(config, requestSeq)
setConfigState('ready')
} catch {
// API not ready
} finally {
setConfigSettled(true)
if (requestSeq !== configMutationSeqRef.current) return
setConfigState('error')
}
}, [applyConfig])
@@ -200,13 +208,24 @@ export function ComputerUseSettings() {
})
}
const toggleComputerUseEnabled = (value: boolean) => {
configMutationSeqRef.current += 1
const toggleComputerUseEnabled = async (value: boolean): Promise<boolean> => {
const requestSeq = ++configMutationSeqRef.current
const previous = computerUseEnabled
setConfigError(null)
setComputerUseEnabled(value)
computerUseApi.setAuthorizedApps({ enabled: value }).then(() => {
try {
await computerUseApi.setAuthorizedApps({ enabled: value })
if (requestSeq !== configMutationSeqRef.current) return true
setAppsSaved(true)
setTimeout(() => setAppsSaved(false), 1500)
})
return true
} catch {
if (requestSeq === configMutationSeqRef.current) {
setComputerUseEnabled(previous)
setConfigError(t('settings.computerUse.configSaveFailed'))
}
return false
}
}
// ── Native (cu-helper) handlers ──
@@ -218,7 +237,8 @@ export function ComputerUseSettings() {
setCardOpening(true)
setCardError(null)
try {
await computerUseApi.openPermissionCard()
const result = await computerUseApi.openPermissionCard()
if (!result.ok) throw new Error(result.reason ?? 'permission card failed')
} catch {
setCardError(t('settings.computerUse.openCardFailed'))
} finally {
@@ -232,14 +252,17 @@ export function ComputerUseSettings() {
// for persistence, but additionally pops the native OS-permission card when
// turning ON while macOS permissions are still missing (the headline flow).
const toggleAnyApp = (value: boolean) => {
toggleComputerUseEnabled(value)
if (
value &&
(status?.permissions.accessibility === false ||
status?.permissions.screenRecording === false)
) {
void openPermissionCard()
}
void (async () => {
const saved = await toggleComputerUseEnabled(value)
if (
saved &&
value &&
(status?.permissions.accessibility === false ||
status?.permissions.screenRecording === false)
) {
await openPermissionCard()
}
})()
}
// Persist an authorized-apps list change (native add/remove). Reuses the same
@@ -365,7 +388,7 @@ export function ComputerUseSettings() {
// Native (cu-helper) path: drop the entire Python setup flow in favor of the
// Codex-style page. Branch ONLY when on macOS AND the Swift helper resolves.
const native = status?.platform === 'darwin' && status?.cuHelper?.available === true
const native = status?.engine === 'macos-native'
// Picker list (native "+ 添加应用"): installed apps not yet authorized, sorted.
const pickerApps = useMemo(() => {
@@ -398,7 +421,7 @@ export function ComputerUseSettings() {
// Status chooses the page implementation, while config supplies the switch
// value. Waiting for both prevents a native disabled setting from briefly
// rendering as enabled when the capability probe wins the race.
if (!configSettled) {
if (configState === 'loading') {
return (
<div className="max-w-2xl">
<LoadingState size="md" label={t('common.loading')} />
@@ -406,6 +429,52 @@ export function ComputerUseSettings() {
)
}
if (configState === 'error') {
return (
<div className="max-w-2xl">
<ErrorState
size="lg"
title={t('settings.computerUse.configLoadFailed')}
retryLabel={t('common.retry')}
onRetry={fetchConfig}
/>
</div>
)
}
if (status.engine === 'unsupported') {
const macosVersionProblem = status.platform === 'darwin'
const versionDetectionFailed = status.cuHelper.reason === 'system_version_unknown'
return (
<div className="max-w-2xl space-y-5">
<div>
<h2 className="text-[24px] font-semibold leading-tight text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-headline)' }}>
{t('settings.computerUse.controlTitle')}
</h2>
<p className="mt-1.5 text-[13.5px] leading-6 text-[var(--color-text-secondary)]">
{t('settings.computerUse.controlSubtitle')}
</p>
</div>
<ErrorState
size="lg"
title={versionDetectionFailed
? t('settings.computerUse.macosDetectionFailedTitle')
: macosVersionProblem
? t('settings.computerUse.macosUnsupportedTitle', { version: status.cuHelper.minimumMacosVersion })
: t('settings.computerUse.notSupported')}
detail={versionDetectionFailed
? t('settings.computerUse.macosDetectionFailedDetail')
: macosVersionProblem
? t('settings.computerUse.macosUnsupportedDetail', { current: status.systemVersion ?? t('settings.computerUse.unknownVersion') })
: undefined}
retryLabel={versionDetectionFailed ? t('settings.computerUse.recheckBtn') : undefined}
onRetry={versionDetectionFailed ? fetchStatus : undefined}
tone="strong"
/>
</div>
)
}
if (native && status) {
return (
<NativeComputerUse
@@ -416,6 +485,8 @@ export function ComputerUseSettings() {
authorizedApps={authorizedApps}
onRemoveApp={removeAuthorizedApp}
appsSaved={appsSaved}
configError={configError}
statusError={checkState === 'error'}
cardOpening={cardOpening}
cardError={cardError}
onOpenCard={openPermissionCard}
@@ -432,6 +503,24 @@ export function ComputerUseSettings() {
)
}
// The Python compatibility page is Windows-only. Missing or future engine
// values (for example during a rolling sidecar/UI upgrade) fail closed on
// the native page instead of resurrecting the retired macOS setup screen.
if (status.engine !== 'windows-compat') {
return (
<div className="max-w-2xl space-y-5">
<ErrorState
size="lg"
title={t('settings.computerUse.nativeUnavailableTitle')}
detail={t('settings.computerUse.nativeUnavailableDetail')}
retryLabel={t('settings.computerUse.recheckBtn')}
onRetry={fetchStatus}
tone="strong"
/>
</div>
)
}
return (
<div className="max-w-2xl space-y-6">
{/* Title */}
@@ -442,7 +531,7 @@ export function ComputerUseSettings() {
</h2>
<Switch
checked={computerUseEnabled}
onChange={toggleComputerUseEnabled}
onChange={value => { void toggleComputerUseEnabled(value) }}
label={t('settings.computerUse.enabledToggle')}
size="sm"
/>
@@ -452,6 +541,12 @@ export function ComputerUseSettings() {
</p>
</div>
{configError && (
<div className="px-4 py-3 rounded-[var(--radius-lg)] border border-[var(--color-error)] bg-[var(--color-error-container)] text-sm text-[var(--color-on-error-container)]">
{configError}
</div>
)}
{!computerUseEnabled && (
<div className="px-4 py-3 rounded-[var(--radius-lg)] border border-[var(--color-warning)] bg-[var(--color-warning-container)] text-sm text-[var(--color-on-warning-container)]">
{t('settings.computerUse.disabledHint')}
@@ -826,26 +921,34 @@ function PermissionStatusRow({
t,
label,
state,
failed = false,
}: {
t: Translate
label: string
state: boolean | null
failed?: boolean
}) {
const granted = state === true
const needed = state === false
const detail = granted
const detail = failed
? t('settings.computerUse.permCheckFailed')
: granted
? t('settings.computerUse.permGranted')
: needed
? t('settings.computerUse.permNeeded')
: t('settings.computerUse.permChecking')
const dotClass = granted
const dotClass = failed
? 'bg-[var(--color-error)]'
: granted
? 'bg-[var(--color-success)]'
: needed
? 'bg-[var(--color-warning)]'
: 'bg-[var(--color-text-tertiary)]'
// Status colors ride the semantic tokens so they follow [data-theme]
// (stock emerald/amber shades are fixed colors — see paletteEscapes.test.ts).
const detailClass = granted
const detailClass = failed
? 'text-[var(--color-error)]'
: granted
? 'text-[var(--color-success)]'
: needed
? 'text-[var(--color-warning)]'
@@ -874,6 +977,8 @@ function NativeComputerUse({
authorizedApps,
onRemoveApp,
appsSaved,
configError,
statusError,
cardOpening,
cardError,
onOpenCard,
@@ -894,6 +999,8 @@ function NativeComputerUse({
authorizedApps: AuthorizedApp[]
onRemoveApp: (bundleId: string) => void
appsSaved: boolean
configError: string | null
statusError: boolean
cardOpening: boolean
cardError: string | null
onOpenCard: () => void
@@ -909,26 +1016,68 @@ function NativeComputerUse({
}) {
const accessibility = status.permissions.accessibility
const screenRecording = status.permissions.screenRecording
const permissionProbeFailed = Boolean(status.permissions.error)
const header = (
<div className="flex items-start justify-between gap-6">
<div className="min-w-0">
<h2 className="text-lg font-semibold tracking-tight text-[var(--color-text-primary)]">
{t('settings.computerUse.controlTitle')}
</h2>
<p className="mt-1.5 text-sm leading-relaxed text-[var(--color-text-secondary)]">
{t('settings.computerUse.controlSubtitle')}
</p>
</div>
<Switch
checked={enabled}
onChange={onToggleEnabled}
label={t('settings.computerUse.enabledToggle')}
size="sm"
/>
</div>
)
const configErrorNotice = configError ? (
<div className="px-4 py-3 rounded-[var(--radius-lg)] border border-[var(--color-error)] bg-[var(--color-error-container)] text-sm text-[var(--color-on-error-container)]">
{configError}
</div>
) : null
if (statusError) {
return (
<div className="max-w-2xl space-y-5">
{header}
{configErrorNotice}
<ErrorState
size="lg"
title="Failed to check status."
retryLabel={t('common.retry')}
onRetry={onRecheck}
tone="strong"
/>
</div>
)
}
if (!status.cuHelper.available) {
return (
<div className="max-w-2xl space-y-5">
{header}
{configErrorNotice}
<ErrorState
size="lg"
title={t('settings.computerUse.nativeUnavailableTitle')}
detail={t('settings.computerUse.nativeUnavailableDetail')}
retryLabel={t('settings.computerUse.recheckBtn')}
onRetry={onRecheck}
tone="strong"
/>
</div>
)
}
return (
<div className="max-w-2xl space-y-10">
{/* Title */}
<div className="flex items-start justify-between gap-6">
<div className="min-w-0">
<h2 className="text-lg font-semibold tracking-tight text-[var(--color-text-primary)]">
{t('settings.computerUse.controlTitle')}
</h2>
<p className="mt-1.5 text-sm leading-relaxed text-[var(--color-text-secondary)]">
{t('settings.computerUse.controlSubtitle')}
</p>
</div>
<Switch
checked={enabled}
onChange={onToggleEnabled}
label={t('settings.computerUse.enabledToggle')}
size="sm"
/>
</div>
{header}
{configErrorNotice}
{/* ─── 控制 (Control) ─── */}
<section className="space-y-3">
@@ -952,11 +1101,13 @@ function NativeComputerUse({
t={t}
label={t('settings.computerUse.accessibility')}
state={accessibility}
failed={permissionProbeFailed}
/>
<PermissionStatusRow
t={t}
label={t('settings.computerUse.screenRecording')}
state={screenRecording}
failed={permissionProbeFailed}
/>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
@@ -50,7 +50,7 @@ The focused UI element is 2 outline.
- **子节点不止 kAXChildren**:并 `kAXChildren + kAXRows + AXContents + AXVisibleChildren`,按角色选主源(outline/list/table/AXBrowser 用 AXRows),CFEqual 去重,跳过菜单栏下的 Apple 菜单。**否则 Finder/系统设置/活动监视器的行全丢。**
- **环路守卫**:传 ancestors 集合,CFEqual 命中祖先则跳过(Electron 树有环,否则重复子树)。
- **【关键】泛容器消除 + 扁平化**:剪掉无描述的 AXGroup/AXUnknown 包装(同深递归进子)、单子无意义组折叠、纯文本兄弟合并成一个 ` text …`、链接渲染成 markdown `[text](url)` 并吞子。**没有这步,Electron 的 AXWebArea 是几千个空 wrapper,在到达有用控件前就撑爆 cap——这正是"Electron 看起来读不到树"的真因。**
- **【关键】泛容器消除 + 扁平化**:剪掉无描述的 AXGroup/AXUnknown 包装(同深递归进子)、单子无意义组折叠、纯文本兄弟合并成一个 ` text …`、链接渲染成 Markdown 的文本加 URL 形式并吞子。**没有这步,Electron 的 AXWebArea 是几千个空 wrapper,在到达有用控件前就撑爆 cap——这正是"Electron 看起来读不到树"的真因。**
- **菜单栏第二趟**:走完窗口后 `walk(copyElement(app, kAXMenuBar))` 追加(否则不能按 index 点菜单)。
- **行可见性窗口化**:outline/list 只 emit 可见行(与父框相交),cap 20,容器加 ` (showing 0-N of M items)` 摘要。
- **window-relative frame**:`localFrame = elementFrame - windowBounds.origin`,内部存。
@@ -4,7 +4,7 @@
>
> 本文是**技术方案/评审稿**,不含落地代码。决策点见末尾「八、待决策」。
相关文档:[Computer Use 架构深度解析](./computer-use-architecture.md) · [功能指南](./computer-use.md)
相关文档:[Computer Use 架构深度解析](./computer-use.md) · [功能指南](../desktop/computer-use.md)
---
+2 -2
View File
@@ -170,7 +170,7 @@ socket, which avoids a connect race against `bind()`/`listen()`.
| Verb | Effect | `result` |
|----------------|-----------------------------------------------------------------------------------------|----------|
| `overlay_show` | `cursor.show()` + `glow.show(over: frontmost app)`. Reveals the virtual cursor + glow. | `true` |
| `overlay_hide` | `cursor.hide()` + `glow.hide(animated: true)`. Parks the cursor, fades the glow. | `true` |
| `overlay_hide` | Parks the cursor and resets turn-owned AX/input/focus state. The keyed `SCStream` remains warm until target/config change, disconnect, or daemon teardown. | `true` |
| `ping` | Liveness probe. | `"pong"` |
| `shutdown` | Returns `true`, then `NSApp.terminate(nil)` for a graceful exit. | `true` |
@@ -263,7 +263,7 @@ invocation*, swapping the Python interpreter + `mac_helper.py` for the signed
- **bundled (Tauri):** the sidecar resolved from `binaries/cu-helper` (§3.5).
`pythonBridge.ts` is **not** edited; the Windows path (`win_helper.py`) is
untouched (this helper is macOS-only — `Package.swift` targets `.macOS("14.0")`).
untouched (this helper is macOS-only — `Package.swift` targets `.macOS("14.4")`).
### 3.3 `src/utils/computerUse/wrapper.tsx` — `acquireCuLock` fresh branch
+1 -1
View File
@@ -32,7 +32,7 @@
<key>LSUIElement</key>
<true/>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<string>14.4</string>
<key>NSScreenCaptureUsageDescription</key>
<string>Claude 需要屏幕录制权限来截取屏幕,以便在你的电脑上执行操作。</string>
</dict>
+5 -5
View File
@@ -16,15 +16,15 @@
// (Swift 6.3.2 / Xcode 26.5 / macOS 26.4.1, Apple Silicon arm64) compiles the
// `@MainActor` AppKit/ScreenCaptureKit code under full isolation checking.
//
// platforms .macOS("14.0"): SCShareableContent / SCContentFilter /
// SCScreenshotManager (the modern screenshot path) require macOS 14+. The build
// host (26.5) far exceeds this; the floor only constrains the availability
// annotations the capture code must carry.
// platforms .macOS("14.4"): match the reference Computer Use service's runtime
// floor. Keeping this subsystem floor above the desktop host's floor lets the
// app show a deterministic unsupported state instead of launching a helper the
// OS loader will reject.
import PackageDescription
let package = Package(
name: "cu-helper",
platforms: [.macOS("14.0")],
platforms: [.macOS("14.4")],
targets: [
// Tiny C shim exposing the private `responsibility_spawnattrs_setdisclaim`
// self-re-exec (see Sources/CDisclaim/disclaim.c). Lets cu-helper become its
@@ -105,7 +105,6 @@ public final class CommandRouter {
Self.lastCaptureDigest.removeAll()
MutationClock.reset()
ClipboardPasteReceipt.resetForTurn()
windowCaptureProvider?.invalidate()
// Apps we told they were focused must be told they are not, or the
// belief outlives the session that needed it.
SyntheticWindowFocus.relinquishAll()
@@ -221,6 +221,7 @@ public final class Daemon {
// never strand a stuck modifier/button when teardown is followed
// immediately by exit() (shutdown verb + signal handlers).
Injection.releaseAllHeldSync()
router.invalidateWindowCaptureStream()
router.resetSessionState()
displaySleepAssertion.release()
inputMonitor.stop()
@@ -437,6 +438,7 @@ public final class Daemon {
private func cleanupDisconnectedSession() {
stopOverlaySession()
Injection.releaseAllHeldSync()
router.invalidateWindowCaptureStream()
router.resetSessionState()
turnGate.reset()
displaySleepAssertion.release()
@@ -603,12 +605,13 @@ public final class Daemon {
explicitOverlayTarget = nil
Injection.clearResolvedTarget()
cursor.hide()
router.invalidateWindowCaptureStream()
}
/// Codex-parity turn boundary. The helper process stays warm, but no AX
/// snapshot, coordinate transform, focus belief, held input, mutation clock,
/// clipboard diagnostic, or capture stream may leak into the next turn.
/// Codex-parity turn boundary. The helper process and its SCStream consumer
/// stay warm, while AX snapshots, coordinate transforms, focus belief,
/// held input, mutation clocks, and clipboard diagnostics are reset. The
/// stream key itself proves process/window/config identity and retires on
/// any target change, screen reconfiguration, disconnect, or shutdown.
private func endTurn() {
stopOverlaySession()
Injection.releaseAllHeldSync()
@@ -215,21 +215,27 @@ final class WindowCaptureStreamManager: WindowCaptureProviding {
}
/// Match the reference's two separate lifetimes: SCStream remains a
/// consumer while covered; every state read runs an on-demand Skyshot/SCK
/// capture. An idle stream's cached frame is not evidence of the current UI.
/// daemon-lifetime consumer while covered; every state read runs an
/// on-demand Skyshot/SCK capture. The stream must have produced a real
/// pixel frame before its on-demand screenshot may be treated as live.
func captureSnapshot(
for target: WindowCaptureStreamTarget,
scale: Double,
newerThanUptime: TimeInterval? = nil
) async -> WindowShot? {
if let newerThanUptime {
// The stream is a long-lived render/freshness consumer, not the
// model screenshot source. Before the post-action Skyshot, observe
// a stream frame newer than the action when possible. `frame`
// performs one bounded rebuild for a silently starved stream; a
// static/no-op UI may legitimately emit no changed frame, so the
// authoritative on-demand screenshot still runs after the bound.
_ = await frame(for: target, newerThanUptime: newerThanUptime)
// The stream is a long-lived render/freshness consumer, not the model
// screenshot source. A brand-new source must deliver its first pixel
// frame before we trust an on-demand screenshot for a covered window.
// After an input mutation, the frame must additionally be newer than
// the action watermark. `frame` performs one bounded rebuild for a
// silently starved stream; if neither source produces qualifying
// pixels, fail closed instead of labelling compositor-cached pixels as
// stream-backed.
guard await frame(
for: target,
newerThanUptime: newerThanUptime
) != nil else {
return nil
}
for _ in 0..<2 {
guard let source = await source(for: target) else { continue }
@@ -534,9 +540,10 @@ final class ScreenCaptureKitWindowStreamSource: WindowCaptureStreamSource {
guard let stream else { return }
self.stream = nil
// Do not await SCK shutdown on overlay_hide/disconnect. The mailbox is
// already inert, and retaining the stream in this completion closure
// lets ScreenCaptureKit finish cleanup without delaying the turn.
// Do not await SCK shutdown on target replacement, disconnect, or
// daemon teardown. The mailbox is already inert, and retaining the
// stream in this completion closure lets ScreenCaptureKit finish
// cleanup without delaying the request.
Task { @MainActor in
try? await stream.stopCapture()
}
@@ -126,7 +126,7 @@ final class CommandRouterSafetyTests: XCTestCase {
}
}
func testSessionResetAlsoInvalidatesTheWindowCaptureProvider() {
func testTurnStateResetPreservesTheDaemonLifetimeWindowCaptureProvider() {
let monitor = PhysicalInputEpochMonitor(counterReader: { _ in 0 })
let provider = WindowCaptureProviderSpy()
let router = CommandRouter(
@@ -138,6 +138,8 @@ final class CommandRouterSafetyTests: XCTestCase {
router.resetSessionState()
XCTAssertEqual(provider.invalidateCount, 0)
router.invalidateWindowCaptureStream()
XCTAssertEqual(provider.invalidateCount, 1)
}
@@ -52,7 +52,7 @@ final class DaemonOverlayTargetTests: XCTestCase {
))
}
func testEveryOverlayStopAlsoInvalidatesTheLongLivedWindowStream() throws {
func testTurnEndPreservesTheLongLivedWindowStreamUntilDaemonTeardown() throws {
let sourceURL = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
@@ -68,6 +68,17 @@ final class DaemonOverlayTargetTests: XCTestCase {
}
)
XCTAssertTrue(body.contains("router.invalidateWindowCaptureStream()"))
XCTAssertFalse(body.contains("router.invalidateWindowCaptureStream()"))
XCTAssertTrue(body.contains("router.resetSessionState()"))
let teardownBody = try XCTUnwrap(
source.range(of: "private func teardown()").flatMap { start in
source.range(
of: "private func bindAndListen",
range: start.upperBound..<source.endIndex
).map { end in String(source[start.lowerBound..<end.lowerBound]) }
}
)
XCTAssertTrue(teardownBody.contains("router.invalidateWindowCaptureStream()"))
}
}
@@ -48,10 +48,10 @@ final class WindowCaptureStreamTests: XCTestCase {
XCTAssertEqual(refreshed.latestFrameAgeSeconds, 1)
XCTAssertEqual(refreshed.sampleCount, 3)
XCTAssertEqual(refreshed.latestSampleStatus, SCFrameStatus.complete.rawValue)
XCTAssertEqual(source.latestReadCount, 0)
XCTAssertGreaterThan(source.latestReadCount, 0)
XCTAssertEqual(source.startCount, 1)
XCTAssertEqual(source.retireCount, 0)
XCTAssertEqual(captures, 1, "Inspecting metadata must not take screenshots")
XCTAssertEqual(captures, 0, "A source without a pixel frame must not take a screenshot")
}
func testDiagnosticFailureAndInvalidationDoNotRebuildOrExposeRetiredFrames() async throws {
@@ -80,7 +80,7 @@ final class WindowCaptureStreamTests: XCTestCase {
XCTAssertNil(retired.latestFrameSequence)
XCTAssertNil(retired.latestFrameAgeSeconds)
XCTAssertNil(retired.latestSampleStatus)
XCTAssertEqual(source.latestReadCount, 0)
XCTAssertGreaterThan(source.latestReadCount, 0)
XCTAssertEqual(source.retireCount, 1)
}
@@ -116,7 +116,14 @@ final class WindowCaptureStreamTests: XCTestCase {
func testEveryStateReadTakesANewSnapshotWhileReusingTheLongLivedStream() async throws {
let target = makeTarget(windowID: 84)
let factory = FakeWindowCaptureStreamFactory { _, _ in }
let factory = FakeWindowCaptureStreamFactory { source, _ in
source.startFrame = makeFrame(
for: source.targetKey,
sequence: 1,
uptime: 10,
byte: 7
)
}
var captures = 0
let manager = WindowCaptureStreamManager(factory: factory, takeSnapshot: { target, _ in
captures += 1
@@ -129,7 +136,7 @@ final class WindowCaptureStreamTests: XCTestCase {
XCTAssertEqual(captures, 2)
XCTAssertEqual(factory.sources.count, 1)
XCTAssertEqual(factory.sources[0].startCount, 1)
XCTAssertEqual(factory.sources[0].latestReadCount, 0, "A cached stream frame must not become the model's screenshot")
XCTAssertGreaterThan(factory.sources[0].latestReadCount, 0)
}
func testPostMutationSnapshotConsumesFreshStreamWatermarkBeforeSkyshot() async throws {
@@ -161,6 +168,39 @@ final class WindowCaptureStreamTests: XCTestCase {
XCTAssertEqual(factory.sources[0].retireCount, 0)
}
func testPostMutationSnapshotFailsClosedWhenNoFreshStreamFrameArrives() async {
let target = makeTarget(windowID: 88)
let factory = FakeWindowCaptureStreamFactory { source, _ in
source.startFrame = makeFrame(
for: source.targetKey,
sequence: 1,
uptime: 10,
byte: 7
)
}
var captures = 0
let manager = WindowCaptureStreamManager(
factory: factory,
frameWaitAttempts: 0,
frameWaitNanoseconds: 0,
takeSnapshot: { target, _ in
captures += 1
return self.makeSnapshot(target, pixels: "must-not-run")
}
)
let shot = await manager.captureSnapshot(
for: target,
scale: 0.5,
newerThanUptime: 11
)
XCTAssertNil(shot)
XCTAssertEqual(captures, 0)
XCTAssertEqual(factory.sources.count, 2, "One bounded stream rebuild is attempted")
XCTAssertEqual(factory.sources[0].retireCount, 1)
}
func testSnapshotFailureDoesNotFallBackToCachedStreamPixels() async {
let target = makeTarget(windowID: 85)
let factory = FakeWindowCaptureStreamFactory { source, _ in
@@ -169,12 +209,19 @@ final class WindowCaptureStreamTests: XCTestCase {
let manager = WindowCaptureStreamManager(factory: factory, takeSnapshot: { _, _ in nil })
let shot = await manager.captureSnapshot(for: target, scale: 0.5)
XCTAssertNil(shot)
XCTAssertEqual(factory.sources[0].latestReadCount, 0)
XCTAssertGreaterThan(factory.sources[0].latestReadCount, 0)
}
func testSnapshotFinishingAfterSessionInvalidationIsDiscarded() async {
let target = makeTarget(windowID: 86)
let factory = FakeWindowCaptureStreamFactory { _, _ in }
let factory = FakeWindowCaptureStreamFactory { source, _ in
source.startFrame = makeFrame(
for: source.targetKey,
sequence: 1,
uptime: 10,
byte: 7
)
}
var manager: WindowCaptureStreamManager!
manager = WindowCaptureStreamManager(factory: factory, takeSnapshot: { target, _ in
manager.invalidate()
@@ -198,7 +245,14 @@ final class WindowCaptureStreamTests: XCTestCase {
MutationClock.resetForTests()
defer { MutationClock.resetForTests() }
let target = makeTarget(windowID: 81)
let factory = FakeWindowCaptureStreamFactory { _, _ in }
let factory = FakeWindowCaptureStreamFactory { source, _ in
source.startFrame = makeFrame(
for: source.targetKey,
sequence: 1,
uptime: 10,
byte: 7
)
}
var captures = 0
var captureTimes: [TimeInterval] = []
let manager = WindowCaptureStreamManager(factory: factory, takeSnapshot: { target, _ in
@@ -229,14 +283,21 @@ final class WindowCaptureStreamTests: XCTestCase {
XCTAssertNil(MutationClock.lastMutation(), "The settle marker is one-shot")
}
XCTAssertEqual(factory.sources.count, 1)
XCTAssertEqual(factory.sources[0].latestReadCount, 0)
XCTAssertGreaterThan(factory.sources[0].latestReadCount, 0)
}
func testPartiallyFailedDispatchAlsoSettlesBeforeTheOnDemandSnapshot() async throws {
MutationClock.resetForTests()
defer { MutationClock.resetForTests() }
let target = makeTarget(windowID: 83)
let factory = FakeWindowCaptureStreamFactory { _, _ in }
let factory = FakeWindowCaptureStreamFactory { source, _ in
source.startFrame = makeFrame(
for: source.targetKey,
sequence: 1,
uptime: 10,
byte: 7
)
}
var capturedAt: TimeInterval?
let manager = WindowCaptureStreamManager(factory: factory, takeSnapshot: { target, _ in
capturedAt = ProcessInfo.processInfo.systemUptime
@@ -263,7 +324,7 @@ final class WindowCaptureStreamTests: XCTestCase {
pendingMutation,
capturedAt: try XCTUnwrap(capturedAt)
)
XCTAssertEqual(factory.sources[0].latestReadCount, 0)
XCTAssertGreaterThan(factory.sources[0].latestReadCount, 0)
}
private func assertMutationHasSettledBeforeCapture(
+58 -39
View File
@@ -11,7 +11,7 @@
# CU_HELPER_TIMESTAMP_MODE
# (default: auto; secure for Developer ID, none for local development)
#
# Output: prints "built: <abs path to .build/release/cc-haha-computer-use.app>"
# Output: prints "built: <arch-specific abs path>/cc-haha-computer-use.app"
#
# Stable-identity contract: same cert + same --identifier on every build,
# --options runtime, a secure timestamp for Developer ID distribution, no ad-hoc.
@@ -46,25 +46,19 @@ PKG_DIR="$(cd -P "$(dirname "$SCRIPT_SOURCE")" >/dev/null 2>&1 && pwd)"
BUILD_CONFIG="release"
BUILD_DIR="$PKG_DIR/.build"
# Output binary name == the SwiftPM executable-target name (see Package.swift).
# This is the brand-facing name macOS shows in the Privacy lists.
BIN_PATH="$BUILD_DIR/$BUILD_CONFIG/cc-haha-computer-use"
# After build+sign we wrap the binary in a minimal .app bundle. WHY: macOS Screen
# Recording (ScreenCaptureKit / TCC kTCCServiceScreenCapture) only grants
# EFFECTIVE access to a real .app bundle process — a bare Mach-O can be toggled
# ON in the Privacy list but CGPreflightScreenCaptureAccess() still reads false.
# Accessibility tolerates a bare binary (works), Screen Recording does NOT. So
# the shipped/dragged artifact is the .app; the inner binary is what we spawn.
APP_PATH="$BUILD_DIR/$BUILD_CONFIG/cc-haha-computer-use.app"
# Reuse the desktop brand asset so both Privacy lists show the product logo.
APP_ICON_PATH="$PKG_DIR/../../desktop/src-tauri/icons/icon.icns"
# Records the (identity, identifier) actually used, so we can detect rotation
# across rebuilds and warn that TCC grants will have been dropped.
SIGN_STAMP="$BUILD_DIR/.cu-helper.signid"
BUNDLE_ID="${CU_HELPER_BUNDLE_ID:-dev.cchaha.cu-helper}"
ARCH="${CU_HELPER_ARCH:-$(uname -m)}"
SWIFT_SCRATCH_PATH="$BUILD_DIR/$ARCH"
BIN_DIR=""
BIN_PATH=""
APP_PATH=""
RESOURCE_BUNDLE_PATH=""
# Records the (identity, identifier) actually used, so we can detect rotation
# across rebuilds and warn that TCC grants will have been dropped.
SIGN_STAMP="$BUILD_DIR/.cu-helper.$ARCH.signid"
# ---------------------------------------------------------------------------
# Logging helpers — everything diagnostic goes to STDERR so the final
@@ -84,6 +78,7 @@ preflight() {
fi
command -v swift >/dev/null 2>&1 || die "swift not found on PATH. Install Xcode / Command Line Tools."
command -v lipo >/dev/null 2>&1 || die "lipo not found on PATH. Install Xcode / Command Line Tools."
command -v codesign >/dev/null 2>&1 || die "codesign not found on PATH. Install Xcode / Command Line Tools."
command -v security >/dev/null 2>&1 || die "security tool not found on PATH (needed to enumerate signing identities)."
@@ -102,12 +97,12 @@ preflight() {
# 2. Resolve a STABLE signing identity.
#
# Priority:
# a) $CU_HELPER_IDENTITY (explicit override — trusted verbatim)
# b) the first real 'Apple Development: ...' identity in the keychain
# (preferred for fast, offline local iteration)
# a) $CC_HAHA_SIGN_IDENTITY (shared host/sidecar/helper build identity)
# b) $CU_HELPER_IDENTITY (legacy helper-only override for direct builds)
# c) the first 'Developer ID Application: ...' identity (release/CI)
# d) a self-signed 'cu-helper-dev' identity if one exists
# e) NONE -> print one-time create instructions and FAIL (never ad-hoc).
# d) the first real 'Apple Development: ...' identity in the keychain
# e) a self-signed 'cu-helper-dev' identity if one exists
# f) NONE -> print one-time create instructions and FAIL (never ad-hoc).
#
# Sets globals: SIGN_IDENTITY (string passed to codesign --sign)
# ---------------------------------------------------------------------------
@@ -182,7 +177,21 @@ EOF
}
resolve_identity() {
# a) explicit override.
# a) shared build-wide override. The helper, the sidecar and the Electron host
# must end up on ONE certificate or the helper's client attestation rejects
# every call (see desktop/scripts/sign-identity.ts). It deliberately wins
# over the legacy helper-only variable so stale shell state cannot split a
# signed app across two certificates.
if [ -n "${CC_HAHA_SIGN_IDENTITY:-}" ]; then
SIGN_IDENTITY="$CC_HAHA_SIGN_IDENTITY"
if [ "$SIGN_IDENTITY" = "-" ]; then
die "CC_HAHA_SIGN_IDENTITY='-' (ad-hoc) is refused. Ad-hoc signing rotates the TCC identity every build. Use a stable cert."
fi
log "identity: $SIGN_IDENTITY (from CC_HAHA_SIGN_IDENTITY)"
return 0
fi
# b) legacy explicit helper-only override for direct build.sh use.
if [ -n "${CU_HELPER_IDENTITY:-}" ]; then
SIGN_IDENTITY="$CU_HELPER_IDENTITY"
# Best-effort sanity check; do not hard-fail on an override the user insists on,
@@ -198,20 +207,7 @@ resolve_identity() {
return 0
fi
# a2) shared build-wide override. The helper, the sidecar and the Electron host
# must end up on ONE certificate or the helper's client attestation rejects
# every call (see desktop/scripts/sign-identity.ts). This variable is how
# the whole build agrees on which one.
if [ -n "${CC_HAHA_SIGN_IDENTITY:-}" ]; then
SIGN_IDENTITY="$CC_HAHA_SIGN_IDENTITY"
if [ "$SIGN_IDENTITY" = "-" ]; then
die "CC_HAHA_SIGN_IDENTITY='-' (ad-hoc) is refused. Ad-hoc signing rotates the TCC identity every build. Use a stable cert."
fi
log "identity: $SIGN_IDENTITY (from CC_HAHA_SIGN_IDENTITY)"
return 0
fi
# b) Developer ID distribution identity — PREFERRED. It is long-lived and
# c) Developer ID distribution identity — PREFERRED. It is long-lived and
# notarizable, and TCC grants are keyed to the signing identity: an
# Apple Development cert expires in about a year and its replacement
# silently drops the user's Accessibility + Screen Recording grants.
@@ -226,7 +222,7 @@ resolve_identity() {
return 0
fi
# c) real Apple Development identity.
# d) real Apple Development identity.
local apple_dev
apple_dev="$(first_apple_development_identity || true)"
if [ -n "$apple_dev" ]; then
@@ -235,14 +231,14 @@ resolve_identity() {
return 0
fi
# d) self-signed fallback cert.
# e) self-signed fallback cert.
if identity_exists "$SELF_SIGNED_NAME"; then
SIGN_IDENTITY="$SELF_SIGNED_NAME"
log "identity: $SIGN_IDENTITY (auto-detected self-signed Code Signing cert)"
return 0
fi
# e) nothing usable -> instructions + fail. NEVER ad-hoc.
# f) nothing usable -> instructions + fail. NEVER ad-hoc.
print_self_signed_instructions
die "no stable code-signing identity available (refusing to ad-hoc sign)."
}
@@ -302,6 +298,23 @@ resolve_timestamp_mode() {
# ---------------------------------------------------------------------------
# 4. Build (release, requested target architecture).
# ---------------------------------------------------------------------------
resolve_build_paths() {
# `.build/release` is a mutable SwiftPM convenience symlink. It can point at
# the host architecture after a cross-build, so resolve the bin directory
# with the exact target arguments and keep each architecture in its own
# scratch tree.
BIN_DIR="$(swift build \
-c "$BUILD_CONFIG" \
--arch "$ARCH" \
--package-path "$PKG_DIR" \
--scratch-path "$SWIFT_SCRATCH_PATH" \
--show-bin-path)"
[ -n "$BIN_DIR" ] || die "swift build --show-bin-path returned an empty path for $ARCH"
BIN_PATH="$BIN_DIR/cc-haha-computer-use"
APP_PATH="$BIN_DIR/cc-haha-computer-use.app"
RESOURCE_BUNDLE_PATH="$BIN_DIR/cu-helper_cc-haha-computer-use.bundle"
}
build() {
log ""
log "==> swift build -c $BUILD_CONFIG --arch $ARCH (+embed Info.plist)"
@@ -314,13 +327,19 @@ build() {
# Screen Recording. Done here (not in Package.swift) so the path is an absolute
# build-time value, not a hardcoded machine path in the manifest. The section
# is created before sign() runs, so the signature seals it.
resolve_build_paths
swift build \
-c "$BUILD_CONFIG" \
--arch "$ARCH" \
--package-path "$PKG_DIR" \
--scratch-path "$SWIFT_SCRATCH_PATH" \
-Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker "$PKG_DIR/Info.plist" 1>&2
[ -x "$BIN_PATH" ] || die "expected product not found or not executable at: $BIN_PATH"
if ! lipo "$BIN_PATH" -verify_arch "$ARCH" 1>&2; then
die "built product at $BIN_PATH does not contain required architecture $ARCH"
fi
log "verified architecture: $ARCH"
# Hard assertion: the Info.plist section MUST be embedded, or Screen Recording
# grants silently fail (Accessibility would still work, masking the bug).
@@ -425,7 +444,7 @@ wrap_app() {
# Standard .app location is Contents/Resources/ (Bundle.main.resourceURL). Do
# NOT also put it in MacOS/ — a nested .bundle there breaks codesign with an
# "In subcomponent" error. Overlay degrades to a procedural ring if unresolved.
local res_bundle="$BUILD_DIR/$BUILD_CONFIG/cu-helper_cc-haha-computer-use.bundle"
local res_bundle="${RESOURCE_BUNDLE_PATH:-$BUILD_DIR/$BUILD_CONFIG/cu-helper_cc-haha-computer-use.bundle}"
if [ -d "$res_bundle" ]; then
cp -R "$res_bundle" "$APP_PATH/Contents/Resources/"
fi
+57
View File
@@ -7,6 +7,40 @@ const buildScript = path.resolve(import.meta.dirname, 'build.sh')
const productIcon = path.resolve(import.meta.dirname, '../../desktop/src-tauri/icons/icon.icns')
const fixtureDirectories: string[] = []
function resolveArchitectureSpecificBuildPaths(arch: 'arm64' | 'x86_64') {
const directory = mkdtempSync(path.join(tmpdir(), 'cu-helper-build-path-'))
fixtureDirectories.push(directory)
const binDir = path.join(directory, arch, `${arch}-apple-macosx`, 'release')
const result = Bun.spawnSync([
'bash',
'-c',
`
source "$1"
ARCH="$2"
BUILD_DIR="$3"
SWIFT_SCRATCH_PATH="$BUILD_DIR/$ARCH"
EXPECTED_BIN_DIR="$4"
swift() {
printf '%s\\n' "$EXPECTED_BIN_DIR"
}
resolve_build_paths
printf '%s\\n%s\\n%s\\n%s\\n' "$BIN_DIR" "$BIN_PATH" "$APP_PATH" "$RESOURCE_BUNDLE_PATH"
`,
'cu-helper-build-path-test',
buildScript,
arch,
directory,
binDir,
])
return {
exitCode: result.exitCode,
lines: result.stdout.toString().trim().split('\n'),
stderr: result.stderr.toString(),
binDir,
}
}
afterEach(() => {
for (const directory of fixtureDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
@@ -127,6 +161,29 @@ describe('cu-helper build signing identity', () => {
})
})
describe('cu-helper architecture-specific build output', () => {
test.each(['arm64', 'x86_64'] as const)(
'resolves %s products from the matching SwiftPM bin directory',
(arch) => {
const result = resolveArchitectureSpecificBuildPaths(arch)
expect(result.exitCode).toBe(0)
expect(result.lines).toEqual([
result.binDir,
path.join(result.binDir, 'cc-haha-computer-use'),
path.join(result.binDir, 'cc-haha-computer-use.app'),
path.join(result.binDir, 'cu-helper_cc-haha-computer-use.bundle'),
])
},
)
test('verifies the requested Mach-O architecture before signing', () => {
const source = readFileSync(buildScript, 'utf8')
expect(source).toContain('lipo "$BIN_PATH" -verify_arch "$ARCH"')
expect(source.indexOf('lipo "$BIN_PATH" -verify_arch "$ARCH"'))
.toBeLessThan(source.indexOf('\nsign() {'))
})
})
describe.skipIf(process.platform !== 'darwin')('cu-helper permission-list app icon', () => {
test('declares and bundles the product icon before signing the helper app', () => {
const result = wrapFixtureApp()
+3 -6
View File
@@ -257,7 +257,7 @@ describe('release desktop workflow', () => {
expect(workflow.indexOf('Build unsigned Electron release artifacts')).toBeLessThan(workflow.indexOf('Verify packaged app structure'))
})
test('release workflow records macOS signing state and warns for unsigned builds', () => {
test('release workflow records macOS signing state and refuses unsigned macOS builds', () => {
const workflow = readReleaseWorkflow()
const signingJob = workflow.match(
/signing-preflight:[\s\S]*?(?:\n {2}[a-zA-Z0-9_-]+:|$)/,
@@ -283,10 +283,8 @@ describe('release desktop workflow', () => {
expect(signingJob).toContain(secret)
}
expect(signingJob).toContain('Missing macOS signing/notarization secrets')
expect(signingJob).toContain('macOS artifacts will be unsigned')
expect(signingJob).toContain('install-macos-unsigned.sh')
expect(signingJob).toContain("RELEASE_DRAFT: ${{ github.event_name == 'workflow_dispatch' && inputs.draft == true }}")
expect(signingJob).toContain('Refusing to publish a non-draft desktop release without macOS signing/notarization secrets.')
expect(signingJob).toContain('refusing to build a macOS release whose Computer Use runtime cannot pass client attestation')
expect(signingJob).not.toContain('RELEASE_DRAFT:')
expect(signingJob).toContain('macos_signed=false')
expect(signingJob).toContain('macos_signed=true')
expect(signingJob).toContain('Windows signing secrets missing')
@@ -298,7 +296,6 @@ describe('release desktop workflow', () => {
const windowsOptionalBlock = signingJob?.match(
/win_missing=\(\)[\s\S]*?fi\n/,
)?.[0]
expect(macRequiredBlock).toContain('if [ "$RELEASE_DRAFT" != "true" ]; then')
expect(macRequiredBlock).toContain('exit 1')
expect(windowsOptionalBlock).toContain('::warning::')
expect(windowsOptionalBlock).not.toContain('exit 1')
@@ -8,6 +8,8 @@ import {
} from './current'
import {
inspectPackagedArtifacts,
parseCodesignMetadata,
parseMachOMinimumMacosVersions,
parsePackageSmokeArgs,
} from './index'
@@ -27,7 +29,7 @@ function createRepoRoot() {
return rootDir
}
function writeFile(rootDir: string, relativePath: string, content = 'ok') {
function writeFile(rootDir: string, relativePath: string, content: string | Uint8Array = 'ok') {
const fullPath = join(rootDir, relativePath)
mkdirSync(dirname(fullPath), { recursive: true })
writeFileSync(fullPath, content)
@@ -45,9 +47,35 @@ function writeFile(rootDir: string, relativePath: string, content = 'ok') {
for (const licenseName of ['COPYING', 'LICENSE-MIT', 'UNLICENSE']) {
writeFileSync(join(licensesDir, licenseName), content)
}
if (fileName.includes('apple-darwin')) {
const helperRoot = join(dirname(fullPath), 'cc-haha-computer-use.app', 'Contents')
mkdirSync(join(helperRoot, 'MacOS'), { recursive: true })
writeFileSync(
join(helperRoot, 'Info.plist'),
'<plist><dict><key>LSMinimumSystemVersion</key><string>14.4</string></dict></plist>',
)
writeFileSync(join(helperRoot, 'MacOS', 'cc-haha-computer-use'), content)
}
}
}
function thinMachO(arch: 'arm64' | 'x64', minimum = '14.4') {
const [major, minor, patch = 0] = minimum.split('.').map(Number)
const encodedMinimum = (major << 16) | (minor << 8) | patch
const bytes = Buffer.alloc(56)
bytes.writeUInt32LE(0xfeedfacf, 0)
bytes.writeUInt32LE(arch === 'arm64' ? 0x0100000c : 0x01000007, 4)
bytes.writeUInt32LE(2, 12)
bytes.writeUInt32LE(1, 16)
bytes.writeUInt32LE(24, 20)
bytes.writeUInt32LE(0x32, 32)
bytes.writeUInt32LE(24, 36)
bytes.writeUInt32LE(1, 40)
bytes.writeUInt32LE(encodedMinimum, 44)
bytes.writeUInt32LE(15 << 16, 48)
return bytes
}
const tempDirs: string[] = []
afterEach(() => {
@@ -78,6 +106,27 @@ describe('package smoke args', () => {
expect(currentPackageSmokeArch('x64')).toBe('x64')
expect(currentPackageSmokeArch('ia32')).toBeNull()
})
test('reads the helper deployment target from the Mach-O load commands', () => {
expect(parseMachOMinimumMacosVersions(thinMachO('arm64', '14.4'))).toEqual(['14.4'])
expect(parseMachOMinimumMacosVersions(thinMachO('x64', '14.0'))).toEqual(['14.0'])
expect(parseMachOMinimumMacosVersions(Buffer.from('not Mach-O'))).toEqual([])
})
test('reads the identity fields required by Computer Use client attestation', () => {
expect(parseCodesignMetadata([
'Identifier=dev.cchaha.cu-helper',
'Authority=Developer ID Application: Example (TEAM123456)',
'Authority=Developer ID Certification Authority',
'Timestamp=Sep 1, 2026 at 18:43:53',
'TeamIdentifier=TEAM123456',
].join('\n'))).toEqual({
identifier: 'dev.cchaha.cu-helper',
authority: 'Developer ID Application: Example (TEAM123456)',
team: 'TEAM123456',
timestamp: 'Sep 1, 2026 at 18:43:53',
})
})
})
describe('packaged artifact inspection', () => {
@@ -147,6 +196,84 @@ describe('packaged artifact inspection', () => {
)).toBe(true)
})
test('fails closed when an arm64 package contains an x64 cu-helper', async () => {
const rootDir = createRepoRoot()
tempDirs.push(rootDir)
const appRoot = 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app'
const resources = `${appRoot}/Contents/Resources`
const sidecarRoot = `${resources}/app.asar.unpacked/src-tauri/binaries`
const nodePtyRoot = `${resources}/app.asar.unpacked/node_modules/node-pty`
writeFile(rootDir, `${appRoot}/Contents/Info.plist`)
writeFile(rootDir, `${appRoot}/Contents/MacOS/Claude Code Haha`, thinMachO('arm64'))
writeFile(rootDir, `${resources}/app.asar`)
writeFile(rootDir, `${resources}/app.asar.unpacked/dist/index.html`)
writeFile(rootDir, `${sidecarRoot}/claude-sidecar-aarch64-apple-darwin`, thinMachO('arm64'))
writeFile(rootDir, `${nodePtyRoot}/package.json`)
writeFile(rootDir, `${nodePtyRoot}/prebuilds/darwin-arm64/pty.node`, thinMachO('arm64'))
writeFile(rootDir, `${nodePtyRoot}/prebuilds/darwin-arm64/spawn-helper`, thinMachO('arm64'))
const validReport = await inspectPackagedArtifacts(rootDir, {
platform: 'macos',
arch: 'arm64',
packageKind: 'dir',
})
expect(validReport.passed).toBe(true)
writeFile(
rootDir,
`${sidecarRoot}/cc-haha-computer-use.app/Contents/MacOS/cc-haha-computer-use`,
thinMachO('x64'),
)
const report = await inspectPackagedArtifacts(rootDir, {
platform: 'macos',
arch: 'arm64',
packageKind: 'dir',
})
expect(report.passed).toBe(false)
expect(report.missingChecks.some(
check => check.label === 'macOS arm64 cu-helper Mach-O architecture',
)).toBe(true)
expect(report.notes.join('\n')).toContain('expected arm64, found x86_64')
})
test('fails closed when the helper Mach-O deployment target drifts below 14.4', async () => {
const rootDir = createRepoRoot()
tempDirs.push(rootDir)
const appRoot = 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app'
const resources = `${appRoot}/Contents/Resources`
const sidecarRoot = `${resources}/app.asar.unpacked/src-tauri/binaries`
const nodePtyRoot = `${resources}/app.asar.unpacked/node_modules/node-pty`
writeFile(rootDir, `${appRoot}/Contents/Info.plist`)
writeFile(rootDir, `${appRoot}/Contents/MacOS/Claude Code Haha`, thinMachO('arm64'))
writeFile(rootDir, `${resources}/app.asar`)
writeFile(rootDir, `${resources}/app.asar.unpacked/dist/index.html`)
writeFile(rootDir, `${sidecarRoot}/claude-sidecar-aarch64-apple-darwin`, thinMachO('arm64'))
writeFile(rootDir, `${nodePtyRoot}/package.json`)
writeFile(rootDir, `${nodePtyRoot}/prebuilds/darwin-arm64/pty.node`, thinMachO('arm64'))
writeFile(rootDir, `${nodePtyRoot}/prebuilds/darwin-arm64/spawn-helper`, thinMachO('arm64'))
writeFile(
rootDir,
`${sidecarRoot}/cc-haha-computer-use.app/Contents/MacOS/cc-haha-computer-use`,
thinMachO('arm64', '14.0'),
)
const report = await inspectPackagedArtifacts(rootDir, {
platform: 'macos',
arch: 'arm64',
packageKind: 'dir',
})
expect(report.passed).toBe(false)
expect(report.missingChecks.some(
check => check.label === 'macOS cu-helper Mach-O deployment target (14.4)',
)).toBe(true)
expect(report.notes.join('\n')).toContain('expected 14.4, found 14.0')
})
test('fails macOS inspection when the H5 shell is not unpacked for the sidecar', async () => {
const rootDir = createRepoRoot()
tempDirs.push(rootDir)
@@ -272,6 +399,69 @@ describe('packaged artifact inspection', () => {
expect(report.notes.join('\n')).toContain('notarization ticket validation exited with status 65')
})
test('requires one Developer ID signer across host, sidecar, and helper', async () => {
const rootDir = createRepoRoot()
tempDirs.push(rootDir)
const appRoot = 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app'
const resources = `${appRoot}/Contents/Resources`
const sidecarRoot = `${resources}/app.asar.unpacked/src-tauri/binaries`
const nodePtyRoot = `${resources}/app.asar.unpacked/node_modules/node-pty`
writeFile(rootDir, `${appRoot}/Contents/Info.plist`)
writeFile(rootDir, `${appRoot}/Contents/MacOS/Claude Code Haha`, thinMachO('arm64'))
writeFile(rootDir, `${resources}/app.asar`)
writeFile(rootDir, `${resources}/app.asar.unpacked/dist/index.html`)
writeFile(rootDir, `${sidecarRoot}/claude-sidecar-aarch64-apple-darwin`, thinMachO('arm64'))
writeFile(rootDir, `${nodePtyRoot}/package.json`)
writeFile(rootDir, `${nodePtyRoot}/prebuilds/darwin-arm64/pty.node`, thinMachO('arm64'))
writeFile(rootDir, `${nodePtyRoot}/prebuilds/darwin-arm64/spawn-helper`, thinMachO('arm64'))
const inspect = (sidecarAuthority: string) => inspectPackagedArtifacts(rootDir, {
platform: 'macos',
arch: 'arm64',
packageKind: 'dir',
requireMacosGatekeeper: true,
hostPlatform: 'macos',
commandRunner: (command, args) => {
if (command.endsWith('/spctl')) return { status: 0, stdout: 'accepted', stderr: '' }
if (command.endsWith('/codesign') && args[0] === '--verify') {
return { status: 0, stdout: '', stderr: '' }
}
if (command.endsWith('/codesign') && args[0] === '-dv') {
const target = args.at(-1) ?? ''
const isSidecar = target.includes('claude-sidecar-')
const identifier = target.endsWith('cc-haha-computer-use.app')
? 'dev.cchaha.cu-helper'
: isSidecar
? 'com.claude-code-haha.desktop.sidecar'
: 'com.claude-code-haha.desktop'
const authority = isSidecar
? sidecarAuthority
: 'Developer ID Application: Example (TEAM123456)'
return {
status: 0,
stdout: '',
stderr: [
`Identifier=${identifier}`,
`Authority=${authority}`,
'Timestamp=Sep 1, 2026 at 18:43:53',
'TeamIdentifier=TEAM123456',
].join('\n'),
}
}
return { status: 0, stdout: '', stderr: '' }
},
})
const valid = await inspect('Developer ID Application: Example (TEAM123456)')
expect(valid.passedChecks.some(
check => check.label === 'macOS Computer Use signing attestation chain',
)).toBe(true)
const mismatched = await inspect('Developer ID Application: Other (TEAM123456)')
expect(mismatched.passed).toBe(false)
expect(mismatched.notes.join('\n')).toContain('mismatched Developer ID authority/team')
})
test('retries macOS Gatekeeper assessment with a raised file limit when spctl hits open-file limits', async () => {
const rootDir = createRepoRoot()
tempDirs.push(rootDir)
+359 -22
View File
@@ -274,6 +274,190 @@ function addMatchCheck(
})
}
type MachOArch = 'arm64' | 'x86_64'
const MACHO_CPU_TYPES: Record<number, MachOArch> = {
[0x0100000c]: 'arm64',
[0x01000007]: 'x86_64',
}
export function parseMachOArchitectures(bytes: Uint8Array): MachOArch[] {
const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength)
if (buffer.length < 8) return []
const architectures = new Set<MachOArch>()
const addCpu = (value: number) => {
const arch = MACHO_CPU_TYPES[value >>> 0]
if (arch) architectures.add(arch)
}
const littleMagic = buffer.readUInt32LE(0)
const bigMagic = buffer.readUInt32BE(0)
if (littleMagic === 0xfeedface || littleMagic === 0xfeedfacf) {
addCpu(buffer.readUInt32LE(4))
return [...architectures]
}
if (bigMagic === 0xfeedface || bigMagic === 0xfeedfacf) {
addCpu(buffer.readUInt32BE(4))
return [...architectures]
}
const fat64 = bigMagic === 0xcafebabf || littleMagic === 0xcafebabf
const fat32 = bigMagic === 0xcafebabe || littleMagic === 0xcafebabe
if (!fat32 && !fat64) return []
const bigEndian = bigMagic === 0xcafebabe || bigMagic === 0xcafebabf
const readU32 = (offset: number) => bigEndian
? buffer.readUInt32BE(offset)
: buffer.readUInt32LE(offset)
const count = readU32(4)
const stride = fat64 ? 32 : 20
for (let index = 0; index < count; index += 1) {
const offset = 8 + index * stride
if (offset + 4 > buffer.length) return []
addCpu(readU32(offset))
}
return [...architectures].sort()
}
function decodeMachOVersion(encoded: number): string {
const major = (encoded >>> 16) & 0xffff
const minor = (encoded >>> 8) & 0xff
const patch = encoded & 0xff
return patch > 0 ? `${major}.${minor}.${patch}` : `${major}.${minor}`
}
function parseThinMachOMinimumVersion(
buffer: Buffer,
start: number,
length: number,
): string | null {
if (length < 28 || start < 0 || start + length > buffer.length) return null
const littleMagic = buffer.readUInt32LE(start)
const bigMagic = buffer.readUInt32BE(start)
const littleEndian = littleMagic === 0xfeedface || littleMagic === 0xfeedfacf
const bigEndian = bigMagic === 0xfeedface || bigMagic === 0xfeedfacf
if (!littleEndian && !bigEndian) return null
const readU32 = (offset: number) => littleEndian
? buffer.readUInt32LE(offset)
: buffer.readUInt32BE(offset)
const is64Bit = (littleEndian ? littleMagic : bigMagic) === 0xfeedfacf
const commandCount = readU32(start + 16)
let cursor = start + (is64Bit ? 32 : 28)
const end = start + length
for (let index = 0; index < commandCount; index += 1) {
if (cursor + 8 > end) return null
const command = readU32(cursor)
const commandSize = readU32(cursor + 4)
if (commandSize < 8 || cursor + commandSize > end) return null
if (command === 0x32 && commandSize >= 24) {
// LC_BUILD_VERSION.minos
return decodeMachOVersion(readU32(cursor + 12))
}
if (command === 0x24 && commandSize >= 16) {
// Legacy LC_VERSION_MIN_MACOSX.version
return decodeMachOVersion(readU32(cursor + 8))
}
cursor += commandSize
}
return null
}
export function parseMachOMinimumMacosVersions(bytes: Uint8Array): string[] {
const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength)
if (buffer.length < 8) return []
const bigMagic = buffer.readUInt32BE(0)
const fat64 = bigMagic === 0xcafebabf || bigMagic === 0xbfbafeca
const fat32 = bigMagic === 0xcafebabe || bigMagic === 0xbebafeca
if (!fat32 && !fat64) {
const version = parseThinMachOMinimumVersion(buffer, 0, buffer.length)
return version ? [version] : []
}
const bigEndian = bigMagic === 0xcafebabe || bigMagic === 0xcafebabf
const readU32 = (offset: number) => bigEndian
? buffer.readUInt32BE(offset)
: buffer.readUInt32LE(offset)
const readU64 = (offset: number) => Number(bigEndian
? buffer.readBigUInt64BE(offset)
: buffer.readBigUInt64LE(offset))
const count = readU32(4)
const stride = fat64 ? 32 : 20
const versions = new Set<string>()
for (let index = 0; index < count; index += 1) {
const entry = 8 + index * stride
if (entry + stride > buffer.length) return []
const offset = fat64 ? readU64(entry + 8) : readU32(entry + 8)
const size = fat64 ? readU64(entry + 16) : readU32(entry + 12)
const version = parseThinMachOMinimumVersion(buffer, offset, size)
if (!version) return []
versions.add(version)
}
return [...versions].sort()
}
function addExactMachOArchitectureCheck(
report: PackageSmokeReport,
rootDir: string,
label: string,
targetPath: string,
expected: MachOArch,
) {
const record = { label, path: toRelative(rootDir, targetPath) }
try {
const actual = parseMachOArchitectures(readFileSync(targetPath))
if (actual.length === 1 && actual[0] === expected) {
report.passedChecks.push(record)
return
}
report.notes.push(`${label} expected ${expected}, found ${actual.join(', ') || 'not Mach-O'}.`)
} catch (error) {
report.notes.push(`${label} could not be inspected: ${String(error)}`)
}
report.missingChecks.push(record)
}
function addHelperMinimumSystemCheck(
report: PackageSmokeReport,
rootDir: string,
infoPlistPath: string,
) {
const label = 'macOS cu-helper minimum system version (14.4)'
const record = { label, path: toRelative(rootDir, infoPlistPath) }
try {
const plist = readFileSync(infoPlistPath, 'utf8')
const version = plist.match(
/<key>LSMinimumSystemVersion<\/key>\s*<string>([^<]+)<\/string>/,
)?.[1]?.trim()
if (version === '14.4') {
report.passedChecks.push(record)
return
}
report.notes.push(`${label} expected 14.4, found ${version ?? 'missing'}.`)
} catch (error) {
report.notes.push(`${label} could not be inspected: ${String(error)}`)
}
report.missingChecks.push(record)
}
function addHelperMachOMinimumSystemCheck(
report: PackageSmokeReport,
rootDir: string,
helperExecutable: string,
) {
const label = 'macOS cu-helper Mach-O deployment target (14.4)'
const record = { label, path: toRelative(rootDir, helperExecutable) }
try {
const versions = parseMachOMinimumMacosVersions(readFileSync(helperExecutable))
if (versions.length > 0 && versions.every(version => version === '14.4')) {
report.passedChecks.push(record)
return
}
report.notes.push(`${label} expected 14.4, found ${versions.join(', ') || 'missing'}.`)
} catch (error) {
report.notes.push(`${label} could not be inspected: ${String(error)}`)
}
report.missingChecks.push(record)
}
function parseUpdateMetadataReferences(content: string) {
const references = [] as string[]
const pattern = /^\s*(?:url|path):\s*['"]?([^'"\n]+?)['"]?\s*$/gm
@@ -411,6 +595,104 @@ function addCommandDiagnostics(
report.notes.push(`${label} exited with status ${status}: ${lines.join(' | ')}`)
}
type CodesignMetadata = {
identifier: string | null
authority: string | null
team: string | null
timestamp: string | null
}
export function parseCodesignMetadata(output: string): CodesignMetadata {
const first = (prefix: string) => output
.split(/\r?\n/)
.find(line => line.startsWith(prefix))
?.slice(prefix.length)
.trim() ?? null
const team = first('TeamIdentifier=')
return {
identifier: first('Identifier='),
authority: first('Authority='),
team: team === 'not set' ? null : team,
timestamp: first('Timestamp='),
}
}
function addMacosComputerUseAttestationCheck(
report: PackageSmokeReport,
rootDir: string,
appBundle: string,
sidecar: string,
helperApp: string,
commandRunner: PackageSmokeCommandRunner,
) {
const label = 'macOS Computer Use signing attestation chain'
const record = { label, path: toRelative(rootDir, helperApp) }
if (report.hostPlatform !== 'macos') {
report.notes.push(`${label} was requested but skipped because host platform is ${report.hostPlatform}.`)
return
}
const targets = [
{ name: 'host', path: appBundle, identifier: 'com.claude-code-haha.desktop', deep: true },
{ name: 'sidecar', path: sidecar, identifier: 'com.claude-code-haha.desktop.sidecar', deep: false },
{ name: 'helper', path: helperApp, identifier: 'dev.cchaha.cu-helper', deep: true },
] as const
const metadata: CodesignMetadata[] = []
for (const target of targets) {
const verifyArgs = ['--verify', ...(target.deep ? ['--deep'] : []), '--strict', '--verbose=2', target.path]
const verify = commandRunner('/usr/bin/codesign', verifyArgs)
if (verify.status !== 0) {
report.missingChecks.push(record)
addCommandDiagnostics(report, `${target.name} codesign verification`, verify)
return
}
const details = commandRunner('/usr/bin/codesign', ['-dv', '--verbose=4', target.path])
if (details.status !== 0) {
report.missingChecks.push(record)
addCommandDiagnostics(report, `${target.name} codesign details`, details)
return
}
const parsed = parseCodesignMetadata(`${details.stdout ?? ''}${details.stderr ?? ''}`)
if (
parsed.identifier !== target.identifier
|| !parsed.authority?.startsWith('Developer ID Application:')
|| !parsed.team
|| !parsed.timestamp
) {
report.missingChecks.push(record)
report.notes.push(
`${label} rejected ${target.name}: identifier=${parsed.identifier ?? 'missing'}, `
+ `authority=${parsed.authority ?? 'missing'}, team=${parsed.team ?? 'missing'}, `
+ `timestamp=${parsed.timestamp ? 'present' : 'missing'}.`,
)
return
}
metadata.push(parsed)
}
if (metadata.length !== targets.length) {
report.missingChecks.push(record)
report.notes.push(`${label} could not collect metadata for every required executable.`)
return
}
const [host, sidecarMetadata, helper] = metadata as [
CodesignMetadata,
CodesignMetadata,
CodesignMetadata,
]
if (
host.authority !== sidecarMetadata.authority
|| host.authority !== helper.authority
|| host.team !== sidecarMetadata.team
|| host.team !== helper.team
) {
report.missingChecks.push(record)
report.notes.push(`${label} rejected mismatched Developer ID authority/team values.`)
return
}
report.passedChecks.push(record)
}
function addMacosGatekeeperCheck(
report: PackageSmokeReport,
rootDir: string,
@@ -539,9 +821,13 @@ function inspectMacosArtifacts(rootDir: string, report: PackageSmokeReport, opti
const nodePtyDir = join(unpackedDir, 'node_modules', 'node-pty')
const prebuildsDir = join(nodePtyDir, 'prebuilds')
const sidecarDir = join(unpackedDir, 'src-tauri', 'binaries')
const helperApp = join(sidecarDir, 'cc-haha-computer-use.app')
const helperInfoPlist = join(helperApp, 'Contents', 'Info.plist')
const helperExecutable = join(helperApp, 'Contents', 'MacOS', 'cc-haha-computer-use')
const hostExecutable = join(contentsDir, 'MacOS', report.productName)
addPresenceCheck(report, rootDir, 'macOS Info.plist', join(contentsDir, 'Info.plist'))
addPresenceCheck(report, rootDir, 'macOS app executable', join(contentsDir, 'MacOS', report.productName))
addPresenceCheck(report, rootDir, 'macOS app executable', hostExecutable)
addPresenceCheck(report, rootDir, 'macOS app.asar', join(resourcesDir, 'app.asar'))
addPresenceCheck(report, rootDir, 'macOS unpacked H5 shell', join(unpackedDir, 'dist', 'index.html'))
addInstalledUpdateMetadataCheck(
@@ -552,13 +838,10 @@ function inspectMacosArtifacts(rootDir: string, report: PackageSmokeReport, opti
releaseMode,
)
addPresenceCheck(report, rootDir, 'macOS node-pty package.json', join(nodePtyDir, 'package.json'))
addMatchCheck(
report,
rootDir,
'macOS unpacked sidecar binary',
findMatches(sidecarDir, (candidate) => normalizePath(candidate).includes('/claude-sidecar-')),
sidecarDir,
)
addPresenceCheck(report, rootDir, 'macOS cu-helper app bundle', helperApp)
addPresenceCheck(report, rootDir, 'macOS cu-helper Info.plist', helperInfoPlist)
addPresenceCheck(report, rootDir, 'macOS cu-helper executable', helperExecutable)
if (existsSync(helperInfoPlist)) addHelperMinimumSystemCheck(report, rootDir, helperInfoPlist)
addBundledRipgrepLicenseChecks(report, rootDir, sidecarDir, 'macOS')
addMatchCheck(
report,
@@ -568,23 +851,77 @@ function inspectMacosArtifacts(rootDir: string, report: PackageSmokeReport, opti
normalizePath(candidate).endsWith(bundledRipgrepNeedle('macos'))),
sidecarDir,
)
addMatchCheck(
report,
rootDir,
'macOS node-pty native module',
findMatches(prebuildsDir, (candidate) => normalizePath(candidate).includes('/darwin-') && normalizePath(candidate).endsWith('/pty.node')),
prebuildsDir,
)
addMatchCheck(
report,
rootDir,
'macOS node-pty spawn-helper',
findMatches(prebuildsDir, (candidate) => normalizePath(candidate).includes('/darwin-') && normalizePath(candidate).endsWith('/spawn-helper')),
prebuildsDir,
)
if (report.arch) {
const expectedMachOArch: MachOArch = report.arch === 'arm64' ? 'arm64' : 'x86_64'
const targetTriple = report.arch === 'arm64'
? 'aarch64-apple-darwin'
: 'x86_64-apple-darwin'
const nodePtyArch = report.arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64'
const sidecar = join(sidecarDir, `claude-sidecar-${targetTriple}`)
const pty = join(prebuildsDir, nodePtyArch, 'pty.node')
const spawnHelper = join(prebuildsDir, nodePtyArch, 'spawn-helper')
addPresenceCheck(report, rootDir, `macOS ${report.arch} unpacked sidecar binary`, sidecar)
addPresenceCheck(report, rootDir, `macOS ${report.arch} node-pty native module`, pty)
addPresenceCheck(report, rootDir, `macOS ${report.arch} node-pty spawn-helper`, spawnHelper)
if (existsSync(helperExecutable)) {
addHelperMachOMinimumSystemCheck(report, rootDir, helperExecutable)
}
for (const [label, target] of [
['app executable', hostExecutable],
['sidecar', sidecar],
['cu-helper', helperExecutable],
['node-pty native module', pty],
['node-pty spawn-helper', spawnHelper],
] as const) {
if (existsSync(target)) {
addExactMachOArchitectureCheck(
report,
rootDir,
`macOS ${report.arch} ${label} Mach-O architecture`,
target,
expectedMachOArch,
)
}
}
} else {
addMatchCheck(
report,
rootDir,
'macOS unpacked sidecar binary',
findMatches(sidecarDir, (candidate) => normalizePath(candidate).includes('/claude-sidecar-')),
sidecarDir,
)
addMatchCheck(
report,
rootDir,
'macOS node-pty native module',
findMatches(prebuildsDir, (candidate) => normalizePath(candidate).includes('/darwin-') && normalizePath(candidate).endsWith('/pty.node')),
prebuildsDir,
)
addMatchCheck(
report,
rootDir,
'macOS node-pty spawn-helper',
findMatches(prebuildsDir, (candidate) => normalizePath(candidate).includes('/darwin-') && normalizePath(candidate).endsWith('/spawn-helper')),
prebuildsDir,
)
}
report.notes.push('No GUI launch was attempted. This command only inspects packaged bundle structure and key unpacked resources.')
if (options.requireMacosGatekeeper) {
if (report.arch) {
const targetTriple = report.arch === 'arm64'
? 'aarch64-apple-darwin'
: 'x86_64-apple-darwin'
addMacosComputerUseAttestationCheck(
report,
rootDir,
appBundle,
join(sidecarDir, `claude-sidecar-${targetTriple}`),
helperApp,
options.commandRunner ?? defaultCommandRunner,
)
}
addMacosGatekeeperCheck(report, rootDir, appBundle, options.commandRunner)
} else if (report.hostPlatform === 'macos') {
report.notes.push('macOS Gatekeeper launch approval was not assessed. Add --require-macos-gatekeeper for release-readiness launch policy checks.')
+170 -3
View File
@@ -245,6 +245,135 @@ describe('Computer Use API authorized app config', () => {
})
})
describe('Computer Use platform capability', () => {
it('builds native macOS status through the real capability transition', async () => {
const { checkStatus } = await importComputerUseApi()
const calls: string[] = []
const status = await checkStatus({
platform: 'darwin',
arch: 'x64',
detectMacosProductVersion: async () => {
calls.push('version')
return '14.4.1'
},
isMacosRuntimeSupported: (platform) => {
calls.push(`runtime:${platform}`)
return true
},
isCuHelperAvailable: () => {
calls.push('helper')
return true
},
checkPermissions: async () => {
calls.push('permissions')
return { accessibility: true, screenRecording: false, error: null }
},
})
expect(calls).toEqual(['version', 'runtime:darwin', 'helper', 'permissions'])
expect(status).toEqual({
platform: 'darwin',
supported: true,
engine: 'macos-native',
systemVersion: '14.4.1',
arch: 'x64',
cuHelper: {
available: true,
supported: true,
minimumMacosVersion: '14.4',
reason: null,
},
python: { installed: false, version: null, path: null, source: null, error: null },
venv: { created: false, path: expect.any(String) },
dependencies: { installed: false, requirementsFound: false },
permissions: { accessibility: true, screenRecording: false, error: null },
})
})
it('does not probe or launch the helper below the macOS system floor', async () => {
const { checkStatus } = await importComputerUseApi()
let helperCalls = 0
let permissionCalls = 0
const status = await checkStatus({
platform: 'darwin',
arch: 'arm64',
detectMacosProductVersion: async () => '14.3.9',
isMacosRuntimeSupported: () => true,
isCuHelperAvailable: () => {
helperCalls += 1
return true
},
checkPermissions: async () => {
permissionCalls += 1
return { accessibility: true, screenRecording: true, error: null }
},
})
expect(helperCalls).toBe(0)
expect(permissionCalls).toBe(0)
expect(status).toMatchObject({
supported: false,
engine: 'unsupported',
systemVersion: '14.3.9',
arch: 'arm64',
cuHelper: { available: false, supported: false, reason: 'os_too_old' },
permissions: { accessibility: null, screenRecording: null, error: null },
})
})
it('keeps eligible macOS on the native engine when the helper is missing', async () => {
const { resolveComputerUseCapability } = await importComputerUseApi()
expect(resolveComputerUseCapability('darwin', '14.4', false)).toEqual({
supported: true,
engine: 'macos-native',
cuHelper: {
available: false,
supported: true,
minimumMacosVersion: '14.4',
reason: 'helper_missing',
},
})
expect(resolveComputerUseCapability('darwin', '15.0', true).engine)
.toBe('macos-native')
})
it('fails closed below the native system floor without string comparison bugs', async () => {
const { isVersionAtLeast, resolveComputerUseCapability } = await importComputerUseApi()
expect(isVersionAtLeast('14.10', '14.4')).toBe(true)
expect(isVersionAtLeast('14.3.9', '14.4')).toBe(false)
expect(resolveComputerUseCapability('darwin', '14.3.9', true)).toMatchObject({
supported: false,
engine: 'unsupported',
cuHelper: { available: false, reason: 'os_too_old' },
})
expect(resolveComputerUseCapability('darwin', null, true)).toMatchObject({
supported: false,
engine: 'unsupported',
cuHelper: { available: false, reason: 'system_version_unknown' },
})
expect(resolveComputerUseCapability('darwin', null, true, true)).toMatchObject({
supported: true,
engine: 'macos-native',
cuHelper: { available: true, reason: null },
})
})
it('routes Windows to compatibility and rejects unsupported platforms', async () => {
const { resolveComputerUseCapability } = await importComputerUseApi()
expect(resolveComputerUseCapability('win32', null, false).engine)
.toBe('windows-compat')
expect(resolveComputerUseCapability('linux', null, false)).toMatchObject({
supported: false,
engine: 'unsupported',
})
})
})
describe('runPipInstallWithFallback', () => {
it('rejects setup on unsupported platforms before writing runtime files', async () => {
const { getUnsupportedComputerUsePlatformStep } = await importComputerUseApi()
@@ -543,6 +672,41 @@ describe('app icon endpoint input', () => {
})
})
describe('native permission card command result', () => {
it('fails when the helper exits unsuccessfully or returns no snapshot', async () => {
const { resolvePermissionCardCommandResult } = await importComputerUseApi()
expect(resolvePermissionCardCommandResult({
ok: false,
stdout: '',
stderr: 'loader rejected helper',
code: 1,
})).toEqual({
ok: false,
reason: 'loader rejected helper',
accessibility: null,
screenRecording: null,
})
expect(resolvePermissionCardCommandResult({
ok: true,
stdout: 'not-json',
stderr: '',
code: 0,
})).toMatchObject({ ok: false, accessibility: null, screenRecording: null })
})
it('returns the final valid permission snapshot', async () => {
const { resolvePermissionCardCommandResult } = await importComputerUseApi()
expect(resolvePermissionCardCommandResult({
ok: true,
stdout: 'log\n{"ok":true,"result":{"accessibility":true,"screenRecording":false}}',
stderr: '',
code: 0,
})).toEqual({ ok: true, accessibility: true, screenRecording: false })
})
})
/**
* Nulls render as a permanent "checking…" in the settings page, so a probe that
* fails silently is indistinguishable from one still in flight. That happened:
@@ -567,8 +731,11 @@ describe('checkCuHelperPermissions failure reporting', () => {
)
})
// Still degrades to unknown — the caller contract does not change.
expect(result).toEqual({ accessibility: null, screenRecording: null })
expect(result).toEqual({
accessibility: null,
screenRecording: null,
error: 'This helper command requires the signed Claude Code Haha desktop app.',
})
expect(recorded).toHaveLength(1)
expect(recorded[0]).toMatchObject({
@@ -596,7 +763,7 @@ describe('checkCuHelperPermissions failure reporting', () => {
({ accessibility: true, screenRecording: false }) as never,
)
expect(result).toEqual({ accessibility: true, screenRecording: false })
expect(result).toEqual({ accessibility: true, screenRecording: false, error: null })
// A granted-or-denied answer is a completed check, not a diagnostic event.
expect(spy).not.toHaveBeenCalled()
} finally {
+232 -22
View File
@@ -29,9 +29,9 @@ import {
} from '../../utils/computerUse/preauthorizedConfig.js'
import {
callCuHelper,
resolveCuHelperBinary,
isMacosComputerUseRuntimeSupported,
resolveLaunchableCuHelperBinary,
} from '../../utils/computerUse/cuHelperBridge.js'
import { ensureInstalledHelper } from '../../utils/computerUse/cuHelperInstall.js'
// Embed the runtime scripts at compile time so bundled mode has them without
// shipping loose files. Windows only: macOS drives Computer Use through the
// signed native `cu-helper` daemon, and `helperBridge` refuses to fall back to
@@ -55,6 +55,7 @@ const installStampPath = join(runtimeStateRoot, 'requirements.sha256')
const baseInterpreterMarkerPath = join(runtimeStateRoot, 'venv-base-interpreter.txt')
const MIN_PYTHON_MAJOR = 3
const MIN_PYTHON_MINOR = 9
export const MIN_MACOS_COMPUTER_USE_VERSION = '14.4'
const isWindows = process.platform === 'win32'
const REQUIREMENTS_CONTENT = REQUIREMENTS_WIN32
@@ -179,6 +180,9 @@ async function ensureRuntimeFiles(): Promise<void> {
type EnvStatus = {
platform: string
supported: boolean
engine: 'macos-native' | 'windows-compat' | 'unsupported'
systemVersion: string | null
arch: string
/**
* Native cu-helper engine availability. `available` is true only on macOS
* AND when the Swift `cu-helper` binary resolves. The desktop UI branches on
@@ -186,6 +190,14 @@ type EnvStatus = {
*/
cuHelper: {
available: boolean
supported: boolean
minimumMacosVersion: typeof MIN_MACOS_COMPUTER_USE_VERSION
reason:
| 'unsupported_platform'
| 'system_version_unknown'
| 'os_too_old'
| 'helper_missing'
| null
}
python: {
installed: boolean
@@ -205,6 +217,101 @@ type EnvStatus = {
permissions: {
accessibility: boolean | null
screenRecording: boolean | null
error: string | null
}
}
type ComputerUseCapability = Pick<EnvStatus, 'supported' | 'engine'> & {
cuHelper: EnvStatus['cuHelper']
}
export function isVersionAtLeast(version: string, minimum: string): boolean {
const parse = (value: string) => value.split('.').map((part) => {
const parsed = Number.parseInt(part, 10)
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0
})
const actual = parse(version)
const floor = parse(minimum)
const length = Math.max(actual.length, floor.length)
for (let index = 0; index < length; index += 1) {
const left = actual[index] ?? 0
const right = floor[index] ?? 0
if (left !== right) return left > right
}
return true
}
/**
* Choose the implementation from platform/OS eligibility first. A missing
* native helper is a runtime failure on the native path, never a reason to
* fall back to the obsolete macOS Python page.
*/
export function resolveComputerUseCapability(
platform: string,
systemVersion: string | null,
helperPresent: boolean,
kernelVersionEligible = false,
): ComputerUseCapability {
const baseHelper = {
minimumMacosVersion: MIN_MACOS_COMPUTER_USE_VERSION,
} as const
if (platform === 'win32') {
return {
supported: true,
engine: 'windows-compat',
cuHelper: {
...baseHelper,
available: false,
supported: false,
reason: 'unsupported_platform',
},
}
}
if (platform !== 'darwin') {
return {
supported: false,
engine: 'unsupported',
cuHelper: {
...baseHelper,
available: false,
supported: false,
reason: 'unsupported_platform',
},
}
}
if (!systemVersion && !kernelVersionEligible) {
return {
supported: false,
engine: 'unsupported',
cuHelper: {
...baseHelper,
available: false,
supported: false,
reason: 'system_version_unknown',
},
}
}
if (systemVersion && !isVersionAtLeast(systemVersion, MIN_MACOS_COMPUTER_USE_VERSION)) {
return {
supported: false,
engine: 'unsupported',
cuHelper: {
...baseHelper,
available: false,
supported: false,
reason: 'os_too_old',
},
}
}
return {
supported: true,
engine: 'macos-native',
cuHelper: {
...baseHelper,
available: helperPresent,
supported: true,
reason: helperPresent ? null : 'helper_missing',
},
}
}
@@ -219,13 +326,12 @@ type CuHelperPermissions = {
}
/**
* True only on macOS AND when the native `cu-helper` binary resolves. Mirrors
* isCuHelperAvailable() from cuHelperBridge, re-derived here because the server
* is a separate process; resolveCuHelperBinary() applies the identical path
* logic and is safe to call from any process.
* True only on macOS AND when the native `cu-helper` can be launched from its
* canonical installation. This also verifies/install-repairs the packaged
* helper instead of reporting a nested app-bundle binary as healthy.
*/
function isCuHelperAvailableForServer(): boolean {
return process.platform === 'darwin' && resolveCuHelperBinary() !== null
return process.platform === 'darwin' && resolveLaunchableCuHelperBinary() !== null
}
/**
@@ -240,12 +346,14 @@ export async function checkCuHelperPermissions(
): Promise<{
accessibility: boolean | null
screenRecording: boolean | null
error: string | null
}> {
try {
const result = await call<CuHelperPermissions>('check_permissions')
return {
accessibility: result.accessibility ?? null,
screenRecording: result.screenRecording ?? null,
error: null,
}
} catch (error) {
// Nulls reach the settings page as a permanent "checking…" — the UI cannot
@@ -271,7 +379,13 @@ export async function checkCuHelperPermissions(
hint: 'permissions stay unknown until this call succeeds',
},
})
return { accessibility: null, screenRecording: null }
return {
accessibility: null,
screenRecording: null,
error: error instanceof Error
? error.message
: 'cu-helper check_permissions failed',
}
}
}
@@ -318,7 +432,7 @@ async function openNativePermissionCard(): Promise<{
}
// Launch from the standalone-installed helper so the card drags the same `.app`
// the daemon runs from (its own Screen Recording subject). See cuHelperInstall.ts.
const bin = ensureInstalledHelper()?.binary ?? resolveCuHelperBinary()
const bin = resolveLaunchableCuHelperBinary()
if (!bin) {
return { ok: false, reason: 'helper-missing', accessibility: null, screenRecording: null }
}
@@ -328,10 +442,40 @@ async function openNativePermissionCard(): Promise<{
// then prints exactly one `{ok,result}` line. runCommand spawns + reads
// stdout + awaits exit, which is exactly what we need.
const result = await runCommand(bin, ['request-access'])
return resolvePermissionCardCommandResult(result)
}
export function resolvePermissionCardCommandResult(result: {
ok: boolean
stdout: string
stderr: string
code: number
}): {
ok: boolean
reason?: string
accessibility: boolean | null
screenRecording: boolean | null
} {
if (!result.ok) {
return {
ok: false,
reason: result.stderr || `permission card exited with code ${result.code}`,
accessibility: null,
screenRecording: null,
}
}
// Parse the final snapshot line. The card always exits 0; tolerate trailing
// log chatter by scanning lines for the last valid JSON envelope.
const perms = parsePermissionSnapshot(result.stdout)
if (perms.accessibility === null && perms.screenRecording === null) {
return {
ok: false,
reason: 'permission card returned no permission snapshot',
accessibility: null,
screenRecording: null,
}
}
return { ok: true, accessibility: perms.accessibility, screenRecording: perms.screenRecording }
}
@@ -367,9 +511,64 @@ export function parsePermissionSnapshot(stdout: string): {
return { accessibility: null, screenRecording: null }
}
async function checkStatus(): Promise<EnvStatus> {
const platform = process.platform
const supported = platform === 'darwin' || platform === 'win32'
async function detectMacosProductVersion(): Promise<string | null> {
if (process.platform !== 'darwin') return null
const result = await runCommand('/usr/bin/sw_vers', ['-productVersion'])
return result.ok && result.stdout ? result.stdout : null
}
type CheckStatusDependencies = {
platform?: string
arch?: string
detectMacosProductVersion?: () => Promise<string | null>
isMacosRuntimeSupported?: (platform: string) => boolean
isCuHelperAvailable?: () => boolean
checkPermissions?: typeof checkCuHelperPermissions
}
export async function checkStatus(
dependencies: CheckStatusDependencies = {},
): Promise<EnvStatus> {
const platform = dependencies.platform ?? process.platform
const arch = dependencies.arch ?? process.arch
const systemVersion = platform === 'darwin'
? await (dependencies.detectMacosProductVersion ?? detectMacosProductVersion)()
: null
const kernelVersionEligible = platform === 'darwin'
&& (dependencies.isMacosRuntimeSupported ?? isMacosComputerUseRuntimeSupported)(platform)
const macosEligible = platform === 'darwin'
&& (systemVersion !== null
? isVersionAtLeast(systemVersion, MIN_MACOS_COMPUTER_USE_VERSION)
: kernelVersionEligible)
const helperPresent = macosEligible
&& (dependencies.isCuHelperAvailable ?? isCuHelperAvailableForServer)()
const capability = resolveComputerUseCapability(
platform,
systemVersion,
helperPresent,
kernelVersionEligible,
)
const supported = capability.supported
// macOS has a self-contained native runtime. Its status must never wait for
// a stale custom Python executable or expose the retired setup flow.
if (platform === 'darwin') {
const permissions = capability.cuHelper.available
? await (dependencies.checkPermissions ?? checkCuHelperPermissions)()
: { accessibility: null, screenRecording: null, error: null }
return {
platform,
supported,
engine: capability.engine,
systemVersion,
arch,
cuHelper: capability.cuHelper,
python: { installed: false, version: null, path: null, source: null, error: null },
venv: { created: false, path: venvRoot },
dependencies: { installed: false, requirementsFound: false },
permissions,
}
}
// Check venv — different paths on Windows vs Unix
const venvPython = isWindows
@@ -419,12 +618,12 @@ async function checkStatus(): Promise<EnvStatus> {
// straight from its `check_permissions` snapshot, with NO Python prerequisite.
// This is what lets the new settings UI show 辅助功能 / 屏幕录制 status even
// before (or entirely without) a Python venv.
const cuHelperAvailable = isCuHelperAvailableForServer()
const cuHelperAvailable = capability.cuHelper.available
if (cuHelperAvailable) {
const perms = await checkCuHelperPermissions()
accessibility = perms.accessibility
screenRecording = perms.screenRecording
} else if (supported && effectiveVenvCreated && depsInstalled) {
} else if (capability.engine === 'windows-compat' && effectiveVenvCreated && depsInstalled) {
// Python path (Windows, or macOS without cu-helper). The helper uses
// preflight + visible-window metadata as a passive fallback because plain
// preflight can misreport child processes launched by the desktop app.
@@ -447,7 +646,10 @@ async function checkStatus(): Promise<EnvStatus> {
return {
platform,
supported,
cuHelper: { available: cuHelperAvailable },
engine: capability.engine,
systemVersion,
arch,
cuHelper: capability.cuHelper,
python: {
installed: pythonRuntime.installed,
version: pythonRuntime.version,
@@ -457,7 +659,7 @@ async function checkStatus(): Promise<EnvStatus> {
},
venv: { created: effectiveVenvCreated, path: venvRoot },
dependencies: { installed: depsInstalled, requirementsFound: requirementsFound || true },
permissions: { accessibility, screenRecording },
permissions: { accessibility, screenRecording, error: null },
}
}
@@ -499,6 +701,16 @@ export async function installSetupDependencies(
async function runSetup(): Promise<SetupResult> {
const steps: SetupResult['steps'] = []
if (process.platform === 'darwin') {
return {
success: false,
steps: [{
name: 'native_runtime',
ok: false,
message: 'Computer Use on macOS uses the built-in native runtime and does not require Python setup.',
}],
}
}
const unsupportedPlatformStep = getUnsupportedComputerUsePlatformStep(process.platform)
if (unsupportedPlatformStep) {
return { success: false, steps: [unsupportedPlatformStep] }
@@ -887,12 +1099,10 @@ async function listInstalledApps(): Promise<{ bundleId: string; displayName: str
if (process.platform === 'darwin') {
const apps = await listInstalledMacApps()
if (apps.length > 0) return apps
}
// macOS fallback: ask the Swift cu-helper (still ahead of the Python path,
// which additionally gates on venv+helper being installed).
if (isCuHelperAvailableForServer()) {
return listInstalledAppsViaCuHelper()
// The native helper is the only macOS fallback. Never cross into the
// retired Python runtime when native app enumeration is temporarily empty.
if (isCuHelperAvailableForServer()) return listInstalledAppsViaCuHelper()
return []
}
const helperPath = getHelperPath()
+55 -24
View File
@@ -1,21 +1,35 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
__resetCuHelperCache,
callCuHelper,
isCuHelperAvailable,
isMacosComputerUseRuntimeSupported,
resolveCuHelperAppBundle,
resolveCuHelperBinary,
resolveCuHelperDevelopmentBinary,
resolveLaunchableCuHelperBinary,
} from './cuHelperBridge.js'
import { __resetInstalledHelperCache } from './cuHelperInstall.js'
afterEach(() => {
function resetComputerUseHelperState(): void {
__resetCuHelperCache()
// callCuHelper now resolves through ensureInstalledHelper(); clear its module
// cache too so a prior test's resolution can't leak into the next.
__resetInstalledHelperCache()
delete process.env.CC_HAHA_CU_HELPER_PATH
delete process.env.CLAUDE_APP_ROOT
})
}
beforeEach(resetComputerUseHelperState)
afterEach(resetComputerUseHelperState)
const currentDevBinary = resolveCuHelperDevelopmentBinary('/project')
if (!currentDevBinary) throw new Error(`unsupported test architecture: ${process.arch}`)
const currentDevSuffix = currentDevBinary.slice('/project'.length)
function isCurrentDevBinary(candidate: string): boolean {
return candidate.endsWith(currentDevSuffix)
}
describe('resolveCuHelperBinary', () => {
test('returns the env override when it exists', () => {
@@ -31,7 +45,7 @@ describe('resolveCuHelperBinary', () => {
const found = resolveCuHelperBinary(p =>
p === '/tmp/evil-helper'
|| p.endsWith('/native/cu-helper/.build/release/cc-haha-computer-use.app/Contents/MacOS/cc-haha-computer-use')
|| isCurrentDevBinary(p)
|| p === bundled,
)
expect(found).toBe(bundled)
@@ -39,21 +53,24 @@ describe('resolveCuHelperBinary', () => {
test('ignores the env override when it does not exist, falling to candidates', () => {
process.env.CC_HAHA_CU_HELPER_PATH = '/missing/cu-helper'
const found = resolveCuHelperBinary(p =>
p.endsWith('/native/cu-helper/.build/release/cc-haha-computer-use.app/Contents/MacOS/cc-haha-computer-use'),
)
expect(found).toContain(
'native/cu-helper/.build/release/cc-haha-computer-use.app/Contents/MacOS/cc-haha-computer-use',
)
const found = resolveCuHelperBinary(isCurrentDevBinary)
expect(found?.endsWith(currentDevSuffix)).toBe(true)
})
test('resolves the dev build path (.app inner executable)', () => {
const found = resolveCuHelperBinary(p =>
p.endsWith('/native/cu-helper/.build/release/cc-haha-computer-use.app/Contents/MacOS/cc-haha-computer-use'),
const found = resolveCuHelperBinary(isCurrentDevBinary)
expect(found?.endsWith(currentDevSuffix)).toBe(true)
expect(found).not.toContain('/.build/release/')
})
test('maps Node architectures to matching thin SwiftPM products', () => {
expect(resolveCuHelperDevelopmentBinary('/repo', 'arm64')).toBe(
'/repo/native/cu-helper/.build/arm64/arm64-apple-macosx/release/cc-haha-computer-use.app/Contents/MacOS/cc-haha-computer-use',
)
expect(found).toContain(
'native/cu-helper/.build/release/cc-haha-computer-use.app/Contents/MacOS/cc-haha-computer-use',
expect(resolveCuHelperDevelopmentBinary('/repo', 'x64')).toBe(
'/repo/native/cu-helper/.build/x86_64/x86_64-apple-macosx/release/cc-haha-computer-use.app/Contents/MacOS/cc-haha-computer-use',
)
expect(resolveCuHelperDevelopmentBinary('/repo', 'ia32')).toBeNull()
})
test('resolves the bundled unpacked path from CLAUDE_APP_ROOT (.asar → .asar.unpacked)', () => {
@@ -79,7 +96,7 @@ describe('resolveCuHelperBinary', () => {
// A packaged process must never escape to a writable development build.
const found = resolveCuHelperBinary(
p =>
p.endsWith('/native/cu-helper/.build/release/cc-haha-computer-use.app/Contents/MacOS/cc-haha-computer-use') ||
isCurrentDevBinary(p) ||
p.endsWith('/app.asar.unpacked/src-tauri/binaries/cc-haha-computer-use.app/Contents/MacOS/cc-haha-computer-use'),
)
expect(found).toContain('app.asar.unpacked')
@@ -98,10 +115,8 @@ describe('resolveCuHelperBinary', () => {
describe('resolveCuHelperAppBundle', () => {
test('derives the .app bundle path from the resolved inner executable', () => {
const app = resolveCuHelperAppBundle(p =>
p.endsWith('/native/cu-helper/.build/release/cc-haha-computer-use.app/Contents/MacOS/cc-haha-computer-use'),
)
expect(app).toContain('native/cu-helper/.build/release/cc-haha-computer-use.app')
const app = resolveCuHelperAppBundle(isCurrentDevBinary)
expect(app).toContain(`native/cu-helper/.build/${process.arch === 'x64' ? 'x86_64' : 'arm64'}`)
expect(app?.endsWith('cc-haha-computer-use.app')).toBe(true)
// The bundle path stops at `.app` — it must NOT include the inner Contents/MacOS.
expect(app).not.toContain('Contents')
@@ -118,6 +133,14 @@ describe('resolveCuHelperAppBundle', () => {
})
describe('isCuHelperAvailable', () => {
test('gates the native runtime at macOS 14.4 in both directions', () => {
expect(isMacosComputerUseRuntimeSupported('darwin', '23.3.0')).toBe(false)
expect(isMacosComputerUseRuntimeSupported('darwin', '23.4.0')).toBe(true)
expect(isMacosComputerUseRuntimeSupported('darwin', '24.0.0')).toBe(true)
expect(isMacosComputerUseRuntimeSupported('linux', '24.0.0')).toBe(false)
expect(isMacosComputerUseRuntimeSupported('darwin', 'invalid')).toBe(false)
})
test('is false off darwin regardless of binary', () => {
if (process.platform !== 'darwin') {
expect(isCuHelperAvailable()).toBe(false)
@@ -127,6 +150,14 @@ describe('isCuHelperAvailable', () => {
expect(typeof isCuHelperAvailable()).toBe('boolean')
}
})
test('launch resolution fails closed before touching a helper on unsupported systems', () => {
process.env.CC_HAHA_CU_HELPER_PATH = '/x/cu-helper'
__resetCuHelperCache()
resolveCuHelperBinary(p => p === '/x/cu-helper')
expect(resolveLaunchableCuHelperBinary(false)).toBeNull()
expect(resolveLaunchableCuHelperBinary(true)).toBe('/x/cu-helper')
})
})
describe('callCuHelper', () => {
@@ -139,26 +170,26 @@ describe('callCuHelper', () => {
test('parses an ok envelope and returns result', async () => {
primeBinary()
const exec = async () => ({ code: 0, stdout: '{"ok":true,"result":{"x":1}}', stderr: '' })
const r = await callCuHelper<{ x: number }>('foo', {}, exec as never)
const r = await callCuHelper<{ x: number }>('foo', {}, exec as never, () => '/x/cu-helper')
expect(r).toEqual({ x: 1 })
})
test('throws the helper error message on ok:false', async () => {
primeBinary()
const exec = async () => ({ code: 0, stdout: '{"ok":false,"error":{"message":"nope"}}', stderr: '' })
await expect(callCuHelper('foo', {}, exec as never)).rejects.toThrow('nope')
await expect(callCuHelper('foo', {}, exec as never, () => '/x/cu-helper')).rejects.toThrow('nope')
})
test('throws on invalid JSON', async () => {
primeBinary()
const exec = async () => ({ code: 0, stdout: 'not json', stderr: 'boom' })
await expect(callCuHelper('foo', {}, exec as never)).rejects.toThrow()
await expect(callCuHelper('foo', {}, exec as never, () => '/x/cu-helper')).rejects.toThrow()
})
test('throws a clear error when the binary is missing', async () => {
__resetCuHelperCache()
resolveCuHelperBinary(() => false) // prime cache to null
await expect(callCuHelper('foo', {})).rejects.toThrow(/not found/)
await expect(callCuHelper('foo', {}, undefined, () => null)).rejects.toThrow(/not found/)
})
test('never falls back to a packaged nested source when standalone installation fails', async () => {
@@ -187,7 +218,7 @@ describe('callCuHelper', () => {
seenArgs = args
return { code: 0, stdout: '{"ok":true,"result":true}', stderr: '' }
}
await callCuHelper('click', { x: 5, y: 9 }, exec as never)
await callCuHelper('click', { x: 5, y: 9 }, exec as never, () => '/x/cu-helper')
expect(seenArgs).toEqual(['click', '--payload', '{"x":5,"y":9}'])
})
})
+67 -19
View File
@@ -1,4 +1,5 @@
import { existsSync } from 'node:fs'
import { release } from 'node:os'
import path from 'node:path'
import { execFileNoThrow } from '../execFileNoThrow.js'
import { ensureInstalledHelper, isNestedInHostApp } from './cuHelperInstall.js'
@@ -52,12 +53,11 @@ function resolveUncached(exists: (p: string) => boolean): string | null {
const { projectRoot } = getRuntimePaths()
const candidates: string[] = []
if (!isPackaged) {
// dev: `native/cu-helper/build.sh` release output. cu-helper ships as a
// real .app bundle because Screen Recording requires that stable subject.
candidates.push(path.join(
projectRoot, 'native', 'cu-helper', '.build', 'release',
'cc-haha-computer-use.app', 'Contents', 'MacOS', 'cc-haha-computer-use',
))
// build.sh deliberately uses architecture-specific SwiftPM scratch paths.
// Never fall back to the legacy `.build/release` symlink: it can point at a
// stale helper with the wrong architecture or deployment target.
const developmentBinary = resolveCuHelperDevelopmentBinary(projectRoot)
if (developmentBinary) candidates.push(developmentBinary)
}
// bundled: in a packaged Electron app this resolver runs inside the spawned
@@ -84,6 +84,31 @@ function resolveUncached(exists: (p: string) => boolean): string | null {
return null
}
export function resolveCuHelperDevelopmentBinary(
projectRoot: string,
nodeArch: string = process.arch,
): string | null {
const swiftArch = nodeArch === 'arm64'
? 'arm64'
: nodeArch === 'x64'
? 'x86_64'
: null
if (!swiftArch) return null
return path.join(
projectRoot,
'native',
'cu-helper',
'.build',
swiftArch,
`${swiftArch}-apple-macosx`,
'release',
'cc-haha-computer-use.app',
'Contents',
'MacOS',
'cc-haha-computer-use',
)
}
/**
* Resolve the cu-helper `.app` BUNDLE directory (e.g. `…/cc-haha-computer-use.app`),
* derived from the resolved inner executable. This is the SOURCE bundle (dev build
@@ -115,8 +140,40 @@ export function resolveCuHelperAppBundle(
}
/** True only on macOS AND when the native binary is actually present. */
export function isMacosComputerUseRuntimeSupported(
platform: NodeJS.Platform = process.platform,
darwinRelease: string = release(),
): boolean {
if (platform !== 'darwin') return false
const [majorText, minorText] = darwinRelease.split('.')
const major = Number.parseInt(majorText ?? '', 10)
const minor = Number.parseInt(minorText ?? '', 10)
if (!Number.isFinite(major) || !Number.isFinite(minor)) return false
// Darwin 23.4 == macOS 14.4. Darwin 24+ are newer supported macOS
// releases. This synchronous runtime gate complements the status API's
// authoritative `sw_vers` check and prevents old systems from injecting a
// helper their loader cannot execute.
return major > 23 || (major === 23 && minor >= 4)
}
export function isCuHelperAvailable(): boolean {
return process.platform === 'darwin' && resolveCuHelperBinary() !== null
return isMacosComputerUseRuntimeSupported() && resolveCuHelperBinary() !== null
}
/**
* Return the only helper path that is safe to launch. Packaged nested bundles
* are a copy source, not a TCC subject: if canonical installation fails we
* fail closed instead of silently attributing Screen Recording to the host.
*/
export function resolveLaunchableCuHelperBinary(
runtimeSupported: boolean = isMacosComputerUseRuntimeSupported(),
): string | null {
if (!runtimeSupported) return null
const installed = ensureInstalledHelper()
if (installed?.binary) return installed.binary
const sourceApp = resolveCuHelperAppBundle()
if (sourceApp && isNestedInHostApp(sourceApp)) return null
return resolveCuHelperBinary()
}
/**
@@ -128,6 +185,7 @@ export async function callCuHelper<T>(
command: string,
payload: Record<string, unknown> = {},
exec: typeof execFileNoThrow = execFileNoThrow,
resolveBin: () => string | null = resolveLaunchableCuHelperBinary,
): Promise<T> {
// Prefer the STANDALONE-INSTALLED helper (same path the daemon + card use) so a
// CLI-fallback screenshot's Screen Recording subject is the helper, not the
@@ -135,20 +193,10 @@ export async function callCuHelper<T>(
// have failed because its bytes/signer did not match, and launching it would
// also re-attribute Screen Recording to the outer Electron app. Bare dev/test
// overrides remain valid because they have no nested `.app` source bundle.
const installed = ensureInstalledHelper()
let bin = installed?.binary ?? null
if (!bin) {
const sourceApp = resolveCuHelperAppBundle()
if (sourceApp && isNestedInHostApp(sourceApp)) {
throw new Error(
'cu-helper standalone installation failed; refusing packaged nested source',
)
}
bin = resolveCuHelperBinary()
}
const bin = resolveBin()
if (!bin) {
throw new Error(
'cu-helper binary not found. Build it: native/cu-helper/build.sh',
'cu-helper standalone installation failed or binary was not found. Build it: native/cu-helper/build.sh',
)
}
+4 -2
View File
@@ -677,8 +677,10 @@ async function callExistingDaemon<T>(
function needsOverlayReconciliation(): boolean {
// A turn may contain only get_app_state and therefore never show the overlay.
// Its native state (capture stream, AX baseline, held-input guard and display
// sleep assertion) still needs an explicit turn_end at host cleanup.
// Its turn-owned native state (AX baseline, held-input guard and display
// sleep assertion) still needs an explicit turn_end at host cleanup. The
// keyed SCStream consumer deliberately survives that boundary and retires on
// target/config changes or daemon teardown.
if (!overlayDesiredVisible) return overlayActualVisible || activeTurnId !== undefined
return !overlayActualVisible || overlayActualKey !== overlayDesiredKey
}
+121 -5
View File
@@ -1,5 +1,7 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { createHash } from 'node:crypto'
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import {
__resetInstalledHelperCache,
@@ -38,11 +40,11 @@ describe('installedHelperAppBundle / installedHelperRoot', () => {
})
describe('standalone helper copy command', () => {
test('uses the trusted system cp binary instead of PATH lookup', async () => {
test('uses system ditto without quarantine metadata', async () => {
const { __copyAppCommandForTests } = await import('./cuHelperInstall.js')
expect(__copyAppCommandForTests('/source/helper.app', '/dest/helper.app')).toEqual({
command: '/bin/cp',
args: ['-R', '/source/helper.app', '/dest/helper.app'],
command: '/usr/bin/ditto',
args: ['--noqtn', '/source/helper.app', '/dest/helper.app'],
})
})
})
@@ -51,6 +53,7 @@ describe('ensureInstalledHelper', () => {
const CONFIG = '/cfg'
const DEST_APP = path.join(CONFIG, 'cu-helper', 'cc-haha-computer-use.app')
const DEST_INNER = path.join(DEST_APP, INNER)
const STAGING_APP = path.join(CONFIG, 'cu-helper', '.cc-haha-computer-use.app.staging-test')
const NESTED =
'/Applications/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/src-tauri/binaries/cc-haha-computer-use.app'
const STANDALONE = '/dev/native/cu-helper/.build/release/cc-haha-computer-use.app'
@@ -85,14 +88,13 @@ describe('ensureInstalledHelper', () => {
sourceApp: NESTED,
configHome: CONFIG,
exists: (p: string) => (p === DEST_INNER ? state.destExists : false),
readFileBytes: (p: string) => p.startsWith(DEST_APP)
readFileBytes: (p: string) => p.startsWith(DEST_APP) || p.startsWith(STAGING_APP)
? state.destBytes
: BYTES,
readMarker: () => state.marker,
copyApp: (_src: string, _dest: string) => {
if (initial.failCopy) throw new Error('cp -R failed')
state.ops.push('cp')
state.destExists = true
state.destBytes = initial.copyCorrupt ? Buffer.from('corrupt') : BYTES
state.signatureValid = initial.copiedSignatureValid ?? true
},
@@ -109,6 +111,15 @@ describe('ensureInstalledHelper', () => {
if (p === DEST_APP) state.destExists = false
},
mkdir: () => state.ops.push('mkdir'),
stagingApp: STAGING_APP,
withInstallLock: <T>(_path: string, operation: () => T) => {
state.ops.push('lock')
return operation()
},
replaceApp: () => {
state.ops.push('replace')
state.destExists = true
},
},
}
}
@@ -134,10 +145,101 @@ describe('ensureInstalledHelper', () => {
const { state, deps } = fakeFs({ destExists: false })
const r = ensureInstalledHelper(deps)
expect(state.ops).toContain('cp')
expect(state.ops).toContain('replace')
expect(state.marker).toBe(HASH)
expect(r).toEqual({ appBundle: DEST_APP, binary: DEST_INNER })
})
test('installs and refreshes a canonical bundle through the real lock and recoverable replace path', () => {
const tempRoot = mkdtempSync(path.join(tmpdir(), 'cc-haha-cu-install-'))
try {
const sourceApp = path.join(
tempRoot,
'Host.app',
'Contents',
'Resources',
'cc-haha-computer-use.app',
)
const configHome = path.join(tempRoot, 'config')
const fixtureFiles = [
INNER,
path.join('Contents', 'Info.plist'),
path.join('Contents', '_CodeSignature', 'CodeResources'),
]
for (const relative of fixtureFiles) {
const target = path.join(sourceApp, relative)
mkdirSync(path.dirname(target), { recursive: true })
writeFileSync(target, `signed fixture: ${relative}`)
}
let copyCount = 0
const deps = {
sourceApp,
configHome,
copyApp: (src: string, dest: string) => {
copyCount += 1
cpSync(src, dest, { recursive: true })
},
verifyPackagedSignatures: () => true,
}
const destApp = installedHelperAppBundle(configHome)
const destInfo = path.join(destApp, 'Contents', 'Info.plist')
expect(ensureInstalledHelper(deps)?.appBundle).toBe(destApp)
expect(ensureInstalledHelper(deps)?.appBundle).toBe(destApp)
expect(copyCount).toBe(1)
writeFileSync(destInfo, 'tampered destination')
expect(ensureInstalledHelper(deps)?.appBundle).toBe(destApp)
expect(copyCount).toBe(2)
expect(readFileSync(destInfo, 'utf8')).toBe('signed fixture: Contents/Info.plist')
expect(existsSync(path.join(configHome, 'cu-helper', '.install.lock'))).toBe(false)
} finally {
rmSync(tempRoot, { recursive: true, force: true })
}
})
test('restores the last working bundle when post-replacement verification fails', () => {
const tempRoot = mkdtempSync(path.join(tmpdir(), 'cc-haha-cu-rollback-'))
try {
const sourceApp = path.join(
tempRoot,
'Host.app',
'Contents',
'Resources',
'cc-haha-computer-use.app',
)
const configHome = path.join(tempRoot, 'config')
const destApp = installedHelperAppBundle(configHome)
const fixtureFiles = [
INNER,
path.join('Contents', 'Info.plist'),
path.join('Contents', '_CodeSignature', 'CodeResources'),
]
for (const relative of fixtureFiles) {
const source = path.join(sourceApp, relative)
const destination = path.join(destApp, relative)
mkdirSync(path.dirname(source), { recursive: true })
mkdirSync(path.dirname(destination), { recursive: true })
writeFileSync(source, `new signed fixture: ${relative}`)
writeFileSync(destination, `last working fixture: ${relative}`)
}
const installed = ensureInstalledHelper({
sourceApp,
configHome,
copyApp: (src, dest) => cpSync(src, dest, { recursive: true }),
verifyPackagedSignatures: (_source, candidate) => candidate.includes('.staging-'),
})
expect(installed).toBeNull()
expect(readFileSync(path.join(destApp, INNER), 'utf8'))
.toBe(`last working fixture: ${INNER}`)
} finally {
rmSync(tempRoot, { recursive: true, force: true })
}
})
test('nested source + dest present + marker matches → NO copy (idempotent)', () => {
const { state, deps } = fakeFs({ destExists: true, marker: HASH })
const r = ensureInstalledHelper(deps)
@@ -146,6 +248,20 @@ describe('ensureInstalledHelper', () => {
expect(r).toEqual({ appBundle: DEST_APP, binary: DEST_INNER })
})
test('rechecks the destination after acquiring the install lock', () => {
const { state, deps } = fakeFs({ destExists: false })
deps.withInstallLock = <T>(_path: string, operation: () => T) => {
state.ops.push('lock')
state.destExists = true
state.marker = HASH
return operation()
}
expect(ensureInstalledHelper(deps)).toEqual({ appBundle: DEST_APP, binary: DEST_INNER })
expect(state.ops).not.toContain('cp')
expect(state.ops).not.toContain('replace')
})
test('matching marker does not hide destination bundle corruption', () => {
const { state, deps } = fakeFs({
destExists: true,
+133 -23
View File
@@ -1,11 +1,16 @@
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { createHash, randomUUID } from 'node:crypto'
import {
existsSync,
closeSync,
mkdirSync,
mkdtempSync,
openSync,
readFileSync,
renameSync,
rmSync,
statSync,
unlinkSync,
writeFileSync,
} from 'node:fs'
import os from 'node:os'
@@ -92,15 +97,22 @@ type InstallDeps = {
writeMarker?: (p: string, v: string) => void
readMarker?: (p: string) => string | null
verifyPackagedSignatures?: (sourceApp: string, destApp: string) => boolean
replaceApp?: (
stagingApp: string,
destApp: string,
verifyInstalled: () => boolean,
) => void
withInstallLock?: <T>(lockPath: string, operation: () => T) => T
stagingApp?: string
}
/** Real-FS copy via `cp -R`: preserves the code signature + exec mode bits, and
* the self-written copy carries no quarantine xattr (so it isn't translocated). */
/** Real-FS copy via the system `ditto --noqtn`: preserves the signed bundle and
* explicitly strips quarantine metadata from the canonical runtime copy. */
function copyAppCommand(src: string, dest: string): {
command: string
args: string[]
} {
return { command: '/bin/cp', args: ['-R', src, dest] }
return { command: '/usr/bin/ditto', args: ['--noqtn', src, dest] }
}
/** Focused security seam: production and the regression test share this spec. */
@@ -111,11 +123,84 @@ export function __copyAppCommandForTests(src: string, dest: string): {
return copyAppCommand(src, dest)
}
function cpDashR(src: string, dest: string): void {
function dittoNoQuarantine(src: string, dest: string): void {
const command = copyAppCommand(src, dest)
const r = spawnSync(command.command, command.args, { stdio: 'ignore' })
if (r.status !== 0) {
throw new Error(`cp -R failed (status ${String(r.status)}): ${r.error?.message ?? 'unknown'}`)
throw new Error(`ditto --noqtn failed (status ${String(r.status)}): ${r.error?.message ?? 'unknown'}`)
}
}
function withExclusiveInstallLock<T>(lockPath: string, operation: () => T): T {
const deadline = Date.now() + 5_000
const ownerToken = `${process.pid}:${Date.now()}:${randomUUID()}`
let descriptor: number | null = null
while (descriptor === null) {
try {
descriptor = openSync(lockPath, 'wx', 0o600)
writeFileSync(descriptor, `${ownerToken}\n`, 'utf8')
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code !== 'EEXIST') throw error
try {
const oldEnough = Date.now() - statSync(lockPath).mtimeMs > 30_000
const ownerText = readFileSync(lockPath, 'utf8').trim()
const ownerPid = Number.parseInt(ownerText.split(':', 1)[0] ?? '', 10)
let ownerAlive = Number.isFinite(ownerPid) && ownerPid > 0
if (ownerAlive) {
try {
process.kill(ownerPid, 0)
} catch (probeError) {
ownerAlive = (probeError as NodeJS.ErrnoException).code === 'EPERM'
}
}
if (oldEnough && !ownerAlive) {
// Rename the stale inode out of the lock path atomically. Competing
// recoverers can no longer unlink a fresh lock acquired afterward.
const stalePath = `${lockPath}.stale-${process.pid}-${randomUUID()}`
renameSync(lockPath, stalePath)
rmSync(stalePath, { force: true })
}
} catch {}
if (Date.now() >= deadline) {
throw new Error('timed out waiting for the cu-helper install lock')
}
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50)
}
}
try {
return operation()
} finally {
closeSync(descriptor)
try {
// Only remove the lock inode we created. If an external repair replaced
// the path, leave its owner's lock intact.
if (readFileSync(lockPath, 'utf8').trim() === ownerToken) unlinkSync(lockPath)
} catch {}
}
}
function replaceAppRecoverably(
stagingApp: string,
destApp: string,
verifyInstalled: () => boolean,
): void {
const backupApp = `${destApp}.previous-${process.pid}`
rmSync(backupApp, { recursive: true, force: true })
const hadDestination = existsSync(destApp)
if (hadDestination) renameSync(destApp, backupApp)
try {
renameSync(stagingApp, destApp)
if (!verifyInstalled()) {
throw new Error('installed cu-helper failed post-replacement verification')
}
rmSync(backupApp, { recursive: true, force: true })
} catch (error) {
rmSync(destApp, { recursive: true, force: true })
if (hadDestination && existsSync(backupApp)) {
renameSync(backupApp, destApp)
}
throw error
}
}
@@ -272,36 +357,61 @@ function ensureInstalledHelperUncached(deps: InstallDeps): InstalledHelper | nul
const srcHash = signedBundleFingerprint(sourceApp, readBytes)
let destinationVerified = false
let destinationMatches = false
if (exists(destInner) && readMarker(markerPath) === srcHash) {
const verifyDestination = () => {
if (!exists(destInner) || readMarker(markerPath) !== srcHash) return false
try {
const fingerprintMatches = signedBundleFingerprint(destApp, readBytes) === srcHash
destinationVerified = fingerprintMatches
&& verifySignatures(sourceApp, destApp)
destinationMatches = fingerprintMatches && destinationVerified
return fingerprintMatches && destinationVerified
} catch {
destinationVerified = false
destinationMatches = false
return false
}
}
const upToDate = destinationMatches
const upToDate = verifyDestination()
if (!upToDate) {
const rm = deps.rm ?? ((p) => rmSync(p, { recursive: true, force: true }))
const mkdir = deps.mkdir ?? ((p) => mkdirSync(p, { recursive: true, mode: 0o700 }))
const copyApp = deps.copyApp ?? cpDashR
const copyApp = deps.copyApp ?? dittoNoQuarantine
const writeMarker = deps.writeMarker ?? ((p, v) => writeFileSync(p, `${v}\n`, 'utf8'))
const replaceApp = deps.replaceApp ?? replaceAppRecoverably
const withInstallLock = deps.withInstallLock ?? withExclusiveInstallLock
const stagingApp = deps.stagingApp
?? path.join(root, `.${APP_NAME}.staging-${process.pid}`)
mkdir(root)
rm(destApp)
copyApp(sourceApp, destApp)
if (signedBundleFingerprint(destApp, readBytes) !== srcHash) {
throw new Error('copied cu-helper bundle fingerprint does not match source')
}
destinationVerified = verifySignatures(sourceApp, destApp)
if (!destinationVerified) {
throw new Error('copied cu-helper signature does not match the packaged sidecar')
}
writeMarker(markerPath, srcHash)
logForDebugging(`installed cu-helper to standalone path: ${destApp}`, { level: 'debug' })
withInstallLock(path.join(root, '.install.lock'), () => {
// Another sidecar may have completed the same install while this one
// waited. Re-check under the cross-process lock before copying.
if (verifyDestination()) return
rm(stagingApp)
try {
copyApp(sourceApp, stagingApp)
if (signedBundleFingerprint(stagingApp, readBytes) !== srcHash) {
throw new Error('copied cu-helper bundle fingerprint does not match source')
}
if (!verifySignatures(sourceApp, stagingApp)) {
throw new Error('copied cu-helper signature does not match the packaged sidecar')
}
const verifyInstalled = () => {
try {
return signedBundleFingerprint(destApp, readBytes) === srcHash
&& verifySignatures(sourceApp, destApp)
} catch {
return false
}
}
replaceApp(stagingApp, destApp, verifyInstalled)
destinationVerified = verifyInstalled()
if (!destinationVerified) {
throw new Error('installed cu-helper signature does not match the packaged sidecar')
}
writeMarker(markerPath, srcHash)
logForDebugging(`installed cu-helper to standalone path: ${destApp}`, { level: 'debug' })
} finally {
rm(stagingApp)
}
})
}
if (exists(destInner) && destinationVerified) {
return { appBundle: destApp, binary: destInner }
+13 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { getChicagoEnabled } from './gates.js'
import { getChicagoEnabled, shouldExposeComputerUseMcp } from './gates.js'
const ORIGINAL_ENABLED = process.env.CLAUDE_COMPUTER_USE_ENABLED
@@ -25,3 +25,15 @@ describe('getChicagoEnabled', () => {
expect(getChicagoEnabled()).toBe(false)
})
})
describe('shouldExposeComputerUseMcp', () => {
test('requires the canonical native helper on macOS', () => {
expect(shouldExposeComputerUseMcp('darwin', true)).toBe(true)
expect(shouldExposeComputerUseMcp('darwin', false)).toBe(false)
})
test('keeps Windows compatibility independent and rejects other platforms', () => {
expect(shouldExposeComputerUseMcp('win32', false)).toBe(true)
expect(shouldExposeComputerUseMcp('linux', true)).toBe(false)
})
})
+13
View File
@@ -40,6 +40,19 @@ export function getChicagoEnabled(): boolean {
return true
}
/**
* Computer Use is native-only on macOS and compatibility-only on Windows.
* The macOS MCP must not be exposed until the exact helper that status/API
* calls use is launchable; otherwise the model sees tools that can only fail.
*/
export function shouldExposeComputerUseMcp(
platform: NodeJS.Platform,
macosNativeLaunchable: boolean,
): boolean {
return platform === 'win32'
|| (platform === 'darwin' && macosNativeLaunchable)
}
export function getChicagoSubGates(): CuSubGates {
const { enabled: _e, coordinateMode: _c, ...subGates } = readConfig()
return subGates
@@ -1,7 +1,6 @@
import { spawn } from 'node:child_process'
import { logForDebugging } from '../debug.js'
import { resolveCuHelperBinary } from './cuHelperBridge.js'
import { ensureInstalledHelper } from './cuHelperInstall.js'
import { resolveLaunchableCuHelperBinary } from './cuHelperBridge.js'
/**
* Auto-present the native macOS permission card (`cu-helper request-access`)
@@ -53,7 +52,7 @@ export function maybeShowNativePermissionCard(
// dragging THAT into Screen Recording grants the helper's own SR subject, and
// dragging it into Accessibility grants the helper too. One identity, both
// permissions. (See cuHelperInstall.ts.)
const resolveBin = deps.resolveBin ?? (() => ensureInstalledHelper()?.binary ?? resolveCuHelperBinary())
const resolveBin = deps.resolveBin ?? resolveLaunchableCuHelperBinary
const bin = resolveBin()
if (!bin) return
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, test } from 'bun:test'
import { setupComputerUseMCP } from './setup.js'
describe('setupComputerUseMCP runtime capability', () => {
test('does not expose tools when the canonical macOS helper is unavailable', () => {
expect(setupComputerUseMCP({
platform: 'darwin',
resolveMacosNativeBinary: () => null,
})).toEqual({ mcpConfig: {}, allowedTools: [] })
})
test('exposes the native tools only after the macOS helper is launchable', () => {
const result = setupComputerUseMCP({
platform: 'darwin',
resolveMacosNativeBinary: () => '/cfg/cu-helper/helper',
})
expect(Object.keys(result.mcpConfig)).toEqual(['computer-use'])
expect(result.allowedTools.length).toBeGreaterThan(0)
})
test('keeps the Windows compatibility engine available without a macOS helper', () => {
const result = setupComputerUseMCP({
platform: 'win32',
resolveMacosNativeBinary: () => {
throw new Error('must not resolve a macOS helper on Windows')
},
})
expect(Object.keys(result.mcpConfig)).toEqual(['computer-use'])
expect(result.allowedTools.length).toBeGreaterThan(0)
})
})
+15 -2
View File
@@ -9,7 +9,13 @@ import {
COMPUTER_USE_MCP_SERVER_NAME,
getCliComputerUseCapabilities,
} from './common.js'
import { getChicagoCoordinateMode } from './gates.js'
import { resolveLaunchableCuHelperBinary } from './cuHelperBridge.js'
import { getChicagoCoordinateMode, shouldExposeComputerUseMcp } from './gates.js'
type SetupComputerUseDeps = {
platform?: NodeJS.Platform
resolveMacosNativeBinary?: () => string | null
}
/**
* Build the dynamic MCP config + allowed tool names. Mirror of
@@ -23,10 +29,17 @@ import { getChicagoCoordinateMode } from './gates.js'
* with different names wouldn't trigger it. Cowork uses the same names for the
* same reason (apps/desktop/src/main/local-agent-mode/systemPrompt.ts:314).
*/
export function setupComputerUseMCP(): {
export function setupComputerUseMCP(deps: SetupComputerUseDeps = {}): {
mcpConfig: Record<string, ScopedMcpServerConfig>
allowedTools: string[]
} {
const platform = deps.platform ?? process.platform
const macosNativeLaunchable = platform === 'darwin'
&& (deps.resolveMacosNativeBinary ?? resolveLaunchableCuHelperBinary)() !== null
if (!shouldExposeComputerUseMcp(platform, macosNativeLaunchable)) {
return { mcpConfig: {}, allowedTools: [] }
}
const allowedTools = buildPlatformComputerUseTools(
getCliComputerUseCapabilities(),
getChicagoCoordinateMode(),