#!/usr/bin/env node /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ // Build the maka-cu executor from source and pin the result. // // cua-driver arrived as a signed upstream release, so preparing it meant // downloading a tarball and checking it against a digest someone else produced. // maka-cu is ours: there is no third party to download from, and the artifact // that matters is the one this machine just built. So this script builds it, // records what it built, and writes both into apps/desktop/bundled-tools.json — // the same manifest the host reads, so the running app can only ever spawn a // binary whose bytes match the ones recorded here. // // Nothing about this is a substitute for signing. `distributionReady` stays // false until a notarized artifact exists, and the host refuses to use an // unready entry in a packaged build — a development build is the only place // this binary runs. // // node scripts/computer-use.mjs prepare // MAKA_CU_SOURCE=/path/to/maka-cu node scripts/computer-use.mjs prepare // MAKA_CU_SOURCE_BRANCH=maka/base node scripts/computer-use.mjs prepare import { execFileSync, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { chmodSync, copyFileSync, existsSync, mkdirSync, openSync, readSync, closeSync, readFileSync, statSync, writeFileSync, } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveMakaCuSourceBranch } from './prepare-provenance.mjs'; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); const manifestPath = join(repoRoot, 'apps', 'desktop', 'bundled-tools.json'); const destination = join(repoRoot, 'apps', 'desktop', 'resources', 'bin', 'maka-cu'); /** Universal and single-architecture Mach-O, both byte orders. */ const MACH_O_MAGICS = new Set([ 0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xcafebabe, 0xbebafeca, ]); function fail(message) { process.stderr.write(`computer-use prepare: ${message}\n`); process.exit(1); } function sourcePath() { const explicit = process.env.MAKA_CU_SOURCE; if (explicit) return resolve(explicit); // A sibling checkout is the layout this repo is developed in; naming it here // beats every contributor discovering the variable. return resolve(repoRoot, '..', 'maka-cu'); } function assertMachO(path) { const fd = openSync(path, 'r'); try { const head = Buffer.alloc(4); if (readSync(fd, head, 0, 4, 0) !== 4) fail(`${path} is too short to be an executable.`); if (!MACH_O_MAGICS.has(head.readUInt32BE(0)) && !MACH_O_MAGICS.has(head.readUInt32LE(0))) { fail(`${path} is not a Mach-O executable.`); } } finally { closeSync(fd); } } function git(source, args) { return execFileSync('git', ['-C', source, ...args], { encoding: 'utf8' }).trim(); } function sourceBranch(source) { const currentBranch = git(source, ['rev-parse', '--abbrev-ref', 'HEAD']); const remoteBranches = git(source, [ 'for-each-ref', '--points-at', 'HEAD', '--format=%(refname:short)', 'refs/remotes', ]).split('\n'); try { return resolveMakaCuSourceBranch({ currentBranch, remoteBranches, explicitBranch: process.env.MAKA_CU_SOURCE_BRANCH, }); } catch (error) { fail( `${error instanceof Error ? error.message : String(error)}. ` + 'Set MAKA_CU_SOURCE_BRANCH to the source branch this commit belongs to.', ); } } const source = sourcePath(); if (!existsSync(join(source, 'Package.swift'))) { fail(`no Swift package at ${source}. Set MAKA_CU_SOURCE to the maka-cu checkout.`); } // A dirty tree would pin bytes to a commit that does not describe them. const status = git(source, ['status', '--porcelain']); if (status && process.env.MAKA_CU_ALLOW_DIRTY !== '1') { fail( `${source} has uncommitted changes, so the recorded commit would not describe the ` + 'binary. Commit them, or set MAKA_CU_ALLOW_DIRTY=1 for a throwaway build.', ); } process.stderr.write(`computer-use prepare: building ${source}\n`); execFileSync('swift', ['build', '-c', 'release', '--package-path', source], { stdio: 'inherit' }); const built = execFileSync( 'swift', ['build', '-c', 'release', '--package-path', source, '--show-bin-path'], { encoding: 'utf8' }, ).trim(); const binary = join(built, 'OpenComputerUse'); if (!existsSync(binary)) fail(`swift build produced no ${binary}.`); assertMachO(binary); mkdirSync(dirname(destination), { recursive: true }); copyFileSync(binary, destination); chmodSync(destination, 0o755); // Signing rewrites the file, so the digest is taken after it, from the copy the // host will actually spawn — hashing the build output first pins bytes nobody // runs. // // Signing with a stable identity is not only about distribution. TCC keys an // ad-hoc binary by its code directory hash, so every rebuild is a new program // to macOS and Accessibility has to be granted again; a signed one is // identified by its designated requirement, which survives a rebuild. Any // identity does that, including a self-signed one: // // MAKA_CU_SIGN_IDENTITY="Codex++ Local Signing" node scripts/computer-use.mjs prepare // // `security find-identity -v -p codesigning` lists what this machine has. const identity = process.env.MAKA_CU_SIGN_IDENTITY; if (identity) { process.stderr.write(`computer-use prepare: signing with ${identity}\n`); execFileSync( 'codesign', ['--force', '--options', 'runtime', '--timestamp=none', '--sign', identity, destination], { stdio: 'inherit' }, ); } const binarySha256 = createHash('sha256').update(readFileSync(destination)).digest('hex'); /** * What the binary is actually signed with, read rather than asserted. * * `swift build` linker-signs ad-hoc, which runs fine locally — a file built on * this machine carries no quarantine flag, so Gatekeeper never looks at it, and * TCC attributes the executor to whoever spawned it. It is distribution that * needs more: notarization requires every executable in the bundle to be * Developer ID signed with the hardened runtime, and one ad-hoc helper fails the * whole app. * * So this reports what it found. A developer who does hold a certificate signs * the binary before running this, and the manifest records that — rather than * making them hand-edit the field this script would otherwise overwrite. */ function signatureOf(path) { // codesign writes its whole report to stderr and exits 0 for a signed file, // so reading stdout, or only reading stderr on failure, reports every signed // binary as unsigned. It did: an ad-hoc binary came back as `none`. const probe = spawnSync('codesign', ['-dv', '--verbose=4', path], { encoding: 'utf8' }); const text = `${probe.stdout ?? ''}${probe.stderr ?? ''}`; if (!text.trim()) return { signature: 'none', hardenedRuntime: false }; const team = /^TeamIdentifier=(.+)$/m.exec(text)?.[1]?.trim(); const authority = /^Authority=(.+)$/m.exec(text)?.[1]?.trim(); const flags = /^CodeDirectory .*flags=0x[0-9a-f]+\(([^)]*)\)/m.exec(text)?.[1] ?? ''; const hardenedRuntime = flags.includes('runtime'); if (authority?.startsWith('Developer ID Application')) { return { signature: 'developer-id', ...(team && team !== 'not set' ? { teamIdentifier: team } : {}), hardenedRuntime, authority, }; } if (flags.includes('adhoc')) return { signature: 'adhoc', hardenedRuntime }; if (authority) return { signature: 'other', authority, hardenedRuntime }; return { signature: 'none', hardenedRuntime: false }; } /** Stapled means the notarization ticket travels with the file, offline. */ function isStapled(path) { try { execFileSync('xcrun', ['stapler', 'validate', path], { stdio: 'ignore' }); return true; } catch { return false; } } const signing = signatureOf(destination); const stapled = isStapled(destination); // Every condition, or none of it. Distribution is the one place a partial // answer is worse than a refusal: an ad-hoc helper inside a notarized app is // not a smaller problem than an unsigned one, it fails the same way. const distributionReady = signing.signature === 'developer-id' && signing.hardenedRuntime === true && stapled; const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); manifest.makaCu = { repo: 'maka-agent/maka-cu', branch: sourceBranch(source), commit: git(source, ['rev-parse', 'HEAD']), expectedProtocolVersion: 'maka.cu/2', binaryName: 'maka-cu', binarySizeBytes: statSync(destination).size, binarySha256, buildProvenance: 'local-source-build', ...signing, notarization: stapled ? 'stapled' : 'missing', distributionReady, }; writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); process.stderr.write( `computer-use prepare: ${destination}\n` + `computer-use prepare: sha256 ${binarySha256}\n` + `computer-use prepare: commit ${manifest.makaCu.commit} on ${manifest.makaCu.branch}\n` + `computer-use prepare: signature ${signing.signature}` + `${signing.hardenedRuntime ? ' + hardened runtime' : ''}` + `, notarization ${manifest.makaCu.notarization}` + `, distributionReady ${distributionReady}\n` + (distributionReady ? '' : 'computer-use prepare: development only — a packaged build will refuse this entry.\n') + (identity ? '' : 'computer-use prepare: unsigned, so macOS identifies it by its code directory hash — ' + 'Accessibility has to be granted again after every rebuild. Set ' + 'MAKA_CU_SIGN_IDENTITY to a codesigning identity (a self-signed one is enough) ' + 'to make the grant survive.\n'), );