mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
merge: integrate Computer Use into local main
This commit is contained in:
@@ -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 and SignPath signing state and blocks unsigned releases', () => {
|
||||
test('release workflow requires signed macOS Computer Use and preserves SignPath draft policy', () => {
|
||||
const workflow = readReleaseWorkflow()
|
||||
const signingJob = workflow.match(
|
||||
/signing-preflight:[\s\S]*?(?:\n {2}[a-zA-Z0-9_-]+:|$)/,
|
||||
@@ -289,10 +289,8 @@ describe('release desktop workflow', () => {
|
||||
expect(signingJob).toContain(setting)
|
||||
}
|
||||
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('refusing to build a macOS release whose Computer Use runtime cannot pass client attestation')
|
||||
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('macos_signed=false')
|
||||
expect(signingJob).toContain('macos_signed=true')
|
||||
expect(signingJob).toContain('SignPath configuration missing')
|
||||
@@ -304,7 +302,7 @@ describe('release desktop workflow', () => {
|
||||
const macRequiredBlock = signingJob?.match(
|
||||
/missing=\(\)[\s\S]*?# Drafts may remain unsigned/,
|
||||
)?.[0]
|
||||
expect(macRequiredBlock).toContain('if [ "$RELEASE_DRAFT" != "true" ]; then')
|
||||
expect(macRequiredBlock).not.toContain('if [ "$RELEASE_DRAFT" != "true" ]; then')
|
||||
expect(macRequiredBlock).toContain('exit 1')
|
||||
expect(signingJob).toContain('if [ "$RELEASE_DRAFT" != "true" ]; then')
|
||||
expect(signingJob).toContain('exit 1')
|
||||
@@ -609,12 +607,66 @@ describe('release desktop workflow', () => {
|
||||
expect(desktopPackage.build.mac?.notarize).toBe(true)
|
||||
expect(desktopPackage.build.mac?.entitlements).toBe('build/entitlements.mac.plist')
|
||||
expect(desktopPackage.build.mac?.entitlementsInherit).toBe('build/entitlements.mac.inherit.plist')
|
||||
// The Computer Use helper is excluded on purpose. `native/cu-helper/build.sh`
|
||||
// already signed it under the stable identity `dev.cchaha.cu-helper`, and
|
||||
// macOS ties the user's Accessibility and Screen Recording grants to that
|
||||
// signing identity — re-signing it here would rotate the identity and
|
||||
// silently drop both permissions on every update.
|
||||
//
|
||||
// The sidecar is excluded for a different reason — see the dedicated test
|
||||
// below, which states the causal chain this literal list cannot express.
|
||||
expect(desktopPackage.build.mac?.signIgnore).toEqual([
|
||||
'/Contents/Frameworks/.+\\.(?:pak|bin|dat|nib)$',
|
||||
'/Contents/Resources/.+\\.(?:asar|pak|bin|dat|icns|png|jpg|jpeg|gif|svg|ttf|woff|woff2)$',
|
||||
'cc-haha-computer-use\\.app',
|
||||
'claude-sidecar-[^/]+$',
|
||||
])
|
||||
})
|
||||
|
||||
// Regression: this entry was once dropped from signIgnore while the literal
|
||||
// assertion above was edited to match, so the suite stayed green and every
|
||||
// Computer Use call in the shipped build failed closed with
|
||||
// `unauthorized_client` — the settings page just said "checking…" forever.
|
||||
//
|
||||
// The causal chain: `build-sidecars.ts` signs the sidecar with an explicit
|
||||
// `--identifier com.claude-code-haha.desktop.sidecar`, because
|
||||
// `ClientAttestation.swift` compares that identifier EXACTLY when it walks the
|
||||
// helper -> sidecar -> desktop process chain. If electron-builder re-signs the
|
||||
// sidecar it drops that flag, and codesign falls back to deriving the
|
||||
// identifier from the file name (`claude-sidecar-aarch64-apple-darwin`), which
|
||||
// never matches. So this test asserts the behaviour (real sidecar file names
|
||||
// are excluded) rather than the spelling of one array element.
|
||||
test('macOS signIgnore keeps electron-builder off the attested sidecar', () => {
|
||||
const desktopPackage = JSON.parse(readFileSync('desktop/package.json', 'utf8')) as {
|
||||
build: { mac?: { signIgnore?: string[] } }
|
||||
}
|
||||
const patterns = (desktopPackage.build.mac?.signIgnore ?? []).map(
|
||||
p => new RegExp(p),
|
||||
)
|
||||
|
||||
// Both architectures ship under these names; ClientAttestation.swift accepts
|
||||
// exactly these two, so both must survive electron-builder's signing pass.
|
||||
for (const sidecar of [
|
||||
'/Contents/Resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-aarch64-apple-darwin',
|
||||
'/Contents/Resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-x86_64-apple-darwin',
|
||||
]) {
|
||||
expect(
|
||||
patterns.some(p => p.test(sidecar)),
|
||||
`${sidecar} must be in signIgnore, or electron-builder re-signs it and ` +
|
||||
'the attestation chain breaks',
|
||||
).toBe(true)
|
||||
}
|
||||
|
||||
// The identifier the exclusion exists to protect. If this constant moves,
|
||||
// ClientAttestation.swift's `sidecarIdentifier` has to move with it.
|
||||
expect(
|
||||
readFileSync('desktop/scripts/sign-identity.ts', 'utf8'),
|
||||
).toContain("SIDECAR_SIGNING_IDENTIFIER = 'com.claude-code-haha.desktop.sidecar'")
|
||||
expect(
|
||||
readFileSync('native/cu-helper/Sources/cu-helper/ClientAttestation.swift', 'utf8'),
|
||||
).toContain('sidecarIdentifier = "com.claude-code-haha.desktop.sidecar"')
|
||||
})
|
||||
|
||||
test('Windows NSIS installer lets users choose the install directory', () => {
|
||||
const desktopPackage = JSON.parse(readFileSync('desktop/package.json', 'utf8')) as {
|
||||
build: {
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
assertChangedState,
|
||||
assertCleanupEvidence,
|
||||
assertMonitorContinuity,
|
||||
assertPointerTrace,
|
||||
assertNoChangeState,
|
||||
assertSafeRunDirectory,
|
||||
assertScreenshotChanged,
|
||||
assertStaleHandleFailure,
|
||||
assertSystemStatePreserved,
|
||||
acquireLiveSmokeLock,
|
||||
deriveLiveSmokePaths,
|
||||
findEditableHandle,
|
||||
hasExactNoChangeState,
|
||||
hasFreshScreenshot,
|
||||
errorMessage,
|
||||
parseInputMonitorSnapshot,
|
||||
parseLiveSmokeArgs,
|
||||
parseSystemSnapshot,
|
||||
type LiveAppState,
|
||||
type SystemSnapshot,
|
||||
} from './computer-use-live-smoke.js'
|
||||
|
||||
const initialSystemSnapshot: SystemSnapshot = {
|
||||
frontmost: {
|
||||
pid: 101,
|
||||
bundleId: 'com.openai.codex',
|
||||
executablePath: '/Applications/Codex.app/Contents/MacOS/Codex',
|
||||
launchTime: 1234.5,
|
||||
},
|
||||
pointer: { x: 100, y: 200 },
|
||||
input: {
|
||||
flags: '0',
|
||||
buttons: [false, false, false, false, false],
|
||||
},
|
||||
}
|
||||
|
||||
const PNG_ONE =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='
|
||||
const PNG_TWO =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR42mP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC'
|
||||
|
||||
function state(overrides: Partial<LiveAppState> = {}): LiveAppState {
|
||||
return {
|
||||
pid: 220,
|
||||
bundleId: 'com.apple.TextEdit',
|
||||
appName: 'TextEdit',
|
||||
windowTitle: 'smoke-fixture.txt',
|
||||
elementCount: 3,
|
||||
truncated: false,
|
||||
durationMs: 10,
|
||||
axText: [
|
||||
'g8:0 standard window smoke-fixture.txt',
|
||||
'\tg8:7 text area CC_HAHA_SMOKE_STABLE_TOKEN',
|
||||
'\tg8:9 button close',
|
||||
].join('\n'),
|
||||
elements: [
|
||||
{
|
||||
index: 7,
|
||||
role: 'AXTextArea',
|
||||
settable: true,
|
||||
value: 'CC_HAHA_SMOKE_STABLE_TOKEN',
|
||||
},
|
||||
],
|
||||
screenshot: {
|
||||
base64: PNG_ONE,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('computer-use live smoke CLI safety', () => {
|
||||
test('surfaces every primary and cleanup error from an AggregateError', () => {
|
||||
expect(errorMessage(new AggregateError([
|
||||
new Error('primary failed'),
|
||||
new Error('cleanup failed'),
|
||||
], 'smoke failed'))).toContain('primary failed')
|
||||
expect(errorMessage(new AggregateError([
|
||||
new Error('primary failed'),
|
||||
new Error('cleanup failed'),
|
||||
], 'smoke failed'))).toContain('cleanup failed')
|
||||
})
|
||||
|
||||
test('defaults to the dedicated TextEdit target and accepts only its exact bundle id', () => {
|
||||
expect(parseLiveSmokeArgs([])).toEqual({
|
||||
targetBundleId: 'com.apple.TextEdit',
|
||||
})
|
||||
expect(parseLiveSmokeArgs(['--target', 'com.apple.TextEdit'])).toEqual({
|
||||
targetBundleId: 'com.apple.TextEdit',
|
||||
})
|
||||
})
|
||||
|
||||
test('acquires/releases the production lock and performs no release when blocked', async () => {
|
||||
let releases = 0
|
||||
const release = await acquireLiveSmokeLock(
|
||||
async () => ({ kind: 'acquired', fresh: true }),
|
||||
async () => {
|
||||
releases += 1
|
||||
return true
|
||||
},
|
||||
)
|
||||
expect(releases).toBe(0)
|
||||
await release()
|
||||
expect(releases).toBe(1)
|
||||
|
||||
let blockedRelease = 0
|
||||
await expect(
|
||||
acquireLiveSmokeLock(
|
||||
async () => ({ kind: 'blocked', by: 'other-session' }),
|
||||
async () => {
|
||||
blockedRelease += 1
|
||||
return true
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/did not start/i)
|
||||
expect(blockedRelease).toBe(0)
|
||||
})
|
||||
|
||||
test('refuses Finder, terminal, system, and arbitrary targets', () => {
|
||||
for (const target of [
|
||||
'Finder',
|
||||
'com.apple.finder',
|
||||
'Terminal',
|
||||
'com.apple.Terminal',
|
||||
'System Settings',
|
||||
'com.apple.systempreferences',
|
||||
'com.googlecode.iterm2',
|
||||
'com.example.OtherApp',
|
||||
]) {
|
||||
expect(() => parseLiveSmokeArgs(['--target', target])).toThrow(
|
||||
/dedicated TextEdit/i,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects missing values and unknown CLI flags', () => {
|
||||
expect(() => parseLiveSmokeArgs(['--target'])).toThrow(/value/i)
|
||||
expect(() => parseLiveSmokeArgs(['--fixture', '/tmp/user-file.txt'])).toThrow(
|
||||
/unknown argument/i,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computer-use live smoke path confinement', () => {
|
||||
test('derives the fixture and this process daemon artifacts deterministically', () => {
|
||||
expect(
|
||||
deriveLiveSmokePaths(
|
||||
'/tmp/cc-haha-cu-live-smoke-ABC123',
|
||||
'/Users/test/.claude/.runtime',
|
||||
4321,
|
||||
),
|
||||
).toEqual({
|
||||
runDirectory: '/tmp/cc-haha-cu-live-smoke-ABC123',
|
||||
fixturePath:
|
||||
'/tmp/cc-haha-cu-live-smoke-ABC123/computer-use-smoke-fixture.txt',
|
||||
targetIdentityPath:
|
||||
'/tmp/cc-haha-cu-live-smoke-ABC123/.textedit-identity.json',
|
||||
daemonSocket:
|
||||
'/Users/test/.claude/.runtime/cu-helper.daemon.4321.1.sock',
|
||||
daemonPidfile:
|
||||
'/Users/test/.claude/.runtime/cu-helper.daemon.4321.1.sock.pid',
|
||||
})
|
||||
})
|
||||
|
||||
test('accepts only one generated child directory directly beneath /tmp', () => {
|
||||
expect(() =>
|
||||
assertSafeRunDirectory('/tmp/cc-haha-cu-live-smoke-ABC123'),
|
||||
).not.toThrow()
|
||||
|
||||
for (const unsafe of [
|
||||
'/',
|
||||
'/tmp',
|
||||
'/tmp/cc-haha-cu-live-smoke-',
|
||||
'/tmp/cc-haha-cu-live-smoke-ABC123/..',
|
||||
'/var/tmp/cc-haha-cu-live-smoke-ABC123',
|
||||
'/tmp/other-ABC123',
|
||||
]) {
|
||||
expect(() => assertSafeRunDirectory(unsafe)).toThrow(/unsafe/i)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('computer-use live smoke state evidence', () => {
|
||||
test('parses a complete system snapshot and rejects unproven foreground identity', () => {
|
||||
expect(parseSystemSnapshot(JSON.stringify(initialSystemSnapshot))).toEqual(
|
||||
initialSystemSnapshot,
|
||||
)
|
||||
|
||||
expect(() =>
|
||||
parseSystemSnapshot(
|
||||
JSON.stringify({
|
||||
...initialSystemSnapshot,
|
||||
frontmost: { ...initialSystemSnapshot.frontmost, launchTime: null },
|
||||
}),
|
||||
),
|
||||
).toThrow(/frontmost/i)
|
||||
})
|
||||
|
||||
test('accepts at most one pixel of pointer drift with exact foreground and held-input state', () => {
|
||||
expect(() =>
|
||||
assertSystemStatePreserved(initialSystemSnapshot, {
|
||||
...initialSystemSnapshot,
|
||||
pointer: { x: 100.6, y: 200.6 },
|
||||
}),
|
||||
).not.toThrow()
|
||||
|
||||
expect(() =>
|
||||
assertSystemStatePreserved(initialSystemSnapshot, {
|
||||
...initialSystemSnapshot,
|
||||
pointer: { x: 101.01, y: 200 },
|
||||
}),
|
||||
).toThrow(/pointer drift/i)
|
||||
})
|
||||
|
||||
test('rejects PID reuse, foreground replacement, and stuck input state', () => {
|
||||
expect(() =>
|
||||
assertSystemStatePreserved(initialSystemSnapshot, {
|
||||
...initialSystemSnapshot,
|
||||
frontmost: { ...initialSystemSnapshot.frontmost, launchTime: 9999 },
|
||||
}),
|
||||
).toThrow(/frontmost identity/i)
|
||||
|
||||
expect(() =>
|
||||
assertSystemStatePreserved(initialSystemSnapshot, {
|
||||
...initialSystemSnapshot,
|
||||
input: {
|
||||
...initialSystemSnapshot.input,
|
||||
buttons: [true, false, false, false, false],
|
||||
},
|
||||
}),
|
||||
).toThrow(/held input/i)
|
||||
})
|
||||
|
||||
test('rejects transient pointer movement even if the endpoint was restored', () => {
|
||||
expect(() => assertPointerTrace({ samples: 50, maxDriftPx: 0.8 })).not.toThrow()
|
||||
expect(() => assertPointerTrace({ samples: 50, maxDriftPx: 12 })).toThrow(
|
||||
/transiently/i,
|
||||
)
|
||||
expect(() => assertPointerTrace({ samples: 1, maxDriftPx: 0 })).toThrow(
|
||||
/too few samples/i,
|
||||
)
|
||||
})
|
||||
|
||||
test('requires an available, continuous physical-input monitor', () => {
|
||||
const before = parseInputMonitorSnapshot({
|
||||
epoch: '42',
|
||||
available: true,
|
||||
continuityGeneration: '3',
|
||||
})
|
||||
const after = parseInputMonitorSnapshot({
|
||||
epoch: '42',
|
||||
available: true,
|
||||
continuityGeneration: '3',
|
||||
})
|
||||
expect(() => assertMonitorContinuity(before, after)).not.toThrow()
|
||||
|
||||
expect(() =>
|
||||
parseInputMonitorSnapshot({
|
||||
epoch: '42',
|
||||
available: false,
|
||||
continuityGeneration: '3',
|
||||
}),
|
||||
).toThrow(/physical-input monitor/i)
|
||||
|
||||
expect(() =>
|
||||
assertMonitorContinuity(before, { ...after, epoch: 43n }),
|
||||
).toThrow(/physical input/i)
|
||||
expect(() =>
|
||||
assertMonitorContinuity(before, {
|
||||
...after,
|
||||
continuityGeneration: 4n,
|
||||
}),
|
||||
).toThrow(/continuity/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computer-use live smoke AX proof', () => {
|
||||
test('wait predicates reject transiently missing captures', () => {
|
||||
expect(hasFreshScreenshot(state())).toBe(true)
|
||||
expect(hasFreshScreenshot(state({ screenshot: undefined }))).toBe(false)
|
||||
expect(hasFreshScreenshot(state({
|
||||
screenshot: { base64: 'not-a-png', width: 1, height: 1 },
|
||||
}))).toBe(false)
|
||||
})
|
||||
|
||||
test('derives an opaque editable handle from raw element metadata plus rendered generation', () => {
|
||||
expect(findEditableHandle(state(), 'CC_HAHA_SMOKE_STABLE_TOKEN')).toBe(
|
||||
'g8:7',
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects ambiguous or non-settable editable elements', () => {
|
||||
expect(() =>
|
||||
findEditableHandle(
|
||||
state({
|
||||
elements: [
|
||||
{
|
||||
index: 7,
|
||||
role: 'AXTextArea',
|
||||
settable: false,
|
||||
value: 'CC_HAHA_SMOKE_STABLE_TOKEN',
|
||||
},
|
||||
],
|
||||
}),
|
||||
'CC_HAHA_SMOKE_STABLE_TOKEN',
|
||||
),
|
||||
).toThrow(/exactly one/i)
|
||||
|
||||
expect(() =>
|
||||
findEditableHandle(
|
||||
state({
|
||||
elements: [
|
||||
{
|
||||
index: 7,
|
||||
role: 'AXTextArea',
|
||||
settable: true,
|
||||
value: 'CC_HAHA_SMOKE_STABLE_TOKEN',
|
||||
},
|
||||
{
|
||||
index: 8,
|
||||
role: 'AXTextField',
|
||||
settable: true,
|
||||
value: 'CC_HAHA_SMOKE_STABLE_TOKEN',
|
||||
},
|
||||
],
|
||||
axText:
|
||||
'g8:7 text area CC_HAHA_SMOKE_STABLE_TOKEN\ng8:8 text field CC_HAHA_SMOKE_STABLE_TOKEN',
|
||||
}),
|
||||
'CC_HAHA_SMOKE_STABLE_TOKEN',
|
||||
),
|
||||
).toThrow(/exactly one/i)
|
||||
})
|
||||
|
||||
test('requires the exact no-change header and a changed diff with a real screenshot', () => {
|
||||
expect(hasExactNoChangeState(state({
|
||||
axText:
|
||||
'There has been no change in the accessibility tree for Window: "smoke-fixture.txt".',
|
||||
}))).toBe(true)
|
||||
expect(hasExactNoChangeState(state({
|
||||
axText:
|
||||
'The following is a diff from the previous accessibility tree for Window: "smoke-fixture.txt".',
|
||||
}))).toBe(false)
|
||||
|
||||
expect(() =>
|
||||
assertNoChangeState(
|
||||
state({
|
||||
axText:
|
||||
'There has been no change in the accessibility tree for Window: "smoke-fixture.txt".',
|
||||
}),
|
||||
),
|
||||
).not.toThrow()
|
||||
|
||||
expect(() =>
|
||||
assertNoChangeState(state({ axText: 'g8:0 standard window' })),
|
||||
).toThrow(/no-change/i)
|
||||
|
||||
expect(() =>
|
||||
assertChangedState(
|
||||
state({
|
||||
axText:
|
||||
'The following is a diff from the previous accessibility tree for Window: "smoke-fixture.txt" with ~ and + representing changed and added elements, respectively. Removed elements are summarized by ID range.\n~\tg8:7 text area MUTATED',
|
||||
}),
|
||||
'MUTATED',
|
||||
),
|
||||
).not.toThrow()
|
||||
|
||||
expect(() =>
|
||||
assertChangedState(
|
||||
state({
|
||||
axText:
|
||||
'The following is a diff from the previous accessibility tree for Window: "smoke-fixture.txt" with ~ and + representing changed and added elements, respectively. Removed elements are summarized by ID range.\n~\tg8:7 text area MUTATED',
|
||||
screenshot: { base64: '', width: 0, height: 0 },
|
||||
}),
|
||||
'MUTATED',
|
||||
),
|
||||
).toThrow(/screenshot/i)
|
||||
})
|
||||
|
||||
test('rejects fake PNG text and reused mutation screenshots', () => {
|
||||
expect(() =>
|
||||
assertNoChangeState(
|
||||
state({
|
||||
axText:
|
||||
'There has been no change in the accessibility tree for Window: "smoke-fixture.txt".',
|
||||
screenshot: { base64: 'a'.repeat(128), width: 1, height: 1 },
|
||||
}),
|
||||
),
|
||||
).toThrow(/PNG|screenshot/i)
|
||||
|
||||
expect(() => assertScreenshotChanged(state(), state())).toThrow(/reused/i)
|
||||
expect(() =>
|
||||
assertScreenshotChanged(
|
||||
state(),
|
||||
state({ screenshot: { base64: PNG_TWO, width: 1, height: 1 } }),
|
||||
),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
test('accepts only an authoritative stale-handle failure', () => {
|
||||
expect(() =>
|
||||
assertStaleHandleFailure(
|
||||
new Error(
|
||||
'Snapshot handle g9:4 is stale. Re-query the latest state with get_app_state before sending more actions.',
|
||||
),
|
||||
),
|
||||
).not.toThrow()
|
||||
|
||||
expect(() =>
|
||||
assertStaleHandleFailure(new Error('Accessibility permission is required')),
|
||||
).toThrow(/not a stale-handle/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computer-use live smoke cleanup proof', () => {
|
||||
test('requires socket, pidfile, owned daemon, and held input to be gone', () => {
|
||||
expect(() =>
|
||||
assertCleanupEvidence({
|
||||
daemonSocketExists: false,
|
||||
daemonPidfileExists: false,
|
||||
daemonProcessStillMatches: false,
|
||||
inputBefore: initialSystemSnapshot.input,
|
||||
inputAfter: initialSystemSnapshot.input,
|
||||
}),
|
||||
).not.toThrow()
|
||||
|
||||
expect(() =>
|
||||
assertCleanupEvidence({
|
||||
daemonSocketExists: true,
|
||||
daemonPidfileExists: false,
|
||||
daemonProcessStillMatches: false,
|
||||
inputBefore: initialSystemSnapshot.input,
|
||||
inputAfter: initialSystemSnapshot.input,
|
||||
}),
|
||||
).toThrow(/socket/i)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.')
|
||||
|
||||
Reference in New Issue
Block a user