mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
fix(computer-use): harden native macOS automation runtime
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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:')) ??
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': '常に許可するアプリはまだありません。「アプリを追加」をクリックして最初のアプリを認証してください。',
|
||||
|
||||
@@ -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': '항상 허용된 앱이 아직 없습니다. “앱 추가”를 클릭하여 첫 번째 앱을 인증하세요.',
|
||||
|
||||
@@ -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': '還沒有始終允許的應用。點選「新增應用」來授權第一個。',
|
||||
|
||||
@@ -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': '还没有始终允许的应用。点击「添加应用」来授权第一个。',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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}'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user