blob: a235b20956c94a6d95c6a66baa92b00384cc79aa [file]
#!/usr/bin/env node
// 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'),
);