diff --git a/package-lock.json b/package-lock.json index 06cebff5..d5a341b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gajae-app", - "version": "2.0.0-beta.9", + "version": "2.0.0-beta.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gajae-app", - "version": "2.0.0-beta.9", + "version": "2.0.0-beta.10", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index b55d6462..16a85aa8 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "gajae-app", "private": true, - "version": "2.0.0-beta.9", - "desktopVersion": "0.2.3", + "version": "2.0.0-beta.10", + "desktopVersion": "0.2.4", "productName": "Gajae Code App", "description": "A self-hosted web and desktop interface for GJC", "type": "module", diff --git a/scripts/release/MANUAL-RELEASE.md b/scripts/release/MANUAL-RELEASE.md new file mode 100644 index 00000000..52436ca2 --- /dev/null +++ b/scripts/release/MANUAL-RELEASE.md @@ -0,0 +1,188 @@ +# Draft-first manual, updater-disabled release + +This is a separate, explicit release lane for a signed/notarized **manual +installation only** app. It does not complete or claim auto-install acceptance. +`local-release.mjs` and CI retain their updater key, archive/signature/manifest, +eight canonical assets, and full archive/DMG equivalence requirements unchanged. +Do not supply fabricated updater keys or publish updater metadata for this lane. + +The parent owns source freeze, accepted builds from that exact commit, signing, +notarization, release notes, draft creation/upload, and actual publication. +Runtime, standard packaged-server smoke, separate data-survival smoke, GUI and +Linux acceptance remain parent prerequisites. For a test prerelease, record the +actual macOS version tested separately from the declared loader floor. Do not +claim macOS 13 execution from loader stamps or a macOS 26 test; real minimum-OS +qualification remains a separately disclosed pending result. +Independent SHA-256 pins identify the operator's accepted local artifacts; +they are not reproducible-build provenance. Never derive expected hashes from +the remote draft being checked. + +## Exact assets + +For beta.10, the required assets are: + +- `gajae-app-desktop-2.0.0-beta.10-macos-arm64.dmg` +- `gajae-app-desktop-2.0.0-beta.10-macos-arm64.dmg.sha256` +- `gajae-app-server-2.0.0-beta.10-linux-x64-node22.tar.gz` +- `gajae-app-server-2.0.0-beta.10-linux-x64-node22.tar.gz.sha256` + +Optional payloads are **only** the exact versioned +`gajae-app-desktop-2.0.0-beta.10-linux-x64.deb` and +`gajae-app-desktop-2.0.0-beta.10-linux-x64.AppImage`, each with its own independent +`--asset` pin and `.sha256` sidecar. Their installer/platform acceptance is +separate; this verifier checks their hashes and sidecars only. + +Every checksum is exactly `LOWERCASE_SHA256 PAYLOAD_BASENAME` on one line. +Updater `.app.tar.gz`, `.sig`, `desktop-update.json`, unlisted payloads, +unlisted sidecars, duplicates, incomplete uploads and missing assets all block. +Existing assets are never removed or replaced to make the set pass. + +## Verify the local DMG before parent-owned smoke/GUI acceptance + +After signing/notarization/stapling are complete, use the helper with the +explicit manual flag. A separately versioned verifier-only correction does +not change the frozen app/Linux source or release-tag target. For beta.10 those +remain `a6b06a25bdcc8fc7e4980175fc757d93b28becc5`; record the verifier revision +separately, and read identity/config/manifest pins from that frozen source. + +```js +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, realpath } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { verifyMacosRelease } from './scripts/release/local-release-macos.mjs'; + +// Verify these source files against the frozen release commit first; +// verifier-only HEAD may differ and must be recorded separately. +const source = JSON.parse(await readFile('package.json', 'utf8')); +const config = JSON.parse(await readFile('src-tauri/tauri.conf.json', 'utf8')); +const runtimeManifestSha256 = createHash('sha256') + .update(await readFile('server/gjc-runtime-manifest.json')).digest('hex'); +const root = await realpath(await mkdtemp(join(tmpdir(), 'gajae-manual-acceptance-'))); +const verified = await verifyMacosRelease({ + dmg: process.env.DMG, // Absolute path to the final accepted candidate image. + root, // Fresh, owner-only, outside any checkout/node_modules ancestor. + teamId: '5987KT43TJ', + version: source.version, // 2.0.0-beta.10 + desktopVersion: source.desktopVersion, // 0.2.4 + minimumSystemVersion: config.bundle.macOS.minimumSystemVersion, // 13.0 + manualDisabled: true, + runtimeManifestSha256, +}); +console.log(JSON.stringify({ root, copiedApp: verified.copiedApp, + buildInfo: verified.buildInfo, deployment: verified.deployment }, null, 2)); +``` + +Do not pass `updaterArchivePath` with `manualDisabled: true`; even a null or +empty archive option is rejected. Omitting the manual flag **still requires** +a verified updater archive and complete archive equivalence. + +The helper verifies the DMG and both mounted/copied apps: expected Developer ID +team, hardened app signatures, staples, Gatekeeper, package and desktop versions, +arm64 executables, and the pinned macOS minimum. Every inventoried regular file +is checked for Mach-O magic (including universal binaries and extensionless +helpers). macOS binaries must have supported macOS loader stamps no newer than +the declared minimum, including any MACOS binary placed in an iOS folder. +The sole non-Mac resource exception is beneath the canonical +`Contents/Resources/resources/server-payload/node_modules/` path: +`bare-*/prebuilds/ios-(arm64|x64)(-simulator)?/*.bare`. Each discovered resource +is still inspected with vtool and must have structurally valid, uniform `IOS` +stamps for device folders or `IOSSIMULATOR` stamps for simulator folders. +Unknown platforms, malformed/duplicate/mixed evidence, mismatched folders and +foreign binaries anywhere else fail. Named required runtimes/modules remain required. +`deployment.nonMacResourceCount` and `deployment.nonMacResources` record the +inspected exclusions (path, platform and minimum versions), separately from +macOS-only `deployment.stamps` and `maximumStampedMinimumSystemVersion`. +These resource exclusions are not macOS runtime qualification or a signature exemption. +Full mounted/copy inventories must match in bytes, modes and internal symlinks. + +Only **after all copied-app validation** does the helper execute +`Contents/MacOS/gajae-app-desktop --desktop-build-info`, directly and without +UI/browser IPC. Timeout: 10 seconds. Stdout: an exclusively created, owner-only +file capped at 4 KiB while streaming. Stderr must be empty (the shared command +transport also has an 8 MiB total diagnostic-stream cap). The diagnostic must +exit successfully with exactly this eight-field JSON schema, without extras, +duplicates or missing fields: + +```json +{ + "schemaVersion": 1, + "packageName": "gajae-app", + "productVersion": "2.0.0-beta.10", + "desktopVersion": "0.2.4", + "debug": false, + "updateMode": "disabled", + "runtimeManifestSha256": "", + "payloadRuntimeManifestSha256": "" +} +``` + +Finalization signs nested native modules and restamps their runtime manifests, +then rebuilds the desktop with that exact finalized manifest digest before the +outer app signature. The original source digest stays separate. The two values +must match their respective pinned source and verified signed payload bytes; +runtime startup still enforces an exact compiled payload digest (no fallback). + +These are compile-time constants from the early CLI path, before app, QA, +profile, updater or lifecycle initialization. No absence-of-assets inference +is accepted. The copied app inventory is checked again after the diagnostic. + +On success, the image is detached and `copiedApp` remains for the parent's +separate standard/data-survival/GUI acceptance. The API caller owns the temp +directory. If detachment cannot be confirmed, the helper throws with +`preserveDirectory: true` and reports the directory: inspect/detach before any +cleanup. Never recursively remove a directory that may contain a mounted image. + +## Existing draft verification and explicit publication + +The parent creates one unpublished draft with the exact full commit as its +target, reviewed manual/updater-disabled notes, correct prerelease status and +the accepted asset set. Do not use a branch target or clobber existing assets. +The manual CLI never signs, notarizes, installs, creates drafts, uploads files, +reads signing keys, or changes tags directly. It uses the parent's existing +authenticated `gh` session only when the parent invokes it for verification. + +From the frozen release checkout, supply explicit reviewed values: + +```sh +verify_args=( + --repo devswha/gajae-code-app --draft-id "$DRAFT_ID" + --tag v2.0.0-beta.10 --commit "$RELEASE_COMMIT" --team-id 5987KT43TJ + --asset "gajae-app-desktop-2.0.0-beta.10-macos-arm64.dmg=$DMG_SHA" + --asset "gajae-app-server-2.0.0-beta.10-linux-x64-node22.tar.gz=$SERVER_SHA" +) +node scripts/release/manual-release.mjs "${verify_args[@]}" +# Only after acceptance and review, with other draft/tag publishers stopped: +node scripts/release/manual-release.mjs "${verify_args[@]}" --publish +``` + +The first command is read-only on GitHub and reports `verified-draft`. The +second re-downloads and repeats every check before one numeric release-ID +`gh api ... --method PATCH --field draft=false`. Publication may cause GitHub +to create an absent tag at the exact pinned target; an existing lightweight or +annotated tag must already resolve to that commit. Title, notes, prerelease +status and the exact asset snapshot must be preserved in the response. + +Every invocation verifies remote commit/package identity, raw source manifest +SHA-256, full independently pinned downloads and checksum sidecars, server +archive root package identity, strict Mac validation and positive disabled +binary evidence. Complete published desktop-version history must be proven, +and the candidate must advance beyond the history and the existing `0.2.3` +baseline. After local validation, private download bytes, history, draft +metadata/paginated assets, and tag are checked again. Missing or changed +evidence blocks publication. Download snapshots use exclusive file creation +inside private temp directories, inspected byte-count streaming caps and +read-only payload files. Existing shared payload/metadata limits apply. + +CLI temp downloads and copied apps are cleaned normally; use the local API +above for a retained acceptance copy. API exports for orchestration/tests are +`manualReleaseOptions(values)`, `validateManualDraft(release, options)` and +`processManualRelease(options)`. No prior report bypasses verification. + +Exit codes: 0 for success, 2 for invalid CLI arguments, 1 for verification or +publication errors. Before publication, errors report `blocked`. Any failure +after the publication request starts reports `publication-outcome-unknown`, +including unexpected responses and cleanup failures. Inspect the exact release +ID before any retry. There is no automatic retry, rollback or asset deletion. +The last recheck and PATCH are not atomic: single-publisher/no-concurrent-edits +discipline is required. diff --git a/scripts/release/finalize-macos-app.mjs b/scripts/release/finalize-macos-app.mjs index 71857f4d..af6ccd56 100644 --- a/scripts/release/finalize-macos-app.mjs +++ b/scripts/release/finalize-macos-app.mjs @@ -1,29 +1,19 @@ #!/usr/bin/env node import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { existsSync, openSync, closeSync, readSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, openSync, closeSync, readSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { readAppRuntimeManifests, rebuildSignedMacosDesktop } from './rebuild-signed-macos-desktop.mjs'; + const rootDir = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); const defaultApp = join( rootDir, 'src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Gajae Code App.app', ); -const appIndex = process.argv.indexOf('--app'); -const appPath = appIndex >= 0 && process.argv[appIndex + 1] ? process.argv[appIndex + 1] : defaultApp; -const entitlements = join(rootDir, 'src-tauri/entitlements.plist'); -/** - * Ad-hoc is the default so unsigned local builds keep working. Notarization - * requires a Developer ID identity and a secure timestamp on every Mach-O in - * the bundle, which ad-hoc signatures cannot carry. - */ -const identity = process.env.APPLE_SIGNING_IDENTITY?.trim() || '-'; -const adhoc = identity === '-'; -const timestamp = adhoc ? '--timestamp=none' : '--timestamp'; - -function run(command, args, { combined = false } = {}) { - const result = spawnSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); +function run(command, args, { combined = false, cwd, env } = {}) { + const result = spawnSync(command, args, { cwd, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 16 * 1024 * 1024 }); if (result.status !== 0) { throw new Error(`${command} ${args.join(' ')} failed (${result.status}): ${result.stderr || result.stdout}`); } @@ -67,7 +57,7 @@ function executableClosure(directory) { return files; } -function signArguments(filePath) { +function signArguments(filePath, { identity, timestamp, entitlements }) { const name = filePath.slice(filePath.lastIndexOf('/') + 1); const hardened = ['--force', '--sign', identity, timestamp, '--options', 'runtime']; return NATIVE_HOSTS.has(name) ? [...hardened, '--entitlements', entitlements] : hardened; @@ -140,59 +130,81 @@ function restampRuntimeManifests(payloadDir, manifestPaths) { return [...new Set(restamped)].sort(); } -if (process.platform !== 'darwin' || process.arch !== 'arm64') { - throw new Error(`macOS app finalization requires darwin-arm64; received ${process.platform}-${process.arch}.`); -} -if (!existsSync(appPath)) throw new Error(`App bundle not found: ${appPath}`); -if (!existsSync(entitlements)) throw new Error(`Entitlements file not found: ${entitlements}`); -if (!adhoc && !run('security', ['find-identity', '-v', '-p', 'codesigning']).includes(identity)) { - throw new Error(`Signing identity is not available in the keychain: ${identity}`); -} - -const resources = join(appPath, 'Contents', 'Resources'); -const payloadDir = join(resources, 'resources', 'server-payload'); -const sidecar = join(appPath, 'Contents', 'MacOS', 'gajae-app-server'); -const desktop = join(appPath, 'Contents', 'MacOS', 'gajae-app-desktop'); -const nestedExecutables = executableClosure(resources).sort(); -const manifestPaths = assertManifestProvenance(payloadDir); +/** Ad-hoc remains the default; Developer ID builds timestamp every signature. */ +export async function finalizeMacosApp({ + appPath = defaultApp, sourceRoot = rootDir, inheritedEnv = process.env, + execute = run, resolveTargetDirectory, platform = process.platform, arch = process.arch, +} = {}) { + const entitlements = join(sourceRoot, 'src-tauri/entitlements.plist'); + const identity = inheritedEnv.APPLE_SIGNING_IDENTITY?.trim() || '-'; + const adhoc = identity === '-'; + const timestamp = adhoc ? '--timestamp=none' : '--timestamp'; + const invoke = (command, args, options = {}) => execute(command, args, { env: inheritedEnv, ...options }); + if (platform !== 'darwin' || arch !== 'arm64') { + throw new Error(`macOS app finalization requires darwin-arm64; received ${platform}-${arch}.`); + } + if (!existsSync(appPath)) throw new Error(`App bundle not found: ${appPath}`); + if (!existsSync(entitlements)) throw new Error(`Entitlements file not found: ${entitlements}`); + if (!adhoc && !(await invoke('security', ['find-identity', '-v', '-p', 'codesigning'])).includes(identity)) { + throw new Error(`Signing identity is not available in the keychain: ${identity}`); + } -for (const executable of nestedExecutables) { - run('codesign', [...signArguments(executable), executable]); -} + const resources = join(appPath, 'Contents', 'Resources'); + const payloadDir = join(resources, 'resources', 'server-payload'); + const sidecar = join(appPath, 'Contents', 'MacOS', 'gajae-app-server'); + const desktop = join(appPath, 'Contents', 'MacOS', 'gajae-app-desktop'); + const nestedExecutables = executableClosure(resources).sort(); + await readAppRuntimeManifests(appPath); + const manifestPaths = assertManifestProvenance(payloadDir); -const restamped = restampRuntimeManifests(payloadDir, manifestPaths); - -run('codesign', [ - '--force', '--sign', identity, timestamp, '--options', 'runtime', - '--entitlements', entitlements, sidecar, -]); -run('codesign', [ - '--force', '--sign', identity, timestamp, '--options', 'runtime', - '--entitlements', entitlements, appPath, -]); - -for (const executable of nestedExecutables) run('codesign', ['--verify', '--strict', executable]); -run('codesign', ['--verify', '--strict', sidecar]); -run('codesign', ['--verify', '--strict', desktop]); -run('codesign', ['--verify', '--deep', '--strict', appPath]); -run('lipo', [desktop, '-verify_arch', 'arm64']); -run('lipo', [sidecar, '-verify_arch', 'arm64']); - -const sidecarEntitlements = run('codesign', ['-d', '--entitlements', ':-', sidecar], { combined: true }); -for (const entitlement of [ - 'com.apple.security.cs.allow-jit', - 'com.apple.security.cs.allow-unsigned-executable-memory', - 'com.apple.security.cs.disable-library-validation', -]) { - if (!sidecarEntitlements.includes(`${entitlement}`)) { - throw new Error(`Sidecar is missing required entitlement: ${entitlement}`); + for (const executable of nestedExecutables) { + await invoke('codesign', [...signArguments(executable, { identity, timestamp, entitlements }), executable]); + } + await invoke('codesign', [ + '--force', '--sign', identity, timestamp, '--options', 'runtime', + '--entitlements', entitlements, sidecar, + ]); + + const restamped = restampRuntimeManifests(payloadDir, manifestPaths); + const rebuilt = await rebuildSignedMacosDesktop({ rootDir: sourceRoot, appPath, inheritedEnv }, + { execute, resolveTargetDirectory }); + + // All nested signing/restamping is complete. Seal only the outer app now. + await invoke('codesign', [ + '--force', '--sign', identity, timestamp, '--options', 'runtime', + '--entitlements', entitlements, appPath, + ]); + + for (const executable of nestedExecutables) await invoke('codesign', ['--verify', '--strict', executable]); + await invoke('codesign', ['--verify', '--strict', sidecar]); + await invoke('codesign', ['--verify', '--strict', desktop]); + await invoke('codesign', ['--verify', '--deep', '--strict', appPath]); + await invoke('lipo', [desktop, '-verify_arch', 'arm64']); + await invoke('lipo', [sidecar, '-verify_arch', 'arm64']); + + const sidecarEntitlements = await invoke('codesign', ['-d', '--entitlements', ':-', sidecar], { combined: true }); + for (const entitlement of [ + 'com.apple.security.cs.allow-jit', + 'com.apple.security.cs.allow-unsigned-executable-memory', + 'com.apple.security.cs.disable-library-validation', + ]) { + if (!sidecarEntitlements.includes(`${entitlement}`)) { + throw new Error(`Sidecar is missing required entitlement: ${entitlement}`); + } } + + return { + ok: true, + app: appPath, + nestedExecutables: nestedExecutables.map(filePath => relative(appPath, filePath)), + restampedNatives: restamped, + payloadRuntimeManifestSha256: rebuilt.payloadRuntimeManifestSha256, + signature: adhoc ? 'adhoc' : identity, + }; } -console.log(JSON.stringify({ - ok: true, - app: appPath, - nestedExecutables: nestedExecutables.map(filePath => relative(appPath, filePath)), - restampedNatives: restamped, - signature: adhoc ? 'adhoc' : identity, -}, null, 2)); +if (process.argv[1] && realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1])) { + const appIndex = process.argv.indexOf('--app'); + const appPath = appIndex >= 0 && process.argv[appIndex + 1] ? process.argv[appIndex + 1] : defaultApp; + console.log(JSON.stringify(await finalizeMacosApp({ appPath }), null, 2)); +} diff --git a/scripts/release/finalize-macos-app.test.mjs b/scripts/release/finalize-macos-app.test.mjs new file mode 100644 index 00000000..f8a039e9 --- /dev/null +++ b/scripts/release/finalize-macos-app.test.mjs @@ -0,0 +1,208 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { chmod, link, mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; + +import { finalizeMacosApp } from './finalize-macos-app.mjs'; +import { readAppRuntimeManifests, rebuildSignedMacosDesktop } from './rebuild-signed-macos-desktop.mjs'; + +const hash = bytes => createHash('sha256').update(bytes).digest('hex'); +const native = text => Buffer.concat([Buffer.from('cffaedfe', 'hex'), Buffer.from(text)]); +const target = 'aarch64-apple-darwin'; + +async function fixture(t, { externalTarget = false } = {}) { + const fixtureRoot = await realpath(await mkdtemp(join(tmpdir(), 'gajae-signed-rebuild-test-'))); + t.after(() => rm(fixtureRoot, { recursive: true, force: true })); + const rootDir = externalTarget ? join(fixtureRoot, 'source') : fixtureRoot; + const appPath = join(rootDir, 'candidate/Gajae Code App.app'); + const payload = join(appPath, 'Contents/Resources/resources/server-payload'); + const desktop = join(appPath, 'Contents/MacOS/gajae-app-desktop'); + const targetDir = join(fixtureRoot, 'private cargo output'); + const rebuiltDesktop = join(targetDir, target, 'release/gajae-app-desktop'); + const addon = join(payload, 'node_modules/native/addon.node'); + const manifest = { schemaVersion: 1, platforms: { 'darwin-arm64': { + files: [{ package: 'native', path: 'addon.node', sha256: hash(native('installed native')) }], + } } }; + const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`); + const manifests = ['server', 'dist-server/server'].map(location => join(payload, location, 'gjc-runtime-manifest.json')); + const sourceManifests = ['server', 'src-tauri/resources/server-payload/server', 'src-tauri/resources/server-payload/dist-server/server'] + .map(location => join(rootDir, location, 'gjc-runtime-manifest.json')); + const files = [ + [addon, native('installed native')], [desktop, native('old shell')], + [join(appPath, 'Contents/MacOS/gajae-app-server'), native('server')], + [join(rootDir, 'src-tauri/entitlements.plist'), ''], + [join(rootDir, 'src-tauri/Cargo.toml'), '[package]\nname="fixture"\nversion="0.2.4"\n'], + ...[...manifests, ...sourceManifests].map(path => [path, manifestBytes]), + ]; + for (const [path, bytes] of files) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, bytes, { mode: 0o755 }); + } + await mkdir(dirname(rebuiltDesktop), { recursive: true }); + const inheritedEnv = { + CARGO_TARGET_DIR: externalTarget ? targetDir : '../private cargo output', + RUSTUP_TOOLCHAIN: '1.85.1', MACOSX_DEPLOYMENT_TARGET: '13.0', + GJC_UPDATE_MODE: 'qa', GJC_UPDATE_QA_ROOT: '/fixture/private-qa', + GJC_UPDATE_PUBKEY: 'fixture-public-key', GJC_UPDATE_FEED_ORIGIN: 'https://fixture.invalid', + TAURI_CONFIG: '{"version":"0.2.4"}', RUSTFLAGS: '-C target-cpu=apple-m1', + GJC_SIGNED_RUNTIME_MANIFEST_SHA256: 'stale override', + APPLE_SIGNING_IDENTITY: 'Developer ID Application: Fixture (AB12345678)', + }; + const state = { rootDir, appPath, payload, desktop, addon, manifests, sourceManifests, manifestBytes, + inheritedEnv, targetDir, rebuiltDesktop, calls: [], rebuiltBytes: native('rebuilt shell') }; + state.resolveTargetDirectory = async (root, env) => { + state.calls.push({ command: 'metadata', env }); + assert.equal(root, rootDir); + assert.equal(env.CARGO_TARGET_DIR, inheritedEnv.CARGO_TARGET_DIR); + assert.equal(env.RUSTUP_TOOLCHAIN, '1.85.1'); + return state.invalidTarget ?? targetDir; + }; + state.execute = async (command, args, options) => { + state.calls.push({ command, args, options }); + if (command === 'security') return inheritedEnv.APPLE_SIGNING_IDENTITY; + if (command === 'cargo') { + assert.deepEqual(args, ['build', '--manifest-path', join(rootDir, 'src-tauri/Cargo.toml'), + '--locked', '--release', '--target', target, '--features', 'tauri/custom-protocol']); + assert.equal(options.cwd, join(rootDir, 'src-tauri')); + const { bytes } = await readAppRuntimeManifests(appPath); + assert.ok(bytes[0].equals(bytes[1])); + assert.deepEqual(options.env, { ...inheritedEnv, GJC_SIGNED_RUNTIME_MANIFEST_SHA256: hash(bytes[0]) }); + state.atBuild?.(JSON.parse(bytes[0])); + if (state.failBuild) throw new Error('fixture rebuild failure'); + if (!state.skipOutput) await writeFile(rebuiltDesktop, state.rebuiltBytes, { mode: 0o755 }); + await state.afterBuild?.(); + return ''; + } + if (command === 'lipo') { + if (state.failArchitecture) throw new Error('fixture wrong architecture'); + return ''; + } + assert.equal(command, 'codesign', 'No real commands, signing or network is allowed in this suite.'); + if (args.includes('--sign') && args.at(-1) === addon) await writeFile(addon, native('signed native')); + if (args.includes('--sign') && args.at(-1) === appPath) { + assert.deepEqual(await readFile(desktop), state.rebuiltBytes, 'outer signature must seal the rebuilt shell'); + } + if (args[0] === '-d') return ['allow-jit', 'allow-unsigned-executable-memory', 'disable-library-validation'] + .map(name => `com.apple.security.cs.${name}`).join(''); + return ''; + }; + state.finalize = () => finalizeMacosApp({ ...state, sourceRoot: rootDir, platform: 'darwin', arch: 'arm64' }); + state.rebuild = () => rebuildSignedMacosDesktop(state, state); + return state; +} + +test('finalizer signs natives, restamps both manifests, rebuilds/copies shell, then seals app without restamping again', async t => { + const f = await fixture(t); + const originalEnv = { ...f.inheritedEnv }; + f.atBuild = manifest => assert.equal(manifest.platforms['darwin-arm64'].files[0].sha256, hash(native('signed native'))); + const result = await f.finalize(); + const bytes = await readFile(f.manifests[0]); + assert.deepEqual(bytes, await readFile(f.manifests[1])); + assert.equal(result.payloadRuntimeManifestSha256, hash(bytes)); + assert.notEqual(result.payloadRuntimeManifestSha256, hash(f.manifestBytes)); + assert.deepEqual(await readFile(f.desktop), f.rebuiltBytes); + assert.ok((await stat(f.desktop)).mode & 0o111); + for (const path of f.sourceManifests) assert.deepEqual(await readFile(path), f.manifestBytes); + assert.deepEqual(f.inheritedEnv, originalEnv); + const stages = f.calls.filter(call => call.command === 'metadata' || call.command === 'cargo' + || (call.command === 'codesign' && call.args.includes('--sign'))) + .map(call => call.command === 'codesign' ? call.args.at(-1) : call.command); + assert.deepEqual(stages, [f.addon, join(f.appPath, 'Contents/MacOS/gajae-app-server'), 'metadata', 'cargo', f.appPath]); + assert.equal(f.calls.filter(call => call.command === 'codesign' && call.args.includes('--sign') && call.args.at(-1) === f.addon).length, 1); +}); + +test('rebuild failure leaves the existing app executable untouched and never signs the outer app', async t => { + const f = await fixture(t); + f.failBuild = true; + await writeFile(f.rebuiltDesktop, native('stale cached output'), { mode: 0o755 }); + await assert.rejects(f.finalize(), /fixture rebuild failure/); + assert.deepEqual(await readFile(f.desktop), native('old shell')); + assert.ok(!f.calls.some(call => call.command === 'codesign' && call.args.includes('--sign') && call.args.at(-1) === f.appPath)); +}); + +test('native provenance failure prevents signing, rebuilding and restamping', async t => { + const f = await fixture(t); + await writeFile(f.addon, native('foreign bytes')); + await assert.rejects(f.finalize(), /does not match its installed bytes/); + assert.ok(f.calls.every(call => call.command === 'security')); + for (const path of [...f.manifests, ...f.sourceManifests]) assert.deepEqual(await readFile(path), f.manifestBytes); +}); + +test('both manifests are mandatory, nonempty, bounded, and byte-identical before Cargo', async t => { + for (const invalid of ['missing', 'empty', 'oversized', 'different']) { + const f = await fixture(t); + if (invalid === 'missing') await rm(f.manifests[1]); + else await writeFile(f.manifests[1], invalid === 'empty' ? '' : invalid === 'oversized' ? Buffer.alloc(65537) : '{}\n'); + await assert.rejects(f.rebuild()); + assert.deepEqual(f.calls, []); + assert.deepEqual(await readFile(f.desktop), native('old shell')); + } +}); + +test('linked manifests cannot rewrite source or payload outside the passed app', async t => { + for (const kind of ['symlink', 'hardlink', 'parent-symlink']) { + const f = await fixture(t); + await rm(f.manifests[0]); + if (kind === 'parent-symlink') { + await rm(dirname(f.manifests[0]), { recursive: true }); + await symlink(dirname(f.sourceManifests[0]), dirname(f.manifests[0])); + } else if (kind === 'symlink') await symlink(f.sourceManifests[0], f.manifests[0]); + else await link(f.sourceManifests[0], f.manifests[0]); + await assert.rejects(f.finalize(), /inside the passed app|regular app file/); + assert.deepEqual(await readFile(f.sourceManifests[0]), f.manifestBytes); + assert.ok(!f.calls.some(call => call.command === 'cargo' || call.command === 'codesign')); + } +}); + +test('missing, empty, linked, nonexecutable and wrong-architecture rebuilds never replace the desktop', async t => { + for (const invalid of ['missing', 'empty', 'symlink', 'nonexecutable', 'architecture']) { + const f = await fixture(t); + f.skipOutput = true; + if (invalid === 'empty') await writeFile(f.rebuiltDesktop, '', { mode: 0o755 }); + if (invalid === 'symlink') await symlink(f.desktop, f.rebuiltDesktop); + if (invalid === 'nonexecutable' || invalid === 'architecture') { + await writeFile(f.rebuiltDesktop, f.rebuiltBytes, { mode: 0o755 }); + if (invalid === 'nonexecutable') await chmod(f.rebuiltDesktop, 0o600); + else f.failArchitecture = true; + } + await assert.rejects(f.rebuild()); + assert.deepEqual(await readFile(f.desktop), native('old shell')); + } +}); + +test('manifest drift during rebuild and linked copy destinations fail before copy', async t => { + for (const change of ['manifest', 'desktop']) { + const f = await fixture(t); + f.afterBuild = async () => { + if (change === 'manifest') await writeFile(f.manifests[0], '{}\n'); + else { + await rm(f.desktop); + await symlink(f.sourceManifests[0], f.desktop); + } + }; + await assert.rejects(f.rebuild(), /changed during|regular app file/); + assert.deepEqual(await readFile(f.sourceManifests[0]), f.manifestBytes); + if (change === 'manifest') assert.deepEqual(await readFile(f.desktop), native('old shell')); + } +}); + +test('Cargo metadata is authoritative and must report an absolute target directory', async t => { + const f = await fixture(t); + f.invalidTarget = '../not-resolved'; + await assert.rejects(f.rebuild(), /must be absolute/); + assert.deepEqual(f.calls.map(call => call.command), ['metadata']); +}); + +test('final signing preserves Rust 1.85.1 and uses the external absolute Cargo target', async t => { + const f = await fixture(t, { externalTarget: true }); + assert.equal(dirname(f.targetDir), dirname(f.rootDir)); + const result = await f.finalize(); + const cargo = f.calls.find(call => call.command === 'cargo'); + assert.equal(cargo.options.env.RUSTUP_TOOLCHAIN, '1.85.1'); + assert.equal(cargo.options.env.CARGO_TARGET_DIR, f.targetDir); + assert.deepEqual(await readFile(f.desktop), await readFile(f.rebuiltDesktop)); + assert.equal(result.payloadRuntimeManifestSha256, hash(await readFile(f.manifests[0]))); + for (const path of f.sourceManifests) assert.deepEqual(await readFile(path), f.manifestBytes); +}); diff --git a/scripts/release/local-release-macos.mjs b/scripts/release/local-release-macos.mjs index d6444661..b806da43 100644 --- a/scripts/release/local-release-macos.mjs +++ b/scripts/release/local-release-macos.mjs @@ -1,4 +1,5 @@ import { constants } from 'node:fs'; +import { createHash } from 'node:crypto'; import { lstat, mkdir, open } from 'node:fs/promises'; import { basename, join } from 'node:path'; @@ -7,12 +8,14 @@ import semver from 'semver'; import { DESKTOP_APP_ID, PACKAGE_NAME, PRODUCT_NAME, PRODUCT_TOKEN } from '../../shared/productIdentity.js'; import { releaseCommand } from './local-release-command.mjs'; +import { assertOutOfTree } from './out-of-tree.mjs'; import { compareAppInventories, cleanupUpdaterExtraction, extractUpdaterArchive, inventoryApp, } from './updater-archive.mjs'; +import { readUpdaterSidecar } from './updater-signature.mjs'; function requireValue(condition, message) { if (!condition) throw new Error(message); @@ -22,6 +25,9 @@ const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0; const O_NONBLOCK = constants.O_NONBLOCK ?? 0; const MAX_PACKAGE_BYTES = 64 * 1024; const MAX_VTOOL_OUTPUT_BYTES = 64 * 1024; +const MAX_BUILD_INFO_BYTES = 4096; +const MACHO_MAGICS = new Set(['feedface', 'cefaedfe', 'feedfacf', 'cffaedfe', + 'cafebabe', 'bebafeca', 'cafebabf', 'bfbafeca']); const MACOS_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?$/; const REQUIRED_MACHO_PATHS = Object.freeze([ `Contents/MacOS/${PRODUCT_TOKEN}-desktop`, @@ -29,6 +35,8 @@ const REQUIRED_MACHO_PATHS = Object.freeze([ 'Contents/Resources/resources/server-payload/dist-native/bun', 'Contents/Resources/resources/server-payload/dist-native/gajae-core', ]); +const PAYLOAD_MODULES_PREFIX = `${PRODUCT_NAME}.app/Contents/Resources/resources/server-payload/node_modules/`; +const IOS_BARE_PREBUILD = /^bare-[a-z0-9][a-z0-9._-]*\/prebuilds\/ios-(?:arm64|x64)(-simulator)?\/[^/]+\.bare$/; function strictMacosVersion(value, label) { requireValue(typeof value === 'string' && MACOS_VERSION.test(value) @@ -39,7 +47,7 @@ function strictMacosVersion(value, label) { return { text: value, parsed }; } -function nativeMachOPaths(app, inventory) { +async function nativeMachOPaths(app, inventory) { requireValue(inventory !== null && typeof inventory === 'object' && !Array.isArray(inventory) && inventory.root === `${PRODUCT_NAME}.app` && Array.isArray(inventory.entries), `A canonical ${PRODUCT_NAME}.app inventory is required for Mach-O deployment verification.`); @@ -52,6 +60,23 @@ function nativeMachOPaths(app, inventory) { const paths = new Set(required); for (const entry of entries) { const path = entry.path; + // Names alone miss extensionless helpers and framework executables. Read + // only four bytes from each inventoried regular file, never a symlink. + if (entry.type === 'file' && !paths.has(path)) { + requireValue(path.startsWith(`${PRODUCT_NAME}.app/`) + && !path.split('/').some(part => part === '..' || part === '.' || part === ''), + 'Mach-O inventory path must remain inside the app.'); + const fd = await open(join(app, path.slice(`${PRODUCT_NAME}.app/`.length)), + constants.O_RDONLY | O_NOFOLLOW | O_NONBLOCK); + try { + requireValue((await fd.stat()).isFile(), 'Mach-O inventory member must remain a regular file.'); + const magic = Buffer.alloc(4); + const { bytesRead } = await fd.read(magic, 0, 4, 0); + if (bytesRead === 4 && MACHO_MAGICS.has(magic.toString('hex'))) paths.add(path); + } finally { + await fd.close(); + } + } if (/\.(?:node|dylib|so)$/iu.test(path) || path.endsWith('/@vscode/ripgrep/bin/rg') || path.endsWith('/node-pty/build/Release/spawn-helper')) { @@ -71,46 +96,77 @@ function nativeMachOPaths(app, inventory) { * Mach-O load commands; this parser only validates its bounded textual output * and never interprets binary bytes itself. */ -export function parseVtoolBuildMinimums(output, label = 'Mach-O') { +function parseVtoolBuildStamps(output, label) { requireValue(typeof output === 'string' && Buffer.byteLength(output, 'utf8') > 0 && Buffer.byteLength(output, 'utf8') <= MAX_VTOOL_OUTPUT_BYTES, `${label} vtool output is missing or oversized.`); const lines = output.split(/\r?\n/); - const commands = []; + const stamps = []; + const slices = new Set(); + let filename; + let universal; let current; + const finishSlice = () => { + requireValue(current?.cmd === 'LC_BUILD_VERSION' && current.platform !== undefined && current.minos !== undefined, + `${label} has missing or unsupported LC_BUILD_VERSION evidence.`); + requireValue(['MACOS', 'IOS', 'IOSSIMULATOR'].includes(current.platform), + `${label} has unsupported LC_BUILD_VERSION platform evidence.`); + strictMacosVersion(current.minos, `${label} ${current.platform} minos`); + stamps.push(Object.freeze({ platform: current.platform, minos: current.minos })); + }; for (const line of lines) { - if (/^\s*cmd(?:\s|$)/.test(line)) { - const command = /^\s*cmd\s+([A-Za-z0-9_]+)\s*$/.exec(line); - requireValue(command, `${label} has a malformed load-command line.`); - if (current) commands.push(current); - current = { name: command[1], platform: undefined, minos: undefined }; + if (line.trim() === '') continue; + const header = /^(\S.*?)(?: \(architecture ([A-Za-z0-9_]+)\))?:$/.exec(line); + if (header) { + if (filename !== undefined) finishSlice(); + const isUniversal = header[2] !== undefined; + const slice = header[2] ?? 'thin'; + requireValue((filename === undefined || filename === header[1]) + && (universal === undefined || universal === isUniversal) && !slices.has(slice), + `${label} has duplicate or inconsistent vtool slice evidence.`); + filename = header[1]; + universal = isUniversal; + slices.add(slice); + current = undefined; continue; } - if (!current) continue; - const platform = /^\s*platform\s+([A-Za-z0-9_]+)\s*$/.exec(line); - if (platform) { - requireValue(current.platform === undefined, `${label} has duplicate vtool platform evidence.`); - current.platform = platform[1]; + if (/^Load command (?:0|[1-9]\d*)$/.test(line)) { + requireValue(filename !== undefined && current === undefined, + `${label} has duplicate or unsupported load-command evidence.`); + current = {}; continue; } - const minos = /^\s*minos\s+([0-9]+(?:\.[0-9]+){1,2})\s*$/.exec(line); - if (minos) { - requireValue(current.minos === undefined, `${label} has duplicate vtool minimum evidence.`); - current.minos = minos[1]; + const field = /^\s*(cmd|platform|minos|cmdsize|sdk|ntools)\s+(\S+)\s*$/.exec(line); + if (field) { + const [, key, value] = field; + requireValue(current !== undefined && (key === 'cmd' || current.cmd === 'LC_BUILD_VERSION'), + `${label} has misplaced LC_BUILD_VERSION evidence.`); + requireValue(current[key] === undefined, `${label} has duplicate vtool ${key} evidence.`); + if (key === 'cmd') requireValue(value === 'LC_BUILD_VERSION', `${label} contains an unsupported load command.`); + else if (key === 'platform') requireValue(/^[A-Z][A-Z0-9_]*$/.test(value), `${label} has malformed platform evidence.`); + else requireValue((key === 'cmdsize' || key === 'ntools' ? /^(0|[1-9]\d*)$/ : /^\d+(?:\.\d+){1,2}$/).test(value), + `${label} has malformed vtool ${key} evidence.`); + current[key] = value; + continue; } + // Linker/tool metadata is not deployment evidence, but malformed or + // unrecognized lines must not hide an extra platform/minimum stamp. + requireValue(current?.cmd === 'LC_BUILD_VERSION' + && /^\s*(?:tool\s+[A-Za-z0-9_]+|version\s+\d+(?:\.\d+){1,2})\s*$/.test(line), + `${label} has malformed vtool output or LC_BUILD_VERSION evidence.`); } - if (current) commands.push(current); - requireValue(commands.length > 0, `${label} has no LC_BUILD_VERSION evidence.`); - requireValue(commands.every(command => command.name === 'LC_BUILD_VERSION'), - `${label} contains an unsupported load command; only LC_BUILD_VERSION is accepted.`); - const minimums = []; - for (const stamp of commands) { - requireValue(stamp.platform === 'MACOS' && stamp.minos !== undefined, - `${label} has missing or unsupported LC_BUILD_VERSION evidence.`); - strictMacosVersion(stamp.minos, `${label} minos`); - minimums.push(stamp.minos); - } - return Object.freeze(minimums); + finishSlice(); + requireValue(stamps.every(stamp => stamp.platform === stamps[0].platform), + `${label} has mixed LC_BUILD_VERSION platforms.`); + return Object.freeze(stamps); +} + +/** Public parser remains strictly macOS; resource classification is internal. */ +export function parseVtoolBuildMinimums(output, label = 'Mach-O') { + const stamps = parseVtoolBuildStamps(output, label); + requireValue(stamps.every(stamp => stamp.platform === 'MACOS'), + `${label} has missing or unsupported LC_BUILD_VERSION evidence.`); + return Object.freeze(stamps.map(stamp => stamp.minos)); } /** @@ -124,8 +180,9 @@ export async function verifyMacosDeploymentFloor({ inventory, }, { run = releaseCommand } = {}) { const declared = strictMacosVersion(minimumSystemVersion, 'minimumSystemVersion'); - const paths = nativeMachOPaths(app, inventory); + const paths = await nativeMachOPaths(app, inventory); const stamps = []; + const nonMacResources = []; for (const item of paths) { const result = await run('xcrun', ['vtool', '-show-build', item.absolute], { maxOutputBytes: MAX_VTOOL_OUTPUT_BYTES, @@ -135,7 +192,18 @@ export async function verifyMacosDeploymentFloor({ `${item.path} vtool result has an invalid shape.`); requireValue(result.stderr === '', `${item.path} vtool wrote diagnostics to stderr.`); const output = result.stdout; - const minimums = parseVtoolBuildMinimums(output, item.path); + const buildStamps = parseVtoolBuildStamps(output, item.path); + const platform = buildStamps[0].platform; + if (platform !== 'MACOS') { + const resource = item.path.startsWith(PAYLOAD_MODULES_PREFIX) + ? IOS_BARE_PREBUILD.exec(item.path.slice(PAYLOAD_MODULES_PREFIX.length)) : null; + requireValue(resource && platform === (resource[1] ? 'IOSSIMULATOR' : 'IOS'), + `${item.path} has unsupported foreign platform ${platform} outside its matching iOS bare prebuild resource path.`); + nonMacResources.push(Object.freeze({ path: item.path, platform, + minimumSystemVersions: Object.freeze(buildStamps.map(stamp => stamp.minos)) })); + continue; + } + const minimums = buildStamps.map(stamp => stamp.minos); for (const minimum of minimums) { const parsed = strictMacosVersion(minimum, `${item.path} minos`); requireValue(semver.lte(parsed.parsed, declared.parsed), @@ -150,6 +218,8 @@ export async function verifyMacosDeploymentFloor({ ? item.minimumSystemVersion : max ), '0.0'), stamps: Object.freeze(stamps), + nonMacResourceCount: nonMacResources.length, + nonMacResources: Object.freeze(nonMacResources), }); } @@ -182,6 +252,31 @@ export function assertNotarizedAssessment(output) { requireValue(/: accepted\s*$/m.test(output) && /^source=Notarized Developer ID\s*$/m.test(output), 'Gatekeeper did not accept Notarized Developer ID.'); } +/** The signed executable must positively attest its compile-time disabled mode. */ +export function assertManualBuildInfo(text, { version, desktopVersion, runtimeManifestSha256, payloadRuntimeManifestSha256 }) { + requireValue(typeof runtimeManifestSha256 === 'string' && /^[a-f0-9]{64}$/.test(runtimeManifestSha256), + 'The pinned source runtimeManifestSha256 is required for manual verification.'); + requireValue(typeof payloadRuntimeManifestSha256 === 'string' && /^[a-f0-9]{64}$/.test(payloadRuntimeManifestSha256), + 'The verified signed payload runtime manifest SHA-256 is required.'); + requireValue(typeof text === 'string' && Buffer.byteLength(text) <= MAX_BUILD_INFO_BYTES, + 'Desktop build info is missing or oversized.'); + let info; + try { info = JSON.parse(text); } catch { throw new Error('Desktop build info must be exactly one JSON object.'); } + const expected = { schemaVersion: 1, packageName: PACKAGE_NAME, productVersion: version, + desktopVersion, debug: false, updateMode: 'disabled', runtimeManifestSha256, payloadRuntimeManifestSha256 }; + requireValue(info !== null && typeof info === 'object' && !Array.isArray(info) + && JSON.stringify(Object.keys(info).sort()) === JSON.stringify(Object.keys(expected).sort()), + 'Desktop build info must contain exactly the eight schema fields.'); + // This schema is flat and all accepted values are primitives. Count keys + // in the original text as well so JSON.parse cannot hide duplicate fields. + const keys = [...text.matchAll(/"(?:\\.|[^"\\])*"\s*:/g)]; + requireValue(keys.length === Object.keys(expected).length, 'Desktop build info contains duplicate fields.'); + for (const [key, value] of Object.entries(expected)) { + requireValue(info[key] === value, `Desktop build info ${key} does not match the pinned manual-disabled build.`); + } + return Object.freeze(info); +} + /** * Read-only checks for one finalized app bundle. The caller is responsible * for making any disposable copy or applying quarantine before invoking this @@ -240,9 +335,22 @@ export async function verifyMacosRelease({ desktopVersion, updaterArchivePath, minimumSystemVersion, + manualDisabled = false, + runtimeManifestSha256, }, { run = releaseCommand } = {}) { - requireValue(typeof updaterArchivePath === 'string' && updaterArchivePath.length > 0, - 'A verified updaterArchivePath is required for macOS release verification.'); + requireValue(typeof manualDisabled === 'boolean', 'manualDisabled must be an explicit boolean.'); + if (manualDisabled) { + requireValue(updaterArchivePath === undefined, 'Manual-disabled verification forbids an updater archive.'); + requireValue(typeof runtimeManifestSha256 === 'string' && /^[a-f0-9]{64}$/.test(runtimeManifestSha256), + 'The pinned source runtimeManifestSha256 is required for manual verification.'); + const rootStat = await lstat(root); + requireValue(rootStat.isDirectory() && !rootStat.isSymbolicLink() && (rootStat.mode & 0o077) === 0 + && rootStat.uid === process.getuid(), 'Manual verification requires an owner-only private directory.'); + await assertOutOfTree(root, 'Manual release verification'); + } else { + requireValue(typeof updaterArchivePath === 'string' && updaterArchivePath.length > 0, + 'A verified updaterArchivePath is required for macOS release verification.'); + } strictMacosVersion(minimumSystemVersion, 'minimumSystemVersion'); await run('hdiutil', ['verify', dmg]); await run('codesign', ['--verify', '--strict', dmg]); @@ -259,6 +367,7 @@ export async function verifyMacosRelease({ let verificationError; let verificationResult; let extracted; + let copiedDeployment; try { await run('hdiutil', ['attach', dmg, '-nobrowse', '-readonly', '-mountpoint', mount]); const mountedApp = join(mount, `${PRODUCT_NAME}.app`); @@ -271,16 +380,39 @@ export async function verifyMacosRelease({ // names, so this equality binds runtime content without a second parser or // a mutable-manifest shortcut. const copiedInventory = await inventoryApp(copiedApp); - extracted = await extractUpdaterArchive({ archivePath: updaterArchivePath, root }); - compareAppInventories(copiedInventory, extracted.inventory); - for (const app of [mountedApp, copiedApp, extracted.appPath]) { + if (manualDisabled) { + compareAppInventories(copiedInventory, await inventoryApp(mountedApp)); + } else { + extracted = await extractUpdaterArchive({ archivePath: updaterArchivePath, root }); + compareAppInventories(copiedInventory, extracted.inventory); + } + for (const app of [mountedApp, copiedApp, ...(extracted ? [extracted.appPath] : [])]) { const appInventory = app === copiedApp ? copiedInventory - : app === extracted.appPath ? extracted.inventory : undefined; - await verifyMacosApp({ + : app === extracted?.appPath ? extracted.inventory : undefined; + const verifiedApp = await verifyMacosApp({ app, teamId, version, desktopVersion, minimumSystemVersion, inventory: appInventory, }, { run }); + if (app === copiedApp) copiedDeployment = verifiedApp.deployment; + } + if (manualDisabled) { + // No UI, browser IPC, QA mode or lifecycle initialization. This early + // diagnostic is run only after *all* copied-app and Apple checks pass. + const output = join(root, 'desktop-build-info.json'); + const result = await run(join(copiedApp, 'Contents/MacOS', `${PRODUCT_TOKEN}-desktop`), + ['--desktop-build-info'], { output, timeout: 10_000, maxOutputBytes: MAX_BUILD_INFO_BYTES }); + requireValue(result?.stderr === '', 'Desktop build info wrote unexpected diagnostics.'); + const payloadManifest = await readUpdaterSidecar(join(copiedApp, + 'Contents/Resources/resources/server-payload/server/gjc-runtime-manifest.json'), MAX_PACKAGE_BYTES); + const payloadRuntimeManifestSha256 = createHash('sha256').update(payloadManifest, 'utf8').digest('hex'); + const buildInfo = assertManualBuildInfo(await readUpdaterSidecar(output, MAX_BUILD_INFO_BYTES), + { version, desktopVersion, runtimeManifestSha256, payloadRuntimeManifestSha256 }); + compareAppInventories(copiedInventory, await inventoryApp(copiedApp)); + verificationResult = { copiedApp, inventory: copiedInventory, deployment: copiedDeployment, + buildInfo, payloadRuntimeManifestSha256, updateMode: 'disabled' }; + } else { + verificationResult = { copiedApp, extractedApp: extracted.appPath, inventory: copiedInventory, + deployment: copiedDeployment, archive: extracted.archive }; } - verificationResult = { copiedApp, extractedApp: extracted.appPath, inventory: copiedInventory, archive: extracted.archive }; } catch (error) { verificationError = error; } diff --git a/scripts/release/local-release-macos.test.mjs b/scripts/release/local-release-macos.test.mjs index 6a86ecbc..7d92baaf 100644 --- a/scripts/release/local-release-macos.test.mjs +++ b/scripts/release/local-release-macos.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; -import { cp, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { chmod, cp, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; @@ -7,6 +8,7 @@ import { test } from 'node:test'; import { assertDeveloperSignature, assertNotarizedAssessment, + assertManualBuildInfo, parseVtoolBuildMinimums, verifyMacosApp, verifyMacosDeploymentFloor, @@ -16,6 +18,30 @@ import { createUpdaterArchive, inventoryApp } from './updater-archive.mjs'; const teamId = 'AB12345678'; const signature = `Authority=Developer ID Application: Fixture (${teamId})\nTeamIdentifier=${teamId}\nCodeDirectory v=20500 size=400 flags=0x10000(runtime) hashes=12\n`; +const payloadModules = 'Contents/Resources/resources/server-payload/node_modules'; +const simulatorResource = `${payloadModules}/bare-fs/prebuilds/ios-arm64-simulator/bare-fs.bare`; + +function vtoolStamp(platform, minimum = '14.0', header = 'x:') { + return `${header}\nLoad command 9\n cmd LC_BUILD_VERSION\n cmdsize 32\n platform ${platform}\n minos ${minimum}\n sdk 17.5\n ntools 1\n tool LD\n version 1053.12\n`; +} + +async function addMachO(state, app, relativePath, output) { + const path = join(app, relativePath); + await mkdir(join(path, '..'), { recursive: true }); + await writeFile(path, Buffer.from('cffaedfe00000000', 'hex'), { mode: 0o600 }); + state.vtoolOutputByPath ??= new Map(); + state.vtoolOutputByPath.set(path, output); + return path; +} + +async function deploymentFixture(t) { + const state = await fixture(t); + await state.run('hdiutil', ['attach']); + const app = join(state.input.root, 'mount/Gajae Code App.app'); + return { state, app, verify: async () => verifyMacosDeploymentFloor({ + app, minimumSystemVersion: '13.0', inventory: await inventoryApp(app), + }, { run: state.run }) }; +} async function fixture(t) { const root = await realpath(await mkdtemp(join(tmpdir(), 'gajae-macos-validation-test-'))); @@ -32,14 +58,16 @@ async function fixture(t) { teamId, }; const state = { input, calls: [], packageVersion: input.version, desktopVersion: input.desktopVersion }; - state.run = async (program, args) => { - state.calls.push({ program, args }); + state.run = async (program, args, options) => { + state.calls.push({ program, args, ...(options?.output ? { options } : {}) }); if (state.fail?.(program, args)) throw new Error('Simulated acceptance failure'); if (program === 'hdiutil' && args[0] === 'attach') { const app = join(root, 'mount/Gajae Code App.app'); const payload = join(app, 'Contents/Resources/resources/server-payload'); await mkdir(payload, { recursive: true }); await writeFile(join(payload, 'package.json'), JSON.stringify({ name: 'gajae-app', version: state.packageVersion })); + await mkdir(join(payload, 'server'), { recursive: true }); + await writeFile(join(payload, 'server/gjc-runtime-manifest.json'), '{"signed":"fixture"}\n'); await mkdir(join(app, 'Contents/MacOS'), { recursive: true }); await mkdir(join(payload, 'dist-native'), { recursive: true }); await writeFile(join(payload, 'dist-native/libfixture.dylib'), 'Mach-O dylib fixture'); @@ -56,17 +84,34 @@ async function fixture(t) { await mkdir(join(file, '..'), { recursive: true }); await writeFile(file, 'Mach-O fixture'); } + if (state.afterMount) await state.afterMount(app); } if (program === 'ditto') { await cp(args[0], args[1], { recursive: true }); + if (state.alterCopy) await state.alterCopy(args[1]); if (!state.archiveReady && !state.skipArchive) { await createUpdaterArchive({ appPath: args[1], archivePath: input.updaterArchivePath }); state.archiveReady = true; } } + if (args[0] === '--desktop-build-info') { + assert.ok(program.includes('/copy/')); + assert.equal(options.timeout, 10_000); + assert.equal(options.maxOutputBytes, 4096); + await writeFile(options.output, state.buildInfoText ?? JSON.stringify({ + schemaVersion: 1, packageName: 'gajae-app', productVersion: input.version, + desktopVersion: input.desktopVersion, debug: false, updateMode: 'disabled', + runtimeManifestSha256: input.runtimeManifestSha256, + payloadRuntimeManifestSha256: createHash('sha256').update('{"signed":"fixture"}\n').digest('hex'), + ...state.buildInfoOverrides, + }), { flag: 'wx', mode: 0o600 }); + if (state.afterDiagnostic) await state.afterDiagnostic(); + return { stdout: '', stderr: state.buildInfoStderr ?? '' }; + } if (program === 'codesign' && args[0] === '--display') return { stdout: '', stderr: state.signature ?? signature }; if (program === 'spctl') return { stdout: '', stderr: `${args.at(-1)}: accepted\nsource=Notarized Developer ID\n` }; if (program === 'xcrun' && args[0] === 'vtool') { + if (state.vtoolOutputByPath?.has(args.at(-1))) return { stdout: state.vtoolOutputByPath.get(args.at(-1)), stderr: '' }; if (state.vtoolOutput !== undefined) return { stdout: state.vtoolOutput, stderr: '' }; const minimum = state.vtoolMinimumByPath?.get(args.at(-1)) ?? state.vtoolMinimum ?? '13.0'; return { @@ -177,6 +222,135 @@ test('deployment floor includes dylib and shared-object runtime modules', async }, { run: state.run }), /libfixture\.dylib|requires macOS 14\.0/); }); +test('only matching vendor iOS bare prebuilds are reported separately from qualified macOS stamps', async t => { + const { state, app, verify } = await deploymentFixture(t); + const expected = []; + for (const arch of ['arm64', 'x64']) { + for (const simulator of [false, true]) { + const path = `${payloadModules}/bare-fs/prebuilds/ios-${arch}${simulator ? '-simulator' : ''}/bare-fs.bare`; + const platform = simulator ? 'IOSSIMULATOR' : 'IOS'; + await addMachO(state, app, path, vtoolStamp(platform)); + expected.push({ path: `Gajae Code App.app/${path}`, platform, minimumSystemVersions: ['14.0'] }); + } + } + const result = await verify(); + assert.equal(result.nonMacResourceCount, 4); + assert.deepEqual(result.nonMacResources, expected.sort((a, b) => a.path < b.path ? -1 : 1)); + assert.equal(result.maximumStampedMinimumSystemVersion, '13.0'); + assert.equal(result.stamps.length, 7); + assert.ok(result.stamps.every(stamp => !stamp.path.endsWith('.bare') && stamp.minimumSystemVersion === '13.0')); + assert.equal(state.calls.filter(call => call.args[0] === 'vtool').length, 11); +}); + +test('uniform universal slices count as one non-Mac resource; public parser remains strictly MACOS', async t => { + const { state, app, verify } = await deploymentFixture(t); + const output = vtoolStamp('IOSSIMULATOR', '14.0', 'x (architecture arm64):') + + vtoolStamp('IOSSIMULATOR', '15.0', 'x (architecture x86_64):'); + await addMachO(state, app, simulatorResource, output); + const result = await verify(); + assert.equal(result.nonMacResourceCount, 1); + assert.deepEqual(result.nonMacResources[0].minimumSystemVersions, ['14.0', '15.0']); + for (const platform of ['IOS', 'IOSSIMULATOR', 'TVOS', 'UNKNOWN']) { + assert.throws(() => parseVtoolBuildMinimums(vtoolStamp(platform)), /unsupported/); + } + assert.throws(() => parseVtoolBuildMinimums(output, 'resource', { allowForeign: true }), /unsupported/); + assert.deepEqual(parseVtoolBuildMinimums(vtoolStamp('MACOS', '12.0', 'x (architecture arm64):') + + vtoolStamp('MACOS', '13.0', 'x (architecture x86_64):')), ['12.0', '13.0']); +}); + +test('foreign binaries outside the exact canonical resource path fail closed', async t => { + for (const path of [ + 'Contents/Resources/node_modules/bare-fs/prebuilds/ios-arm64-simulator/bare-fs.bare', + 'Contents/Resources/server-payload/node_modules/bare-fs/prebuilds/ios-arm64-simulator/bare-fs.bare', + `${payloadModules}/other/prebuilds/ios-arm64-simulator/bare-fs.bare`, + `${payloadModules}/@vendor/bare-fs/prebuilds/ios-arm64-simulator/bare-fs.bare`, + `${payloadModules}/nested/node_modules/bare-fs/prebuilds/ios-arm64-simulator/bare-fs.bare`, + `${payloadModules}/bare-fs/prebuilds/darwin-arm64/bare-fs.bare`, + `${payloadModules}/bare-fs/prebuilds/ios-armv7-simulator/bare-fs.bare`, + `${payloadModules}/bare-fs/prebuilds/ios-arm64-simulator/nested/bare-fs.bare`, + `${payloadModules}/bare-fs/prebuilds/ios-arm64-simulator/bare-fs.node`, + `${simulatorResource}.backup`, + 'Contents/Resources/unlisted-helper', + ]) { + await t.test(path, async t => { + const { state, app, verify } = await deploymentFixture(t); + await addMachO(state, app, path, vtoolStamp('IOSSIMULATOR')); + await assert.rejects(verify(), /unsupported foreign platform/); + }); + } +}); + +test('iOS paths never exempt MACOS binaries from the declared macOS 13 floor', async t => { + const { state, app, verify } = await deploymentFixture(t); + const path = await addMachO(state, app, simulatorResource, vtoolStamp('MACOS')); + await assert.rejects(verify(), /bare-fs\.bare requires macOS 14\.0/); + state.vtoolOutputByPath.set(path, vtoolStamp('MACOS', '13.0')); + const result = await verify(); + assert.equal(result.nonMacResourceCount, 0); + assert.deepEqual(result.nonMacResources, []); + assert.ok(result.stamps.some(stamp => stamp.path.endsWith(simulatorResource))); +}); + +test('iOS resources reject unexpected, malformed, duplicate and mixed platform evidence', async t => { + const valid = vtoolStamp('IOSSIMULATOR'); + for (const [name, output] of [ + ['device stamp in simulator folder', vtoolStamp('IOS')], + ['unknown platform', vtoolStamp('UNKNOWN')], + ['other foreign platform', vtoolStamp('TVOS')], + ['missing platform', valid.replace(' platform IOSSIMULATOR\n', '')], + ['missing minimum', valid.replace(' minos 14.0\n', '')], + ['invalid minimum', valid.replace('minos 14.0', 'minos 14.0garbage')], + ['unbounded minimum', valid.replace('minos 14.0', 'minos 1000.0')], + ['noncanonical minimum', valid.replace('minos 14.0', 'minos 014.0')], + ['duplicate platform', `${valid} platform IOSSIMULATOR\n`], + ['duplicate minimum', `${valid} minos 14.0\n`], + ['malformed extra platform', `${valid} platform MACOS garbage\n`], + ['malformed extra minimum', `${valid} minos invalid\n`], + ['orphan evidence', ` platform IOSSIMULATOR\n${valid}`], + ['missing load-command boundary', valid.replace('Load command 9\n', '')], + ['missing command', valid.replace(' cmd LC_BUILD_VERSION\n', '')], + ['duplicate command', `${valid} cmd LC_BUILD_VERSION\n`], + ['duplicate stamp in slice', `${valid}Load command 10\n cmd LC_BUILD_VERSION\n platform IOSSIMULATOR\n minos 14.0\n`], + ['duplicate thin slice', valid + valid], + ['duplicate architecture', vtoolStamp('IOSSIMULATOR', '14.0', 'x (architecture arm64):').repeat(2)], + ['truncated second slice', `${vtoolStamp('IOSSIMULATOR', '14.0', 'x (architecture arm64):')}x (architecture x86_64):\n`], + ['mixed iOS platforms', vtoolStamp('IOSSIMULATOR', '14.0', 'x (architecture arm64):') + + vtoolStamp('IOS', '14.0', 'x (architecture x86_64):')], + ['mixed Mac and iOS platforms', vtoolStamp('MACOS', '13.0', 'x (architecture arm64):') + + vtoolStamp('IOSSIMULATOR', '14.0', 'x (architecture x86_64):')], + ['unsupported legacy command', valid.replace('LC_BUILD_VERSION', 'LC_VERSION_MIN_IPHONEOS')], + ['unexpected text', `${valid}not vtool output\n`], + ]) { + await t.test(name, async t => { + const { state, app, verify } = await deploymentFixture(t); + await addMachO(state, app, simulatorResource, output); + await assert.rejects(verify(), /evidence|vtool|unsupported|bounded|mixed/); + }); + } + const { state, app, verify } = await deploymentFixture(t); + await addMachO(state, app, simulatorResource.replace('-simulator', ''), valid); + await assert.rejects(verify(), /unsupported foreign platform/); +}); + +test('required executables and runtime modules never qualify as non-Mac resources', async t => { + for (const path of [ + 'Contents/MacOS/gajae-app-desktop', + 'Contents/MacOS/gajae-app-server', + 'Contents/Resources/resources/server-payload/dist-native/bun', + 'Contents/Resources/resources/server-payload/dist-native/gajae-core', + `${payloadModules}/test-addon/addon.node`, + `${payloadModules}/@vscode/ripgrep/bin/rg`, + `${payloadModules}/node-pty/build/Release/spawn-helper`, + ]) { + await t.test(path, async t => { + const { state, app, verify } = await deploymentFixture(t); + await addMachO(state, app, simulatorResource, vtoolStamp('IOSSIMULATOR')); + await addMachO(state, app, path, vtoolStamp('IOS')); + await assert.rejects(verify(), /unsupported foreign platform/); + }); + } +}); + test('deployment floor rejects vtool diagnostics and malformed command results', async t => { const state = await fixture(t); const app = join(state.input.root, 'mount/Gajae Code App.app'); @@ -266,3 +440,161 @@ test('failed detachment preserves the temporary directory instead of risking del state.fail = (program, args) => program === 'hdiutil' && args[0] === 'detach'; await assert.rejects(state.execute(), error => error.preserveDirectory === true && error.message.includes(state.input.root)); }); + +async function manualFixture(t) { + const state = await fixture(t); + delete state.input.updaterArchivePath; + state.input.manualDisabled = true; + state.input.runtimeManifestSha256 = 'c'.repeat(64); + state.skipArchive = true; + return state; +} + +test('explicit manual mode validates DMG and both apps before the bounded copied-binary diagnostic', async t => { + const state = await manualFixture(t); + const result = await state.execute(); + assert.equal(result.updateMode, 'disabled'); + assert.equal(result.buildInfo.updateMode, 'disabled'); + assert.equal(result.buildInfo.runtimeManifestSha256, state.input.runtimeManifestSha256); + assert.equal(result.deployment.nonMacResourceCount, 0); + assert.equal(result.deployment.maximumStampedMinimumSystemVersion, '13.0'); + assert.equal(result.extractedApp, undefined); + assert.ok((await readFile(join(result.copiedApp, 'Contents/MacOS/gajae-app-desktop'))).length > 0); + const diagnosticIndex = state.calls.findIndex(call => call.args.includes('--desktop-build-info')); + assert.ok(diagnosticIndex > 0); + for (const target of [state.input.dmg, join(state.input.root, 'mount/Gajae Code App.app'), result.copiedApp]) { + for (const program of ['codesign', 'spctl', 'xcrun']) { + assert.ok(state.calls.slice(0, diagnosticIndex).some(call => call.program === program && call.args.at(-1) === target)); + } + } + assert.equal(state.calls.filter(call => call.program === 'lipo').length, 4); + assert.ok(state.calls.slice(0, diagnosticIndex).some(call => call.args[0] === 'vtool' && call.args.at(-1).includes('/copy/'))); + assert.equal(state.calls.filter(call => call.args[0] === '--desktop-build-info').length, 1); + assert.equal(state.calls.at(-1).args[0], 'detach'); + assert.ok(!state.calls.some(call => call.args.some(arg => /--sign|--qa|--browser|\.app\.tar\.gz/.test(arg)))); +}); + +test('manual release exposes inspected resource evidence from the validated copy', async t => { + const state = await manualFixture(t); + state.afterMount = async app => { + const output = vtoolStamp('IOSSIMULATOR'); + await addMachO(state, app, simulatorResource, output); + state.vtoolOutputByPath.set(join(state.input.root, 'copy/Gajae Code App.app', simulatorResource), output); + }; + const result = await state.execute(); + assert.equal(result.deployment.nonMacResourceCount, 1); + assert.deepEqual(result.deployment.nonMacResources, [{ + path: `Gajae Code App.app/${simulatorResource}`, platform: 'IOSSIMULATOR', minimumSystemVersions: ['14.0'], + }]); + assert.equal(result.deployment.maximumStampedMinimumSystemVersion, '13.0'); + const resourceChecks = state.calls.filter(call => call.args[0] === 'vtool' && call.args.at(-1).endsWith(simulatorResource)); + assert.equal(resourceChecks.length, 2); +}); + +test('manual flag is explicit, mutually exclusive with updater archives, and requires private root and source hash', async t => { + for (const modify of [ + input => { delete input.manualDisabled; }, + input => { input.manualDisabled = 'true'; }, + input => { input.manualDisabled = false; }, + input => { input.updaterArchivePath = 'archive.tar.gz'; }, + input => { input.updaterArchivePath = null; }, + input => { delete input.runtimeManifestSha256; }, + input => { input.runtimeManifestSha256 = 'bad'; }, + ]) { + const state = await manualFixture(t); + modify(state.input); + await assert.rejects(state.execute()); + assert.equal(state.calls.length, 0); + } + const state = await manualFixture(t); + await chmod(state.input.root, 0o755); + await assert.rejects(state.execute(), /owner-only/); + assert.equal(state.calls.length, 0); +}); + +test('build info requires exact typed schema, disabled mode and every pinned compile-time identity', () => { + const expected = { version: '2.0.0-beta.10', desktopVersion: '0.2.4', runtimeManifestSha256: 'c'.repeat(64), payloadRuntimeManifestSha256: 'e'.repeat(64) }; + const good = { schemaVersion: 1, packageName: 'gajae-app', productVersion: expected.version, + desktopVersion: expected.desktopVersion, debug: false, updateMode: 'disabled', runtimeManifestSha256: expected.runtimeManifestSha256, + payloadRuntimeManifestSha256: expected.payloadRuntimeManifestSha256 }; + assert.deepEqual(assertManualBuildInfo(JSON.stringify(good), expected), good); + for (const [key, value] of [ + ['schemaVersion', '1'], ['schemaVersion', 2], ['packageName', 'other'], ['productVersion', '2.0.0-beta.9'], + ['desktopVersion', '0.2.3'], ['debug', true], ['debug', 'false'], ['updateMode', 'enabled'], + ['updateMode', 'qa'], ['runtimeManifestSha256', 'd'.repeat(64)], ['extra', true], + ['payloadRuntimeManifestSha256', 'd'.repeat(64)], + ]) assert.throws(() => assertManualBuildInfo(JSON.stringify({ ...good, [key]: value }), expected)); + for (const key of Object.keys(good)) { + const missing = { ...good }; + delete missing[key]; + assert.throws(() => assertManualBuildInfo(JSON.stringify(missing), expected)); + } + for (const text of ['null', '[]', '{}', 'not JSON', `${JSON.stringify(good)}\n{}`, ' '.repeat(4097), + JSON.stringify(good).replace('"debug":false', '"debug":true,"debug":false')]) { + assert.throws(() => assertManualBuildInfo(text, expected)); + } +}); + +test('manual signing, staple, Gatekeeper, architecture and loader failures never launch the diagnostic', async t => { + for (const fail of [ + (program, args) => program === 'codesign' && args.at(-1).endsWith('.dmg'), + (program, args) => program === 'codesign' && args.at(-1).includes('/copy/'), + (program, args) => program === 'xcrun' && args[0] === 'stapler', + program => program === 'spctl', + program => program === 'lipo', + (program, args) => program === 'xcrun' && args[0] === 'vtool', + ]) { + const state = await manualFixture(t); + state.fail = fail; + await assert.rejects(state.execute(), /acceptance failure/); + assert.ok(!state.calls.some(call => call.args[0] === '--desktop-build-info')); + if (state.calls.some(call => call.args[0] === 'attach')) assert.equal(state.calls.at(-1).args[0], 'detach'); + } +}); + +test('manual mode fails closed on diagnostic absence, malformed output, updater enabled and diagnostics', async t => { + for (const change of [ + state => { state.buildInfoOverrides = { updateMode: 'enabled' }; }, + state => { state.buildInfoOverrides = { debug: true }; }, + state => { state.buildInfoText = '{}'; }, + state => { state.buildInfoText = ' '.repeat(4097); }, + state => { state.buildInfoStderr = 'unexpected diagnostics'; }, + state => { state.fail = (_, args) => args[0] === '--desktop-build-info'; }, + ]) { + const state = await manualFixture(t); + change(state); + await assert.rejects(state.execute()); + assert.equal(state.calls.at(-1).args[0], 'detach'); + } +}); + +test('manual copied app must match the mounted app and remain unchanged after diagnostic', async t => { + for (const after of [false, true]) { + const state = await manualFixture(t); + const mutate = copiedApp => writeFile(join(copiedApp, 'Contents/MacOS/gajae-app-server'), 'different signed bytes'); + if (after) state.afterDiagnostic = () => mutate(join(state.input.root, 'copy/Gajae Code App.app')); + else state.alterCopy = mutate; + await assert.rejects(state.execute(), /bytes differ/); + assert.equal(state.calls.at(-1).args[0], 'detach'); + assert.equal(state.calls.some(call => call.args[0] === '--desktop-build-info'), after); + } +}); + +test('deployment floor finds extensionless and fat Mach-O helpers by bytes, regardless of executable mode', async t => { + for (const magic of ['cffaedfe', 'feedface', 'cafebabe', 'bebafeca', 'cafebabf', 'bfbafeca']) { + const state = await fixture(t); + const app = join(state.input.root, 'mount/Gajae Code App.app'); + await state.run('hdiutil', ['attach']); + const helper = join(app, 'Contents/Resources/unlisted-helper'); + await writeFile(helper, Buffer.from(`${magic}00000000`, 'hex'), { mode: 0o600 }); + state.vtoolMinimumByPath = new Map([[helper, '14.0']]); + await assert.rejects(verifyMacosDeploymentFloor({ app, minimumSystemVersion: '13.0', inventory: await inventoryApp(app) }, + { run: state.run }), /unlisted-helper.*requires macOS 14\.0/); + } +}); + +test('manual failed detachment retains the private copy and reports its directory', async t => { + const state = await manualFixture(t); + state.fail = (program, args) => program === 'hdiutil' && args[0] === 'detach'; + await assert.rejects(state.execute(), error => error.preserveDirectory === true && error.message.includes(state.input.root)); +}); diff --git a/scripts/release/manual-release.mjs b/scripts/release/manual-release.mjs new file mode 100644 index 00000000..d01bc3aa --- /dev/null +++ b/scripts/release/manual-release.mjs @@ -0,0 +1,274 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto'; +import { constants, realpathSync } from 'node:fs'; +import { chmod, mkdtemp, open, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; + +import { PACKAGE_NAME, REPOSITORY_SLUG, SERVER_PACKAGE_NAME } from '../../shared/productIdentity.js'; + +import { releaseCommand } from './local-release-command.mjs'; +import { assertManualBuildInfo, verifyMacosRelease } from './local-release-macos.mjs'; +import { assertChecksum, releaseSnapshot } from './local-release.mjs'; +import { assertOutOfTree } from './out-of-tree.mjs'; +import { assetNames, UPDATER_ASSET_LIMITS, validateDesktopVersionFloor, validUtcDate } from './updater-artifacts.mjs'; +import { collectPublishedDesktopHistory, resolveReleaseTag } from './updater-history.mjs'; +import { readUpdaterSidecar } from './updater-signature.mjs'; + +const demand = (condition, message) => { if (!condition) throw new Error(message); }; +const record = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const numericId = value => Number.isSafeInteger(value) && value > 0; +const SHA256 = /^[a-f0-9]{64}$/; +const optionNames = ['repo', 'draft-id', 'tag', 'commit', 'team-id']; +const metadataLimit = 64 * 1024; + +/** Separate allowlist: no updater key, archive, signature, manifest or arbitrary extras. */ +export function manualReleaseOptions(values) { + demand(record(values) && Object.keys(values).every(key => [...optionNames, 'asset', 'publish'].includes(key)), + 'Only explicit manual release options are accepted.'); + demand(values.repo === REPOSITORY_SLUG, 'Explicit canonical --repo is required.'); + demand(typeof values.tag === 'string' && /^v\d+\.\d+\.\d+(?:-beta(?:\.[A-Za-z0-9-]+)*)?$/.test(values.tag), + 'Explicit canonical beta or stable --tag is required.'); + demand(typeof values.commit === 'string' && /^[a-f0-9]{40}$/.test(values.commit), + 'Explicit full lowercase 40-character --commit is required.'); + demand(/^[1-9][0-9]*$/.test(String(values['draft-id'])) && numericId(Number(values['draft-id'])), + 'Explicit numeric --draft-id is required.'); + demand(typeof values['team-id'] === 'string' && /^[A-Z0-9]{10}$/.test(values['team-id']), + 'Explicit 10-character --team-id is required.'); + demand(values.publish === undefined || typeof values.publish === 'boolean', '--publish must be boolean.'); + const version = values.tag.slice(1); + const names = assetNames({ productVersion: version, tag: values.tag }); + const allowed = [names.macos.dmg, names.server.archive, ...names.optionalPayloads]; + demand(Array.isArray(values.asset) && values.asset.length >= 2 && values.asset.length <= allowed.length, + 'Pin the canonical DMG and Linux Node22 server archive; at most two exact Linux desktop payloads are optional.'); + const pins = new Map(); + for (const entry of values.asset) { + demand(typeof entry === 'string', 'Each --asset must be a payload basename=SHA256.'); + const [name, hash, ...extra] = entry.split('='); + demand(extra.length === 0 && allowed.includes(name) && SHA256.test(hash ?? '') && !pins.has(name), + 'Each --asset must uniquely pin an allowed manual payload to a lowercase SHA-256.'); + pins.set(name, hash); + } + demand(pins.has(names.macos.dmg) && pins.has(names.server.archive), 'Pin both canonical manual payloads.'); + return { repo: values.repo, tag: values.tag, commit: values.commit, draftId: Number(values['draft-id']), + teamId: values['team-id'], publish: values.publish === true, version, pins, names }; +} + +export function validateManualDraft(release, options) { + demand(record(release) && release.id === options.draftId && release.draft === true && release.published_at === null, + 'Expected the exact existing unpublished draft ID; published releases are never edited.'); + demand(release.tag_name === options.tag && release.target_commitish === options.commit, + 'Draft tag/target must match the exact supplied tag and full commit, not a branch.'); + demand(release.prerelease === options.version.includes('-'), 'Draft prerelease status does not match the version tag.'); + const expected = new Set([...options.pins.keys()].flatMap(name => [name, `${name}.sha256`])); + demand(Array.isArray(release.assets) && release.assets.length === expected.size, + 'Manual release assets must have the exact expected cardinality.'); + const ids = new Set(); + for (const asset of release.assets) { + demand(record(asset) && expected.has(asset.name), 'Unlisted or duplicate manual release asset.'); + demand(numericId(asset.id) && !ids.has(asset.id), 'Asset IDs must be unique positive numeric IDs.'); + ids.add(asset.id); + expected.delete(asset.name); + const limit = asset.name.endsWith('.sha256') ? UPDATER_ASSET_LIMITS.maxChecksumBytes + : asset.name === options.names.macos.dmg ? UPDATER_ASSET_LIMITS.maxDmgBytes : UPDATER_ASSET_LIMITS.maxPayloadBytes; + demand(numericId(asset.size) && asset.size <= limit, 'Asset size is invalid or exceeds its release limit.'); + demand(asset.state === 'uploaded', 'Asset must be fully uploaded.'); + if (asset.digest !== undefined && asset.digest !== null) { + demand(typeof asset.digest === 'string' && /^sha256:[a-f0-9]{64}$/.test(asset.digest), 'Invalid GitHub asset digest.'); + if (options.pins.has(asset.name)) demand(asset.digest === `sha256:${options.pins.get(asset.name)}`, + 'Asset disagrees with its independent pin.'); + } + if (asset.updated_at !== undefined && asset.updated_at !== null) validUtcDate(asset.updated_at, 'Asset updated_at'); + if (asset.label !== undefined && asset.label !== null) demand(typeof asset.label === 'string' + && Buffer.byteLength(asset.label) <= 256 && !/[\u0000-\u001f\u007f]/u.test(asset.label), 'Invalid asset label.'); + } + demand(expected.size === 0, 'Missing manual release asset.'); +} + +async function fileHash(path, expectedSize, limit = expectedSize) { + const fd = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const start = await fd.stat(); + demand(start.isFile() && start.nlink === 1 && start.size > 0 && start.size <= limit + && (expectedSize === undefined || start.size === expectedSize), 'Downloaded asset size/type differs from draft metadata.'); + const hash = createHash('sha256'); + let size = 0; + for await (const chunk of fd.createReadStream({ autoClose: false })) { + size += chunk.length; + demand(size <= limit, 'Downloaded asset exceeded its streaming byte limit.'); + hash.update(chunk); + } + const end = await fd.stat(); + demand(size === start.size && end.size === start.size && end.mtimeMs === start.mtimeMs + && end.ctimeMs === start.ctimeMs, 'Downloaded asset changed while hashing.'); + return hash.digest('hex'); + } finally { await fd.close(); } +} + +/** Verify only by default; the sole optional mutation publishes the verified numeric draft ID. */ +export async function processManualRelease(input, { + run = releaseCommand, verifyMac = verifyMacosRelease, collectHistory = collectPublishedDesktopHistory, + platform = process.platform, arch = process.arch, +} = {}) { + demand(platform === 'darwin' && arch === 'arm64', 'Manual signed release verification requires macOS arm64.'); + // Revalidate and detach caller-owned mutable pins before any I/O. + const options = manualReleaseOptions({ repo: input.repo, tag: input.tag, commit: input.commit, + 'draft-id': input.draftId, 'team-id': input.teamId, publish: input.publish, + asset: [...input.pins].map(([name, hash]) => `${name}=${hash}`) }); + const endpoint = path => `repos/${options.repo}/${path}`; + const api = async (path, args = []) => { + const result = await run('gh', ['api', '--hostname', 'github.com', endpoint(path), ...args]); + demand(typeof result?.stdout === 'string' && Buffer.byteLength(result.stdout) <= 8 * 1024 * 1024, + 'GitHub API response is missing or oversized.'); + try { return JSON.parse(result.stdout); } catch { throw new Error('GitHub API response must be valid JSON.'); } + }; + const readDraft = async () => { + const release = await api(`releases/${options.draftId}`); + demand(record(release), 'Unexpected release response.'); + const pages = await api(`releases/${options.draftId}/assets`, ['--paginate', '--slurp']); + demand(Array.isArray(pages) && pages.every(Array.isArray), 'Unexpected release asset response.'); + release.assets = pages.flat(); + validateManualDraft(release, options); + return release; + }; + const before = await readDraft(); + const snapshot = releaseSnapshot(before); + const readTag = () => resolveReleaseTag({ tag: options.tag, expectedCommit: options.commit, allowAbsent: true }, api); + const tagSnapshot = JSON.stringify(await readTag()); + demand((await api(`git/commits/${options.commit}`))?.sha === options.commit, 'The supplied commit is not a remote Git commit.'); + const sourceFile = path => api(`contents/${path}?ref=${options.commit}`, ['--header', 'Accept: application/vnd.github.raw+json']); + const source = await sourceFile('package.json'); + demand(record(source) && source.name === PACKAGE_NAME && source.version === options.version + && typeof source.desktopVersion === 'string', 'Pinned commit package/version does not match the release tag.'); + const config = await sourceFile('src-tauri/tauri.conf.json'); + const minimumSystemVersion = config?.bundle?.macOS?.minimumSystemVersion; + demand(typeof minimumSystemVersion === 'string', 'Pinned commit must declare the minimum macOS version.'); + const checkHistory = async () => { + const history = await collectHistory({ repo: options.repo }, { run }); + const floor = validateDesktopVersionFloor({ candidateDesktopVersion: source.desktopVersion, + priorPublished: history.priorPublished, historyComplete: history.historyComplete }); + return { floor: floor.floor, snapshot: JSON.stringify([...history.priorPublished].sort((a, b) => a.id - b.id)) }; + }; + const history = await checkHistory(); + const root = await realpath(await mkdtemp(join(tmpdir(), 'gajae-manual-release-'))); + let preserveDirectory = false; + let publicationRequested = false; + try { + await assertOutOfTree(root, 'Manual release verification'); + const runtimeManifestPath = join(root, 'source-runtime-manifest.json'); + await run('gh', ['api', '--hostname', 'github.com', endpoint(`contents/server/gjc-runtime-manifest.json?ref=${options.commit}`), + '--header', 'Accept: application/vnd.github.raw+json'], { output: runtimeManifestPath, maxOutputBytes: metadataLimit }); + demand(record(JSON.parse(await readUpdaterSidecar(runtimeManifestPath, metadataLimit))), 'Pinned runtime manifest must be a JSON object.'); + const runtimeManifestSha256 = await fileHash(runtimeManifestPath, undefined, metadataLimit); + const hashes = {}; + for (const asset of before.assets) { + const output = join(root, asset.name); + await run('gh', ['api', '--hostname', 'github.com', endpoint(`releases/assets/${asset.id}`), + '--header', 'Accept: application/octet-stream'], { output, timeout: 600_000, maxOutputBytes: asset.size }); + const hash = await fileHash(output, asset.size); + if (asset.digest) demand(asset.digest === `sha256:${hash}`, 'Downloaded asset differs from its GitHub digest.'); + if (options.pins.has(asset.name)) demand(options.pins.get(asset.name) === hash, + 'Downloaded payload differs from its independent pin.'); + await chmod(output, 0o400); + hashes[asset.name] = hash; + } + for (const [name, hash] of options.pins) assertChecksum( + await readUpdaterSidecar(join(root, `${name}.sha256`), UPDATER_ASSET_LIMITS.maxChecksumBytes), name, hash); + const archive = join(root, options.names.server.archive); + const members = (await run('tar', ['-tzf', archive])).stdout.split('\n').filter(name => name === 'package.json' || name === './package.json'); + demand(members.length === 1, 'Server archive must have exactly one root package.json.'); + const server = JSON.parse((await run('tar', ['-xOzf', archive, '--', members[0]])).stdout); + demand(server.name === SERVER_PACKAGE_NAME && server.version === options.version, 'Server archive package/version does not match the release tag.'); + const verified = await verifyMac({ dmg: join(root, options.names.macos.dmg), root, teamId: options.teamId, + version: options.version, desktopVersion: source.desktopVersion, minimumSystemVersion, + manualDisabled: true, runtimeManifestSha256 }, { run }); + // The Mac helper's positive diagnostic is mandatory, never inferred from + // absent updater assets or a verifier that merely returns successfully. + const buildInfo = assertManualBuildInfo(JSON.stringify(verified?.buildInfo), + { version: options.version, desktopVersion: source.desktopVersion, runtimeManifestSha256, + payloadRuntimeManifestSha256: verified?.payloadRuntimeManifestSha256 }); + for (const asset of before.assets) demand(await fileHash(join(root, asset.name), asset.size) === hashes[asset.name], + 'Private asset snapshot changed during verification.'); + demand((await checkHistory()).snapshot === history.snapshot, 'Published desktop-version history changed during verification; publication refused.'); + demand(releaseSnapshot(await readDraft()) === snapshot, 'Draft metadata or assets changed during verification; publication refused.'); + demand(JSON.stringify(await readTag()) === tagSnapshot, 'Tag changed during verification; publication refused.'); + if (options.publish) { + let published; + try { + publicationRequested = true; + published = await api(`releases/${options.draftId}`, ['--method', 'PATCH', '--field', 'draft=false']); + } catch { + throw new Error('Publication request failed; its outcome may be unknown. Inspect the exact release ID before any retry. No automatic retry or rollback is performed.'); + } + demand(record(published) && published.id === options.draftId && published.draft === false + && typeof published.published_at === 'string' && Array.isArray(published.assets) + && releaseSnapshot(published) === snapshot, + 'Publication response is unexpected; inspect the exact release ID and assets. No automatic rollback is performed.'); + validUtcDate(published.published_at, 'Publication timestamp'); + } + return { status: options.publish ? 'published' : 'verified-draft', mode: 'manual-disabled', + repo: options.repo, draftId: options.draftId, tag: options.tag, commit: options.commit, teamId: options.teamId, + hashes, buildInfo, desktopVersionFloor: history.floor, + limits: ['Independent pins identify accepted builds, not reproducible-build provenance.', + 'Manual installation only. Runtime, data-survival, GUI and Linux acceptance remain separate prerequisites.', + 'Optional Linux desktop assets receive hash/sidecar verification only.', + 'A single publisher is required: final rechecks and publication are not atomic.'] }; + } catch (error) { + preserveDirectory = error.preserveDirectory === true; + if (publicationRequested) error.publicationMayHaveOccurred = true; + throw error; + } finally { + if (!preserveDirectory) await rm(root, { recursive: true, force: true }).catch(() => { + throw Object.assign(new Error(`Temporary cleanup failed; inspect ${root} and the release ID.`), + { publicationMayHaveOccurred: publicationRequested }); + }); + } +} + +const usage = `Usage: node scripts/release/manual-release.mjs --repo OWNER/REPO --draft-id ID + --tag vVERSION --commit FULL_SHA --team-id TEAMID1234 + --asset CANONICAL_DMG=SHA256 --asset CANONICAL_LINUX_NODE22_SERVER=SHA256 + [--asset CANONICAL_LINUX_DEB_OR_APPIMAGE=SHA256] [--publish] + +Default: verify an existing draft without changing it. --publish repeats all +checks then changes only draft=false on that exact numeric release ID. +Requires signed/notarized macOS arm64 binaries proven updater-disabled by +--desktop-build-info. No updater keys, archives, signatures or manifests. +See MANUAL-RELEASE.md. Never creates drafts, uploads assets, signs or installs. +`; + +async function main() { + let options; + try { + const { values, tokens } = parseArgs({ tokens: true, options: { + ...Object.fromEntries(optionNames.map(name => [name, { type: 'string' }])), + asset: { type: 'string', multiple: true }, publish: { type: 'boolean' }, help: { type: 'boolean' }, + } }); + const seen = new Set(); + for (const token of tokens) { + demand(token.kind === 'option' && (token.name === 'asset' || !seen.has(token.name)), 'Duplicate or positional CLI argument.'); + seen.add(token.name); + } + if (values.help) { process.stdout.write(usage); return; } + options = manualReleaseOptions(values); + } catch { + process.stderr.write(usage); + process.exitCode = 2; + return; + } + try { + process.stdout.write(`${JSON.stringify(await processManualRelease(options), null, 2)}\n`); + } catch (error) { + process.stderr.write(`${JSON.stringify({ status: error.publicationMayHaveOccurred ? 'publication-outcome-unknown' : 'blocked', error: error.message })}\n`); + process.exitCode = 1; + } +} + +function isDirectInvocation() { + if (!process.argv[1]) return false; + try { return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); } catch { return false; } +} + +if (isDirectInvocation()) await main(); diff --git a/scripts/release/manual-release.test.mjs b/scripts/release/manual-release.test.mjs new file mode 100644 index 00000000..4bbb638c --- /dev/null +++ b/scripts/release/manual-release.test.mjs @@ -0,0 +1,404 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { chmod, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { releaseCommand } from './local-release-command.mjs'; +import { manualReleaseOptions, processManualRelease } from './manual-release.mjs'; +import { assetNames } from './updater-artifacts.mjs'; + +const version = '2.0.0-beta.10'; +const commit = 'a'.repeat(40); +const teamId = 'AB12345678'; +const names = assetNames({ productVersion: version }); +const sha = bytes => createHash('sha256').update(bytes).digest('hex'); +const runtimeManifest = Buffer.from('{\n "schemaVersion": 1, "runtime": "pinned source fixture"\n}\n'); +const buildInfo = { schemaVersion: 1, packageName: 'gajae-app', productVersion: version, + desktopVersion: '0.2.4', debug: false, updateMode: 'disabled', runtimeManifestSha256: sha(runtimeManifest), payloadRuntimeManifestSha256: 'e'.repeat(64) }; +const priorRelease = { id: 11, tag: 'v2.0.0-beta.9', productVersion: '2.0.0-beta.9', + desktopVersion: '0.2.3', commit: 'b'.repeat(40), publishedAt: '2026-09-06T00:00:00Z' }; + +async function fixture(t, { serverVersion = version, serverPackage = 'gajae-app-server' } = {}) { + const directory = await mkdtemp(join(tmpdir(), 'gajae-manual-release-test-')); + t.after(() => rm(directory, { recursive: true, force: true })); + await writeFile(join(directory, 'package.json'), JSON.stringify({ name: serverPackage, version: serverVersion })); + const archive = join(directory, names.server.archive); + await releaseCommand('tar', ['-czf', archive, '-C', directory, 'package.json']); + const files = new Map([[names.macos.dmg, Buffer.from('signed DMG fixture')], [names.server.archive, await readFile(archive)]]); + const values = { repo: 'devswha/gajae-code-app', tag: `v${version}`, commit, + 'draft-id': '123', 'team-id': teamId, asset: [...files].map(([name, body]) => `${name}=${sha(body)}`) }; + for (const [name, body] of [...files]) files.set(`${name}.sha256`, Buffer.from(`${sha(body)} ${name}\n`)); + const state = { files, values, calls: [], writes: [], reads: 0, historyReads: 0, macChecked: 0, + source: { name: 'gajae-app', version, desktopVersion: '0.2.4' }, tag: [], + history: { priorPublished: [priorRelease], historyComplete: true }, + sourceManifest: runtimeManifest, + release: { id: 123, tag_name: values.tag, target_commitish: commit, draft: true, published_at: null, + prerelease: true, name: 'Manual beta.10', body: 'Reviewed manual/updater-disabled notes.', + assets: [...files].map(([name, body], index) => ({ id: index + 1, name, label: '', size: body.length, + state: 'uploaded', digest: `sha256:${sha(body)}`, updated_at: '2026-09-07T00:00:00Z' })) }, + }; + state.run = async (program, args, options = {}) => { + state.calls.push({ program, args, options }); + if (program === 'tar') { + if (state.tarResult) return state.tarResult(args); + return releaseCommand(program, args, options); + } + assert.equal(program, 'gh', 'Tests never invoke signing, app installation or other programs.'); + assert.deepEqual(args.slice(0, 3), ['api', '--hostname', 'github.com']); + const path = args[3].replace('repos/devswha/gajae-code-app/', ''); + const json = value => ({ stdout: JSON.stringify(value), stderr: '' }); + if (args.includes('--method')) { + state.writes.push({ path, args }); + assert.equal(state.macChecked, 1); + assert.equal(state.reads, 2); + assert.equal(state.historyReads, 2); + if (state.failPatch) throw new Error('simulated transport failure'); + state.release.draft = false; + state.release.published_at = '2026-09-07T01:00:00Z'; + state.afterPatch?.(); + return json(state.release); + } + if (path === 'releases/123') { + state.reads++; + if (state.reads === 2) state.beforeRecheck?.(); + return json(state.release); + } + if (path === 'releases/123/assets') return json(state.assetPages ?? [state.release.assets]); + if (path === `git/matching-refs/tags/v${version}`) return json([state.tag]); + if (path.startsWith('git/tags/')) return json(state.annotation); + if (path === `git/commits/${commit}`) return json({ sha: state.remoteCommit ?? commit }); + if (path === `contents/package.json?ref=${commit}`) return json(state.source); + if (path === `contents/src-tauri/tauri.conf.json?ref=${commit}`) return json({ bundle: { macOS: { minimumSystemVersion: state.minimum ?? '13.0' } } }); + if (path === `contents/server/gjc-runtime-manifest.json?ref=${commit}`) { + assert.equal(options.maxOutputBytes, 64 * 1024); + await writeFile(options.output, state.sourceManifest, { flag: 'wx', mode: 0o600 }); + return { stdout: '', stderr: '' }; + } + if (path.startsWith('releases/assets/')) { + const asset = state.release.assets.find(item => item.id === Number(path.split('/').at(-1))); + assert.ok(asset && Number.isSafeInteger(asset.id)); + assert.equal(options.maxOutputBytes, asset.size); + assert.equal(options.timeout, 600_000); + assert.equal((await stat(dirname(options.output))).mode & 0o777, 0o700); + if (state.download) await state.download(asset, options.output); + else await writeFile(options.output, state.files.get(asset.name), { flag: 'wx', mode: 0o600 }); + return { stdout: '', stderr: '' }; + } + assert.fail(`Unexpected API call ${path}`); + }; + state.collectHistory = async () => { + state.historyReads++; + if (state.historyReads === 2) state.beforeHistoryRecheck?.(); + return structuredClone(state.history); + }; + state.verifyMac = async input => { + state.macChecked++; + state.macInput = input; + assert.equal(input.manualDisabled, true); + assert.equal(input.updaterArchivePath, undefined); + assert.equal(input.teamId, teamId); + assert.equal(input.version, version); + assert.equal(input.desktopVersion, '0.2.4'); + assert.equal(input.minimumSystemVersion, '13.0'); + assert.equal(input.runtimeManifestSha256, sha(state.sourceManifest)); + assert.equal((await stat(input.dmg)).mode & 0o777, 0o400); + if (state.macError) throw state.macError; + if (state.duringMac) await state.duringMac(input); + return state.macResult ?? { copiedApp: join(input.root, 'copy/Gajae Code App.app'), buildInfo, payloadRuntimeManifestSha256: 'e'.repeat(64) }; + }; + state.execute = overrides => processManualRelease(manualReleaseOptions({ ...state.values, ...overrides }), { + run: state.run, verifyMac: state.verifyMac, collectHistory: state.collectHistory, platform: 'darwin', arch: 'arm64', + }); + return state; +} + +function addPayload(state, name) { + const bytes = Buffer.from('independently accepted optional Linux payload'); + state.values.asset.push(`${name}=${sha(bytes)}`); + for (const [filename, body] of [[name, bytes], [`${name}.sha256`, Buffer.from(`${sha(bytes)} ${name}\n`)]]) { + state.files.set(filename, body); + state.release.assets.push({ id: state.release.assets.length + 1, name: filename, size: body.length, + state: 'uploaded', digest: `sha256:${sha(body)}`, updated_at: '2026-09-07T00:00:00Z' }); + } +} + +test('default manual lane verifies four assets, positive disabled evidence and fresh inputs without any write', async t => { + const state = await fixture(t); + const result = await state.execute(); + assert.equal(result.status, 'verified-draft'); + assert.equal(result.mode, 'manual-disabled'); + assert.deepEqual(result.buildInfo, buildInfo); + assert.equal(result.desktopVersionFloor, '0.2.3'); + assert.equal(Object.keys(result.hashes).length, 4); + assert.equal(state.reads, 2); + assert.equal(state.historyReads, 2); + assert.deepEqual(state.writes, []); + assert.ok(!state.calls.some(call => call.args.some(arg => /updater|\.sig|\.app\.tar\.gz/.test(arg)))); + await assert.rejects(stat(state.macInput.root), { code: 'ENOENT' }); +}); + +test('explicit publish issues exactly one numeric-ID PATCH draft=false after every gate and preserves notes/assets', async t => { + const state = await fixture(t); + const before = structuredClone(state.release); + const result = await state.execute({ publish: true }); + assert.equal(result.status, 'published'); + assert.deepEqual(state.writes, [{ path: 'releases/123', args: ['api', '--hostname', 'github.com', + 'repos/devswha/gajae-code-app/releases/123', '--method', 'PATCH', '--field', 'draft=false'] }]); + assert.deepEqual(state.release, { ...before, draft: false, published_at: '2026-09-07T01:00:00Z' }); +}); + +test('CLI options reject updater material, unlisted payloads, unsafe IDs/refs, missing and duplicate pins', async t => { + const state = await fixture(t); + for (const change of [ + { repo: 'other/repo' }, { tag: 'latest' }, { tag: 'v02.0.0-beta.10' }, { tag: 'v2.0.0-rc.1' }, + { commit: 'main' }, { commit: 'a'.repeat(7) }, { commit: 'A'.repeat(40) }, + { 'draft-id': 0 }, { 'draft-id': '01' }, { 'draft-id': '-1' }, { 'draft-id': '9007199254740993' }, + { 'draft-id': '123?x=1' }, { 'team-id': undefined }, { publish: 'true' }, + { 'updater-public-key-file': '/not/allowed' }, { mode: 'local' }, { asset: [] }, + { asset: [state.values.asset[0]] }, { asset: [...state.values.asset, state.values.asset[0]] }, + ...[names.macos.archive, names.macos.archiveSignature, names.macos.manifest, + names.macos.dmgChecksum, '../evil', `gajae-app-extra-${version}.zip`, + names.optional.linuxDeb.toUpperCase()].map(name => ({ asset: [...state.values.asset, `${name}=${'a'.repeat(64)}`] })), + { asset: [state.values.asset[0], `${names.server.archive}=${'A'.repeat(64)}`] }, + { asset: [state.values.asset[0], `${state.values.asset[1]}=`] }, + ]) assert.throws(() => manualReleaseOptions({ ...state.values, ...change })); + assert.equal(manualReleaseOptions(state.values).publish, false); +}); + +test('exact deb/AppImage optional pairs are accepted only with their independent pins', async t => { + const state = await fixture(t); + for (const name of names.optionalPayloads) addPayload(state, name); + assert.equal(Object.keys((await state.execute()).hashes).length, 8); + state.values.asset.pop(); + await assert.rejects(state.execute({ publish: true }), /cardinality/); + assert.deepEqual(state.writes, []); +}); + +test('invalid release metadata and extra or duplicate assets fail before any download', async t => { + for (const change of [ + release => { release.id = '123'; }, release => { release.id = 456; }, release => { release.draft = false; }, + release => { release.draft = 'true'; }, release => { release.published_at = '2026-09-07T00:00:00Z'; }, + release => { release.tag_name = 'v2.0.0-beta.9'; }, release => { release.target_commitish = 'main'; }, + release => { release.prerelease = false; }, release => { release.assets.pop(); }, + release => { release.assets.push({ ...release.assets[0], id: 777, name: names.macos.manifest }); }, + release => { release.assets[0].name = names.macos.archive; }, + release => { release.assets[0].name = release.assets[1].name; }, + release => { release.assets[0].id = release.assets[1].id; }, + release => { release.assets[0].id = '1'; }, release => { release.assets[0].id = 0; }, + release => { release.assets[0].state = 'starter'; }, release => { delete release.assets[0].state; }, + release => { release.assets[0].size = 0; }, release => { release.assets[0].size = 250 * 1024 ** 2 + 1; }, + release => { release.assets[2].size = 1025; }, release => { release.assets[0].digest = 'bad'; }, + release => { release.assets[0].updated_at = 'yesterday'; }, release => { release.assets[0].label = '\u0000'; }, + ]) { + const state = await fixture(t); + change(state.release); + await assert.rejects(state.execute({ publish: true })); + assert.deepEqual(state.writes, []); + assert.ok(!state.calls.some(call => call.options.output)); + } +}); + +test('separately paginated asset listing cannot conceal extra pages or malformed responses', async t => { + for (const pages of [[], {}, [null], [[{ id: 500, name: names.macos.manifest }]]]) { + const state = await fixture(t); + state.assetPages = Array.isArray(pages) && pages[0]?.[0]?.id === 500 ? [state.release.assets, ...pages] : pages; + await assert.rejects(state.execute({ publish: true })); + assert.deepEqual(state.writes, []); + } +}); + +test('both absent GitHub digests and matching digests still require actual independent byte hashes', async t => { + const valid = await fixture(t); + for (const asset of valid.release.assets) delete asset.digest; + assert.equal((await valid.execute()).status, 'verified-draft'); + for (const changeDigest of [false, true]) { + const state = await fixture(t); + const replacement = Buffer.from('replacement DMG bytes'); + state.files.set(names.macos.dmg, replacement); + Object.assign(state.release.assets[0], { size: replacement.length, digest: changeDigest ? `sha256:${sha(replacement)}` : null }); + await assert.rejects(state.execute({ publish: true }), /independent pin/); + assert.deepEqual(state.writes, []); + } +}); + +test('malformed checksum sidecars cannot redirect, append entries or disagree with independent pins', async t => { + for (const checksum of ['wrong', `${'a'.repeat(64)} ${names.macos.dmg}\n`, + `${sha('signed DMG fixture')} ../${names.macos.dmg}\n`, + `${sha('signed DMG fixture')} ${names.macos.dmg}\nextra\n`]) { + const state = await fixture(t); + const bytes = Buffer.from(checksum); + state.files.set(names.macos.dmgChecksum, bytes); + Object.assign(state.release.assets.find(asset => asset.name === names.macos.dmgChecksum), { size: bytes.length, digest: `sha256:${sha(bytes)}` }); + await assert.rejects(state.execute({ publish: true }), /Checksum sidecar/); + assert.deepEqual(state.writes, []); + } +}); + +test('source commit, package, server metadata and runtime-manifest mismatches block publication', async t => { + for (const change of [ + state => { state.remoteCommit = 'b'.repeat(40); }, state => { state.source.name = 'other'; }, + state => { state.source.version = '2.0.0-beta.9'; }, state => { state.sourceManifest = Buffer.from('{}\n'); }, + state => { state.sourceManifest = Buffer.from('[]'); }, state => { state.sourceManifest = Buffer.from('bad JSON'); }, + state => { state.minimum = null; }, + ]) { + const state = await fixture(t); + change(state); + // Null simulates missing config explicitly, without fixture's default. + if (state.minimum === null) state.minimum = 13; + await assert.rejects(state.execute({ publish: true })); + assert.deepEqual(state.writes, []); + } + for (const options of [{ serverVersion: '2.0.0-beta.9' }, { serverPackage: 'gajae-app' }]) { + const state = await fixture(t, options); + await assert.rejects(state.execute({ publish: true }), /Server archive package/); + assert.deepEqual(state.writes, []); + } + for (const listing of ['', 'package.json\n./package.json\n', 'elsewhere/package.json\n']) { + const state = await fixture(t); + state.tarResult = () => ({ stdout: listing, stderr: '' }); + await assert.rejects(state.execute({ publish: true }), /exactly one root/); + assert.deepEqual(state.writes, []); + } +}); + +test('signature failures and missing or non-disabled binary evidence never publish', async t => { + for (const change of [ + state => { state.macError = new Error('Gatekeeper rejected app'); }, + state => { state.macResult = {}; }, + state => { state.macResult = { buildInfo: { ...buildInfo, debug: true } }; }, + state => { state.macResult = { buildInfo: { ...buildInfo, updateMode: 'enabled' } }; }, + state => { state.macResult = { buildInfo: { ...buildInfo, runtimeManifestSha256: 'd'.repeat(64) } }; }, + ]) { + const state = await fixture(t); + change(state); + await assert.rejects(state.execute({ publish: true })); + assert.deepEqual(state.writes, []); + } +}); + +test('history must be complete and advancing; changes during verification refuse publication', async t => { + for (const change of [ + state => { state.history.historyComplete = false; }, state => { state.source.desktopVersion = '0.2.3'; }, + state => { state.history.priorPublished[0] = { ...priorRelease, desktopVersion: '0.2.4' }; }, + state => { state.beforeHistoryRecheck = () => { state.history.historyComplete = false; }; }, + state => { state.beforeHistoryRecheck = () => { state.history.priorPublished[0] = { ...priorRelease, commit: 'c'.repeat(40) }; }; }, + ]) { + const state = await fixture(t); + change(state); + await assert.rejects(state.execute({ publish: true })); + assert.deepEqual(state.writes, []); + } +}); + +test('fresh draft and tag checks reject all reviewed-input races', async t => { + for (const change of [ + state => { state.release.assets[0].id = 999; }, state => { state.release.body = 'changed'; }, + state => { state.release.name = 'changed'; }, state => { state.release.prerelease = false; }, + state => { state.release.draft = false; }, state => { state.release.target_commitish = 'main'; }, + state => { state.release.assets[0].updated_at = '2026-09-07T01:00:00Z'; }, + state => { state.tag = [{ ref: `refs/tags/v${version}`, object: { type: 'commit', sha: commit } }]; }, + ]) { + const state = await fixture(t); + state.beforeRecheck = () => change(state); + await assert.rejects(state.execute({ publish: true })); + assert.equal(state.macChecked, 1); + assert.deepEqual(state.writes, []); + } +}); + +test('existing lightweight and annotated tags must resolve to the exact full commit', async t => { + for (const annotated of [false, true]) { + const state = await fixture(t); + state.tag = [{ ref: `refs/tags/v${version}`, object: { type: annotated ? 'tag' : 'commit', sha: annotated ? 'b'.repeat(40) : commit } }]; + state.annotation = { object: { type: 'commit', sha: commit } }; + assert.equal((await state.execute()).status, 'verified-draft'); + } + const wrong = await fixture(t); + wrong.tag = [{ ref: `refs/tags/v${version}`, object: { type: 'commit', sha: 'b'.repeat(40) } }]; + await assert.rejects(wrong.execute({ publish: true }), /expected commit/); + assert.deepEqual(wrong.writes, []); +}); + +test('download truncation, symlinks and post-verification byte changes fail closed', async t => { + for (const download of [ + async (_, output) => writeFile(output, 'short', { flag: 'wx' }), + async (_, output) => symlink('/dev/null', output), + ]) { + const state = await fixture(t); + state.download = download; + await assert.rejects(state.execute({ publish: true })); + assert.deepEqual(state.writes, []); + } + const changed = await fixture(t); + changed.duringMac = async input => { + await chmod(input.dmg, 0o600); + await writeFile(input.dmg, 'altered DMG bytes!'); + }; + await assert.rejects(changed.execute({ publish: true }), /size|snapshot changed/); + assert.deepEqual(changed.writes, []); +}); + +test('failed detachment preserves its directory and never claims publication', async t => { + const state = await fixture(t); + state.macError = Object.assign(new Error('Could not confirm image detachment'), { preserveDirectory: true }); + await assert.rejects(state.execute({ publish: true }), error => error.preserveDirectory && !error.publicationMayHaveOccurred); + assert.ok((await stat(state.macInput.root)).isDirectory()); + assert.deepEqual(state.writes, []); + // This fixture has no real mount; only its synthetic retained directory is removed. + await rm(state.macInput.root, { recursive: true, force: true }); +}); + +test('transport or response uncertainty after PATCH never retries or rolls back, and marks the outcome unknown', async t => { + for (const change of [ + state => { state.failPatch = true; }, + state => { state.afterPatch = () => { state.release.assets[0].id = 999; }; }, + state => { state.afterPatch = () => { state.release.draft = true; }; }, + state => { state.afterPatch = () => { state.release.published_at = null; }; }, + state => { state.afterPatch = () => { state.release.published_at = 'bad'; }; }, + ]) { + const state = await fixture(t); + change(state); + await assert.rejects(state.execute({ publish: true }), error => error.publicationMayHaveOccurred === true); + assert.equal(state.writes.length, 1); + await assert.rejects(stat(state.macInput.root), { code: 'ENOENT' }); + } +}); + +test('CLI rejects missing/duplicate/unknown flags without leaking arguments; imports and symlink entry are safe', async t => { + const script = fileURLToPath(new URL('./manual-release.mjs', import.meta.url)); + for (const args of [[], ['--publish'], ['--password', 'DO-NOT-PRINT'], ['--mode', 'ci'], + ['--updater-public-key-file', 'DO-NOT-PRINT'], ['--repo', 'first', '--repo', 'second'], ['--publish', '--publish']]) { + const result = spawnSync(process.execPath, [script, ...args], { encoding: 'utf8' }); + assert.equal(result.status, 2); + assert.ok(!`${result.stdout}${result.stderr}`.includes('DO-NOT-PRINT')); + } + const directory = await mkdtemp(join(tmpdir(), 'gajae-manual-entry-test-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const linked = join(directory, 'manual.mjs'); + await symlink(script, linked); + const help = spawnSync(process.execPath, [linked, '--help'], { encoding: 'utf8' }); + assert.equal(help.status, 0); + assert.match(help.stdout, /Usage:/); + const imported = spawnSync(process.execPath, ['--input-type=module', '-e', + `process.argv.length = 1; await import(${JSON.stringify(new URL('./manual-release.mjs', import.meta.url).href)});`], { encoding: 'utf8' }); + assert.equal(imported.status, 0); + assert.equal(imported.stdout, ''); +}); + +test('diagnostic command transport enforces the configured hard stdout cap and timeout', async t => { + const directory = await mkdtemp(join(tmpdir(), 'gajae-manual-command-test-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const output = join(directory, 'bounded.json'); + await assert.rejects(releaseCommand(process.execPath, ['-e', 'process.stdout.write(Buffer.alloc(4097))'], + { output, timeout: 10_000, maxOutputBytes: 4096 }), /file output limit/); + assert.ok((await stat(output)).size <= 4096); + await assert.rejects(releaseCommand(process.execPath, ['-e', 'setInterval(()=>{},1000)'], + { output: join(directory, 'timeout.json'), timeout: 20, maxOutputBytes: 4096 }), /timed out/); + await assert.rejects(releaseCommand(process.execPath, ['-e', 'process.stdout.write("overwrite")'], + { output, timeout: 10_000, maxOutputBytes: 4096 }), { code: 'EEXIST' }); + assert.equal((await stat(output)).mode & 0o777, 0o600); +}); diff --git a/scripts/release/rebuild-signed-macos-desktop.mjs b/scripts/release/rebuild-signed-macos-desktop.mjs new file mode 100644 index 00000000..e306a80b --- /dev/null +++ b/scripts/release/rebuild-signed-macos-desktop.mjs @@ -0,0 +1,90 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { constants } from 'node:fs'; +import { copyFile, lstat, open, realpath } from 'node:fs/promises'; +import { dirname, isAbsolute, join, resolve, sep } from 'node:path'; +import { promisify } from 'node:util'; + +import { desktopTargetDirectory } from './desktop-platforms.mjs'; + +const capture = promisify(execFile); +const MAX_MANIFEST_BYTES = 64 * 1024; +const TARGET = 'aarch64-apple-darwin'; + +async function appFile(appPath, path) { + const app = await realpath(appPath); + const parent = await realpath(dirname(path)); + if (!parent.startsWith(`${app}${sep}`)) throw new Error('Finalization file must remain inside the passed app.'); + const stat = await lstat(path); + if (!stat.isFile() || stat.nlink !== 1) throw new Error('Finalization requires a singly linked regular app file, not a symlink or hard link.'); +} + +/** Validate both app-local manifests before signing/restamping can write them. */ +export async function readAppRuntimeManifests(appPath) { + const payload = join(appPath, 'Contents/Resources/resources/server-payload'); + const paths = ['server', 'dist-server/server'].map(location => join(payload, location, 'gjc-runtime-manifest.json')); + const bytes = []; + for (const path of paths) { + await appFile(appPath, path); + const fd = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const start = await fd.stat(); + if (!start.isFile() || start.nlink !== 1 || start.size === 0 || start.size > MAX_MANIFEST_BYTES) { + throw new Error('App runtime manifests must be nonempty regular files bounded to 64 KiB.'); + } + // A bounded buffer plus an extra byte detects growth without buffering it. + const buffer = Buffer.alloc(MAX_MANIFEST_BYTES + 1); + let size = 0; + while (size < buffer.length) { + const result = await fd.read(buffer, size, buffer.length - size, size); + if (result.bytesRead === 0) break; + size += result.bytesRead; + } + const end = await fd.stat(); + if (size !== start.size || end.size !== start.size || end.mtimeMs !== start.mtimeMs || end.ctimeMs !== start.ctimeMs) { + throw new Error('App runtime manifest changed while reading.'); + } + bytes.push(buffer.subarray(0, size)); + } finally { await fd.close(); } + } + return { paths, bytes }; +} + +async function run(command, args, options) { + try { await capture(command, args, { ...options, maxBuffer: 16 * 1024 * 1024 }); } + catch { throw new Error('Signed-manifest desktop rebuild failed; no app executable was copied.'); } +} + +/** Rebuild only the shell; never rebuild, sign or restamp the nested payload. */ +export async function rebuildSignedMacosDesktop({ rootDir, appPath, inheritedEnv = process.env }, { + execute = run, resolveTargetDirectory = desktopTargetDirectory, +} = {}) { + rootDir = resolve(rootDir); + appPath = await realpath(appPath); + const { bytes } = await readAppRuntimeManifests(appPath); + if (!bytes[0].equals(bytes[1])) throw new Error('Both restamped runtime manifests must be byte-identical.'); + const payloadRuntimeManifestSha256 = createHash('sha256').update(bytes[0]).digest('hex'); + const env = { ...inheritedEnv, GJC_SIGNED_RUNTIME_MANIFEST_SHA256: payloadRuntimeManifestSha256 }; + const targetDir = await resolveTargetDirectory(rootDir, env); + if (typeof targetDir !== 'string' || !isAbsolute(targetDir)) throw new Error('Cargo target directory must be absolute.'); + const desktop = join(appPath, 'Contents/MacOS/gajae-app-desktop'); + await appFile(appPath, desktop); + await execute('cargo', ['build', '--manifest-path', join(rootDir, 'src-tauri/Cargo.toml'), + '--locked', '--release', '--target', TARGET, '--features', 'tauri/custom-protocol'], { + cwd: join(rootDir, 'src-tauri'), env, + }); + const rebuiltDesktop = join(targetDir, TARGET, 'release/gajae-app-desktop'); + const stat = await lstat(rebuiltDesktop); + if (!stat.isFile() || stat.size === 0 || (stat.mode & 0o111) === 0) { + throw new Error('Rebuilt desktop must be a nonempty regular executable.'); + } + await execute('lipo', [rebuiltDesktop, '-verify_arch', 'arm64'], { cwd: join(rootDir, 'src-tauri'), env }); + // Never use an old path/manifest snapshot to bind newly copied executable bytes. + const after = await readAppRuntimeManifests(appPath); + if (!after.bytes.every((value, index) => value.equals(bytes[index]))) { + throw new Error('Restamped runtime manifests changed during desktop rebuild.'); + } + await appFile(appPath, desktop); + await copyFile(rebuiltDesktop, desktop); + return { desktop, rebuiltDesktop, payloadRuntimeManifestSha256 }; +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ed3503d0..6095b86e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1029,7 +1029,7 @@ dependencies = [ [[package]] name = "gajae-app-desktop" -version = "0.2.3" +version = "0.2.4" dependencies = [ "base64 0.22.1", "flate2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 115110b0..b7c2698d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gajae-app-desktop" -version = "0.2.3" +version = "0.2.4" description = "Gajae Code App desktop shell" license = "MIT" authors = ["Gajae Code App contributors"] diff --git a/src-tauri/build.rs b/src-tauri/build.rs index c06bc729..1b41fc06 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -9,6 +9,7 @@ fn main() { println!("cargo:rerun-if-changed=../package.json"); println!("cargo:rerun-if-changed=../server/gjc-runtime-manifest.json"); println!("cargo:rerun-if-changed=update_build_binding.rs"); + println!("cargo:rerun-if-env-changed=GJC_SIGNED_RUNTIME_MANIFEST_SHA256"); for name in update_build_binding::INPUT_ENV_NAMES { println!("cargo:rerun-if-env-changed={name}"); } @@ -113,10 +114,17 @@ fn main() { !runtime_manifest.is_empty() && runtime_manifest.len() <= 64 * 1024, "source runtime manifest is empty or oversized" ); - println!( - "cargo:rustc-env=GJC_EXPECTED_RUNTIME_MANIFEST_SHA256={:x}", - Sha256::digest(&runtime_manifest) - ); + let source_digest = format!("{:x}", Sha256::digest(&runtime_manifest)); + let signed_digest = env::var("GJC_SIGNED_RUNTIME_MANIFEST_SHA256").ok(); + let expected_digest = update_build_binding::signed_runtime_digest( + &source_digest, + signed_digest.as_deref(), + &inputs.target_os, + env::var("PROFILE").is_ok_and(|profile| profile == "release"), + ) + .expect("invalid final signed runtime binding"); + println!("cargo:rustc-env=GJC_SOURCE_RUNTIME_MANIFEST_SHA256={source_digest}"); + println!("cargo:rustc-env=GJC_EXPECTED_RUNTIME_MANIFEST_SHA256={expected_digest}"); tauri_build::build() } diff --git a/src-tauri/src/build_info.rs b/src-tauri/src/build_info.rs new file mode 100644 index 00000000..4da6ef5a --- /dev/null +++ b/src-tauri/src/build_info.rs @@ -0,0 +1,93 @@ +//! Read-only, compile-bound release diagnostics. This CLI exits before any +//! webview, profile, keychain, updater cache, instance lock or sidecar is created. +use std::ffi::OsString; + +use serde::Serialize; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct BuildInfo { + schema_version: u8, + package_name: &'static str, + product_version: &'static str, + desktop_version: &'static str, + debug: bool, + update_mode: &'static str, + runtime_manifest_sha256: &'static str, + payload_runtime_manifest_sha256: &'static str, +} + +pub(crate) fn handle_cli( + args: impl IntoIterator, +) -> Result, String> { + let args: Vec<_> = args.into_iter().collect(); + if !args.iter().any(|arg| arg == "--desktop-build-info") { + return Ok(None); + } + if args.len() != 1 { + return Err("--desktop-build-info must be used alone.".into()); + } + let info = BuildInfo { + schema_version: 1, + package_name: env!("GJC_EXPECTED_PAYLOAD_PACKAGE_NAME"), + product_version: env!("GJC_EXPECTED_PAYLOAD_VERSION"), + desktop_version: env!("CARGO_PKG_VERSION"), + debug: cfg!(debug_assertions), + update_mode: env!("GJC_UPDATE_MODE"), + runtime_manifest_sha256: env!("GJC_SOURCE_RUNTIME_MANIFEST_SHA256"), + payload_runtime_manifest_sha256: env!("GJC_EXPECTED_RUNTIME_MANIFEST_SHA256"), + }; + serde_json::to_string(&info) + .map(Some) + .map_err(|_| "Could not encode desktop build info.".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn diagnostics_are_exact_public_build_constants() { + let result = handle_cli([OsString::from("--desktop-build-info")]) + .unwrap() + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(value.as_object().unwrap().len(), 8); + assert_eq!(value["schemaVersion"], 1); + assert_eq!( + value["packageName"], + env!("GJC_EXPECTED_PAYLOAD_PACKAGE_NAME") + ); + assert_eq!( + value["productVersion"], + env!("GJC_EXPECTED_PAYLOAD_VERSION") + ); + assert_eq!(value["desktopVersion"], env!("CARGO_PKG_VERSION")); + assert_eq!(value["debug"], cfg!(debug_assertions)); + assert_eq!(value["updateMode"], env!("GJC_UPDATE_MODE")); + assert_eq!( + value["runtimeManifestSha256"], + env!("GJC_SOURCE_RUNTIME_MANIFEST_SHA256") + ); + assert_eq!( + value["payloadRuntimeManifestSha256"], + env!("GJC_EXPECTED_RUNTIME_MANIFEST_SHA256") + ); + assert!(!result.contains("publicKey") && !result.contains("qaRoot")); + } + + #[test] + fn only_explicit_standalone_diagnostic_request_is_handled() { + assert!(handle_cli(Vec::::new()).unwrap().is_none()); + assert!( + handle_cli([OsString::from("--qa-profile"), OsString::from("/qa")]) + .unwrap() + .is_none() + ); + for args in [ + vec!["--desktop-build-info", "--qa-profile", "/qa"], + vec!["--desktop-build-info", "--desktop-build-info"], + ] { + assert!(handle_cli(args.into_iter().map(OsString::from)).is_err()); + } + } +} diff --git a/src-tauri/src/expected_payload.rs b/src-tauri/src/expected_payload.rs index 5384c783..38a3c26c 100644 --- a/src-tauri/src/expected_payload.rs +++ b/src-tauri/src/expected_payload.rs @@ -1,8 +1,11 @@ //! Read-only payload identity checks, independent of the supervised process. //! //! `build.rs` must bind package name/product version and the SHA-256 of the source -//! `server/gjc-runtime-manifest.json` into the executable. Never derive these -//! expectations from the installed payload, a ready frame, health, or runtime +//! `server/gjc-runtime-manifest.json` into the executable. For a signed macOS +//! release, finalization rebuilds the desktop with the finalized payload digest +//! before sealing the app; the original source digest stays separate for release +//! diagnostics. Never derive these expectations at runtime from the installed +//! payload, a ready frame, health, or runtime //! environment variables. Missing build inputs fail closed, even with updates //! disabled. This module does not enable updates or access update state. //! @@ -426,6 +429,24 @@ mod tests { .is_err()); } + #[test] + fn signed_manifest_requires_its_final_compiled_digest_without_a_runtime_fallback() { + let original = bytes(&manifest()); + let mut signed = manifest(); + signed["platforms"]["darwin-arm64"]["files"][0]["sha256"] = json!("f".repeat(64)); + let signed = bytes(&signed); + // Code signing legitimately changes native bytes. The pre-sign binding + // must reject those bytes; the finalization rebuild supplies the exact + // post-sign digest rather than trusting a mutable receipt at startup. + assert!(expected(&original) + .verify_manifests(&signed, &signed) + .is_err()); + assert!(expected(&signed).verify_manifests(&signed, &signed).is_ok()); + assert!(expected(&signed) + .verify_manifests(&original, &original) + .is_err()); + } + fn reject_even_with_matching_digest(value: &Value) { let source = bytes(value); assert!(expected(&source) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index c9c92079..9b7af49b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -7,6 +7,7 @@ use std::fs::OpenOptions; use fs2::FileExt; use tauri::Manager; +mod build_info; mod desktop_origin; mod expected_payload; #[cfg(target_os = "linux")] @@ -250,6 +251,17 @@ fn retry_desktop_server(app: tauri::AppHandle) { } fn main() { + match build_info::handle_cli(std::env::args_os().skip(1)) { + Ok(Some(info)) => { + println!("{info}"); + return; + } + Ok(None) => {} + Err(error) => { + eprintln!("{error}"); + std::process::exit(2); + } + } use tauri_plugin_deep_link::DeepLinkExt; #[cfg(target_os = "macos")] diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs index 9f51efa3..278dd302 100644 --- a/src-tauri/src/updater.rs +++ b/src-tauri/src/updater.rs @@ -858,6 +858,8 @@ mod tests { &identity(), ) .unwrap(); + let current = Version::parse(env!("CARGO_PKG_VERSION")).unwrap(); + manifest.version = Version::new(current.major, current.minor, current.patch + 1); assert!(eligible_cached(&manifest, "26.0").unwrap()); assert!(!eligible_cached(&manifest, "12.0").unwrap()); manifest.version = Version::new(0, 2, 3); @@ -868,6 +870,14 @@ mod tests { fn fabricated_cached_signature_never_becomes_ready() { let temp = Temp::new(); let store = Store::open(&temp.0).unwrap(); + let mut fixture: serde_json::Value = serde_json::from_slice(include_bytes!( + "../../shared/fixtures/desktop-update-manifest.json" + )) + .unwrap(); + let current = Version::parse(env!("CARGO_PKG_VERSION")).unwrap(); + fixture["version"] = Version::new(current.major, current.minor, current.patch + 1) + .to_string() + .into(); let record = PreparedRecord { schema: 1, release_id: 1, @@ -875,10 +885,7 @@ mod tests { archive_asset_id: 3, archive_size: 4, archive_sha256: digest(b"test"), - manifest: String::from_utf8( - include_bytes!("../../shared/fixtures/desktop-update-manifest.json").to_vec(), - ) - .unwrap(), + manifest: serde_json::to_string(&fixture).unwrap(), inventory: serde_json::json!({"fabricated":true}), }; store diff --git a/src-tauri/tests/update_build_binding.rs b/src-tauri/tests/update_build_binding.rs index b5141591..923971d9 100644 --- a/src-tauri/tests/update_build_binding.rs +++ b/src-tauri/tests/update_build_binding.rs @@ -159,6 +159,25 @@ fn disabled_mode_is_explicitly_empty_and_unknown_or_partial_modes_fail() { assert!(binding::validate(&package(), &unknown).is_err()); } +#[test] +fn signed_runtime_binding_is_exact_and_only_admitted_for_macos_release() { + let source = "a".repeat(64); + let signed = "b".repeat(64); + assert_eq!( + binding::signed_runtime_digest(&source, None, "linux", false).unwrap(), + source + ); + assert_eq!( + binding::signed_runtime_digest(&source, Some(&signed), "macos", true).unwrap(), + signed + ); + assert!(binding::signed_runtime_digest(&source, Some(&signed), "linux", true).is_err()); + assert!(binding::signed_runtime_digest(&source, Some(&signed), "macos", false).is_err()); + for bad in ["".into(), "A".repeat(64), "f".repeat(63), "g".repeat(64)] { + assert!(binding::signed_runtime_digest(&source, Some(&bad), "macos", true).is_err()); + } +} + #[test] fn nonmac_targets_are_disabled_and_reject_explicit_enablement() { let temp = TempRoot::new(); diff --git a/src-tauri/update_build_binding.rs b/src-tauri/update_build_binding.rs index 4161aeb6..8dc51f86 100644 --- a/src-tauri/update_build_binding.rs +++ b/src-tauri/update_build_binding.rs @@ -340,6 +340,33 @@ pub fn read_qa_certificate(root: &Path) -> Result, String> { Ok(bytes) } +/// Finalization may change native signatures and therefore manifest hashes. +/// The signing owner rebuilds the desktop with that final digest BEFORE sealing +/// the app; runtime verification remains an exact compiled digest comparison. +pub fn signed_runtime_digest( + source: &str, + signed: Option<&str>, + target_os: &str, + release: bool, +) -> Result { + let valid = |value: &str| { + value.len() == 64 + && value + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + }; + if !valid(source) { + return Err("Invalid source runtime manifest digest.".into()); + } + match signed { + None => Ok(source.to_owned()), + Some(value) if target_os == "macos" && release && valid(value) => Ok(value.to_owned()), + Some(_) => Err( + "Signed runtime binding requires a macOS release build and a canonical SHA-256.".into(), + ), + } +} + pub fn artifact_prefix(package_name: &str) -> Result { if package_name.is_empty() || package_name.len() > 128 diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index ad6543b0..3fa64eb3 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -42,8 +42,6 @@ export function useChatComposerState(args: UseChatComposerStateArgs) { const { executionCwd, selectedProject, selectedSession, currentSessionId, gjcModel, reasoningEffort = 'default', isLoading, canAbortSession, tokenBudget, sendMessage, sendByCtrlEnter, onSessionProcessing, onSessionEstablished, onInputFocusChange, onCommandGateChange, onShowSettings, onLogin, scrollToBottom, addMessage, setIsUserScrolledUp, setPendingPermissionRequests } = args; const projectId = selectedProject?.projectId; const conversation = selectedSession?.id || currentSessionId || null; - const [useWorktree, setUseWorktree] = useState(false); - useEffect(() => { setUseWorktree(false); }, [projectId, conversation]); const [input, setInput] = useState(() => projectId && typeof window !== 'undefined' ? safeLocalStorage.getItem(draftInputKey(projectId, conversation)) || '' : ''); const [attachedImages, setAttachedImages] = useState([]); const [uploadingImages, setUploadingImages] = useState>(new Map()); @@ -127,7 +125,7 @@ export function useChatComposerState(args: UseChatComposerStateArgs) { if (!isCurrent()) return null; const project = target ? await descend(target) : selectedProject; if (!isCurrent()) return null; - const response = await authenticatedFetch(useWorktree ? '/api/providers/worktree-sessions' : '/api/providers/sessions', { method: 'POST', body: JSON.stringify({ provider: 'gjc', projectPath: project?.fullPath || project?.path || '' }) }); + const response = await authenticatedFetch('/api/providers/sessions', { method: 'POST', body: JSON.stringify({ provider: 'gjc', projectPath: project?.fullPath || project?.path || '' }) }); if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(typeof body.error === 'string' ? body.error : body.error?.message ?? `Failed to create session (${response.status})`); @@ -135,7 +133,7 @@ export function useChatComposerState(args: UseChatComposerStateArgs) { id = (await response.json())?.data?.sessionId || null; if (!id) throw new Error('no session id returned.'); return { id, context: { provider: 'gjc', project: project!, summary } }; - }, [currentSessionId, descend, resolveForSend, selectedProject, selectedSession, useWorktree]); + }, [currentSessionId, descend, resolveForSend, selectedProject, selectedSession]); const handleSubmit = useCallback(async (event: FormEvent | MouseEvent | TouchEvent | KeyboardEvent, queued?: QueuedDraft) => { event.preventDefault(); const text = queued?.content ?? inputRef.current; if (!text.trim() || !selectedProject) return; @@ -264,5 +262,5 @@ export function useChatComposerState(args: UseChatComposerStateArgs) { const handleAbortSession = useCallback(() => { if (!canAbortSession) return; const id = selectedSession?.id || currentSessionId; if (!id) { console.warn('Abort requested but no session ID is available.'); return; } sendMessage({ type: 'chat.abort', sessionId: id }); }, [canAbortSession, currentSessionId, selectedSession?.id, sendMessage]); const handlePermissionDecision = useCallback((requestIds: string | string[], decision: PermissionDecision) => { const ids = (Array.isArray(requestIds) ? requestIds : [requestIds]).filter(Boolean); const sent = ids.filter((requestId) => sendMessage(permissionResponseMessage(requestId, decision)) !== false); if (sent.length) setPendingPermissionRequests((requests) => requests.filter((request) => !sent.includes(request.requestId))); }, [sendMessage, setPendingPermissionRequests]); const handleInputFocusChange = useCallback((focused: boolean) => { setFocused(focused); onInputFocusChange?.(focused); }, [onInputFocusChange]); - return { useWorktree, setUseWorktree, input, setInput, textareaRef, inputHighlightRef, isTextareaExpanded, slashCommandsCount, skillCommands: slashCommands.filter((command) => command.type === 'skill'), filteredCommands, frequentCommands, commandQuery, showCommandMenu, selectedCommandIndex, resetCommandMenuState, handleCommandSelect, handleToggleCommandMenu, showFileDropdown, filteredFiles: filteredFiles as MentionableFile[], selectedFileIndex, renderInputWithMentions, selectFile, attachedImages, setAttachedImages, uploadingImages, imageErrors, getRootProps, getInputProps, isDragActive, openImagePicker: open, handleSubmit, handleSteer, modelPickerTrigger, queuedDrafts, editQueuedDraft, deleteQueuedDraft, moveQueuedDraft, resolveSteerResult, pendingCommandGate, confirmCommandGate, cancelCommandGate, handleVoiceTranscript, insertAtEnd, handleInputChange, handleKeyDown, handlePaste, handleTextareaClick: (event: MouseEvent) => setCursorPosition(event.currentTarget.selectionStart), handleTextareaInput, syncInputOverlayScroll, handleClearInput, handleAbortSession, handlePermissionDecision, handleInputFocusChange, isInputFocused, commandModalPayload, closeCommandModal: () => setModal(null), showCostModal, isWorkspace: workspaceTarget.isWorkspace, workspaceCandidates: workspaceTarget.candidates, workspaceTargetValue: workspaceTarget.target, pickWorkspaceTarget: workspaceTarget.pickTarget }; + return { input, setInput, textareaRef, inputHighlightRef, isTextareaExpanded, slashCommandsCount, skillCommands: slashCommands.filter((command) => command.type === 'skill'), filteredCommands, frequentCommands, commandQuery, showCommandMenu, selectedCommandIndex, resetCommandMenuState, handleCommandSelect, handleToggleCommandMenu, showFileDropdown, filteredFiles: filteredFiles as MentionableFile[], selectedFileIndex, renderInputWithMentions, selectFile, attachedImages, setAttachedImages, uploadingImages, imageErrors, getRootProps, getInputProps, isDragActive, openImagePicker: open, handleSubmit, handleSteer, modelPickerTrigger, queuedDrafts, editQueuedDraft, deleteQueuedDraft, moveQueuedDraft, resolveSteerResult, pendingCommandGate, confirmCommandGate, cancelCommandGate, handleVoiceTranscript, insertAtEnd, handleInputChange, handleKeyDown, handlePaste, handleTextareaClick: (event: MouseEvent) => setCursorPosition(event.currentTarget.selectionStart), handleTextareaInput, syncInputOverlayScroll, handleClearInput, handleAbortSession, handlePermissionDecision, handleInputFocusChange, isInputFocused, commandModalPayload, closeCommandModal: () => setModal(null), showCostModal, isWorkspace: workspaceTarget.isWorkspace, workspaceCandidates: workspaceTarget.candidates, workspaceTargetValue: workspaceTarget.target, pickWorkspaceTarget: workspaceTarget.pickTarget }; } diff --git a/src/components/chat/view/ChatComposer.dom.bun.test.tsx b/src/components/chat/view/ChatComposer.dom.bun.test.tsx index 89e5c85a..eff3e6b2 100644 --- a/src/components/chat/view/ChatComposer.dom.bun.test.tsx +++ b/src/components/chat/view/ChatComposer.dom.bun.test.tsx @@ -85,6 +85,14 @@ function composer(props: ComposerProps) { return createElement(I18nextProvider, { i18n }, createElement(ChatComposer, props)); } +test('the chat toolbar does not expose a project or worktree selector', async () => { + await i18n.changeLanguage('en'); + const view = render(composer(composerProps())); + assert.equal(view.queryByRole('combobox', { name: 'Run location' }), null); + assert.equal(view.queryByText('New worktree'), null); + assert.equal(view.queryByText('Project', { exact: true }), null); +}); + function slot(container: HTMLElement, name: string) { const element = container.querySelector(`[data-slot="prompt-input${name ? `-${name}` : ''}"]`); assert.ok(element, `missing prompt input ${name || 'form'}`); @@ -136,11 +144,11 @@ for (const language of ['en', 'ko']) { }); } -test('model and permission slots retain their widths as metadata loads beside a hidden hint', async () => { +test('model and permission slots retain bounded sizing as metadata loads beside a hidden hint', async () => { await i18n.changeLanguage('en'); const props = composerProps({ input: 'Draft message' }); const view = render(composer({ - ...props, modelOptions: [], modelPresetOptions: [], modelPresetsLoading: true, permissions: null, + ...props, modelPreset: 'default', modelOptions: [], modelPresetOptions: [], modelPresetsLoading: true, permissions: null, })); const tools = slot(view.container, 'tools'); const model = within(tools).getByRole('button', { name: 'Model and reasoning settings' }); @@ -148,10 +156,13 @@ test('model and permission slots retain their widths as metadata loads beside a const permissionSlot = tools.children[3]; const skills = within(tools).getByRole('button', { name: english.input.skills.label }); - assert.ok(modelSlot.classList.contains('w-40')); - assert.ok(modelSlot.classList.contains('sm:w-56')); + assert.ok(modelSlot.classList.contains('w-fit')); assert.ok(modelSlot.classList.contains('max-w-full'), 'model slot must fit a narrow tools row'); + assert.ok(modelSlot.classList.contains('sm:max-w-56')); + assert.equal(modelSlot.classList.contains('w-40'), false); + assert.equal(modelSlot.classList.contains('sm:w-56'), false); assert.ok(modelSlot.classList.contains('shrink-0')); + assert.ok(model.querySelector('.w-24'), 'loading model slot must keep a visible skeleton width'); assert.ok(permissionSlot.classList.contains('w-28')); assert.ok(permissionSlot.classList.contains('shrink-0')); assert.equal(permissionSlot.getAttribute('aria-hidden'), 'true'); diff --git a/src/components/chat/view/ChatComposer.tsx b/src/components/chat/view/ChatComposer.tsx index f1bb7c47..a954955e 100644 --- a/src/components/chat/view/ChatComposer.tsx +++ b/src/components/chat/view/ChatComposer.tsx @@ -63,7 +63,6 @@ interface SlashCommand { } interface ChatComposerProps { - sessionLocationControl?: ReactNode; pendingPermissionRequests: PendingPermissionRequest[]; handlePermissionDecision: (requestIds: string | string[], decision: PermissionDecision) => void; /** A run is in flight for the viewed session: the primary button is Stop, Enter queues. */ @@ -145,7 +144,6 @@ interface ChatComposerProps { } export default function ChatComposer({ - sessionLocationControl, pendingPermissionRequests, handlePermissionDecision, isLoading, @@ -442,8 +440,6 @@ export default function ChatComposer({ can wrap separately when a split pane leaves too little room. */} - {sessionLocationControl} - >((id, context) => { setCurrentSessionId(id); onSessionEstablished?.(id, context); @@ -246,7 +244,6 @@ function ChatInterface({ const composerNode = ( } pendingPermissionRequests={pendingPermissionRequests} handlePermissionDecision={composer.handlePermissionDecision} isLoading={session.isProcessing} diff --git a/src/components/chat/view/GoalControls.dom.bun.test.tsx b/src/components/chat/view/GoalControls.dom.bun.test.tsx index 9e522cb2..481f93cd 100644 --- a/src/components/chat/view/GoalControls.dom.bun.test.tsx +++ b/src/components/chat/view/GoalControls.dom.bun.test.tsx @@ -39,16 +39,20 @@ test('idle active snapshots present Paused and unavailable controls cannot dispa assert.equal(calls, 0); }); -test('new goal requires an explicit objective and displays bounded-run guidance', async () => { - const calls: unknown[] = []; - const view = render( { calls.push(input); }} />); - fireEvent.click(view.getByRole('button', { name: 'New goal' })); - const start = view.getByRole('button', { name: 'Start goal' }); - assert.equal((start as HTMLButtonElement).disabled, true); - fireEvent.change(view.getByRole('textbox', { name: 'Goal objective' }), { target: { value: 'Ship scoped controls' } }); - assert.ok(view.getByText(/200 model steps or 120 minutes/)); - fireEvent.click(start); - await waitFor(() => assert.deepEqual(calls, [{ operation: 'create', objective: 'Ship scoped controls' }])); +test('goal controls are absent while loading or when no goal exists', () => { + const view = render(); + assert.equal(view.container.innerHTML, ''); + view.rerender(); + assert.equal(view.container.innerHTML, ''); +}); + +test('terminal goals show status without offering a new goal or controls', () => { + const view = render(); + assert.equal(view.getByRole('status').textContent, 'Goal · Complete'); + assert.ok(view.getByText('Finish the integration')); + assert.equal(view.queryByRole('button', { name: 'New goal' }), null); + assert.equal(view.queryByRole('button', { name: 'Pause' }), null); + assert.equal(view.queryByRole('button', { name: 'Cancel goal' }), null); }); test('disconnection and pending requests disable controls; server errors remain actionable', () => { diff --git a/src/components/chat/view/GoalControls.tsx b/src/components/chat/view/GoalControls.tsx index 3a46366e..52e18d36 100644 --- a/src/components/chat/view/GoalControls.tsx +++ b/src/components/chat/view/GoalControls.tsx @@ -1,4 +1,3 @@ -import { useId, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '../../../shared/view/ui/Button'; @@ -15,38 +14,26 @@ export type GoalControlsProps = { export default function GoalControls({ snapshot, pending, connected, error, control, refresh }: GoalControlsProps) { const { t } = useTranslation('chat'); - const objectiveId = useId(); const label = (key: string, fallback: string) => t(`goal.${key}`, { defaultValue: fallback }); - const [objective, setObjective] = useState(''); - const [editing, setEditing] = useState(false); const goal = snapshot?.goal; + if (!goal) return null; const paused = goal?.status === 'paused' || snapshot?.resumeRequired === true; const terminal = goal?.status === 'complete' || goal?.status === 'dropped'; const disabled = pending || !connected || !snapshot?.canControl; - const run = (operation: Exclude) => { - void control({ operation, ...(operation === 'create' ? { objective } : {}) }).then(() => { setEditing(false); setObjective(''); }).catch(() => {}); - }; + const run = (operation: Exclude) => { void control({ operation }).catch(() => {}); }; const status = terminal ? goal?.status === 'complete' ? label('complete', 'Complete') : label('cancelled', 'Cancelled') : paused ? label('paused', 'Paused') : label('active', 'Active'); return
- {goal && <> - {label('title', 'Goal')} · {status} - {goal.objective} - {t('goal.tokens', { value: goal.tokensUsed.toLocaleString(), defaultValue: `${goal.tokensUsed.toLocaleString()} tokens` })} - {!terminal && <> - - - } + {label('title', 'Goal')} · {status} + {goal.objective} + {t('goal.tokens', { value: goal.tokensUsed.toLocaleString(), defaultValue: `${goal.tokensUsed.toLocaleString()} tokens` })} + {!terminal && <> + + } - {(!goal || terminal) && } {!connected && {label('reconnect', 'Reconnect to control goals.')}} {snapshot && !snapshot.canControl && connected && {label('unavailable', 'Goal controls are unavailable for this run or owner.')}} - {editing &&
{ event.preventDefault(); if (!disabled && objective.trim()) run('create'); }}> - - setObjective(event.target.value)} placeholder={label('placeholder', 'What should the agent finish?')} /> - -
} - {(editing || (goal && !terminal)) &&

{t('goal.guidance', { turns: GJC_GOAL_TURN_LIMIT, minutes: GJC_GOAL_RUN_LIMIT_MS / 60_000, defaultValue: `Each run pauses after ${GJC_GOAL_TURN_LIMIT} model steps or ${GJC_GOAL_RUN_LIMIT_MS / 60_000} minutes. Stop pauses the goal. Resume starts a new run when idle. Delegated tasks use the same model and permissions.` })}

} + {!terminal &&

{t('goal.guidance', { turns: GJC_GOAL_TURN_LIMIT, minutes: GJC_GOAL_RUN_LIMIT_MS / 60_000, defaultValue: `Each run pauses after ${GJC_GOAL_TURN_LIMIT} model steps or ${GJC_GOAL_RUN_LIMIT_MS / 60_000} minutes. Stop pauses the goal. Resume starts a new run when idle. Delegated tasks use the same model and permissions.` })}

} {error &&
{error}
}
; diff --git a/src/components/chat/view/ModelAndReasoningPicker.dom.bun.test.tsx b/src/components/chat/view/ModelAndReasoningPicker.dom.bun.test.tsx index d8244a63..c2fcef6b 100644 --- a/src/components/chat/view/ModelAndReasoningPicker.dom.bun.test.tsx +++ b/src/components/chat/view/ModelAndReasoningPicker.dom.bun.test.tsx @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; -import { createElement } from 'react'; +import { createElement, type ComponentProps } from 'react'; import { cleanup, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'; import '../../../i18n/config'; @@ -13,6 +13,23 @@ import type { ReasoningEffort } from './reasoningEffort'; afterEach(cleanup); const searchName = 'Search providers and models'; +type PickerProps = ComponentProps; + +function renderPicker(overrides: Partial = {}) { + return render(createElement(ModelAndReasoningPicker, { + value: 'openai-codex/astra', + presetOptions: [], + modelOptions: [{ + value: 'openai-codex/astra', + label: 'Astra', + effort: { values: [{ value: 'xhigh' }] }, + }], + onSelect() {}, + reasoningEffort: 'xhigh', + onSelectReasoningEffort() {}, + ...overrides, + })); +} async function openPicker() { const selectedModels: string[] = []; @@ -47,6 +64,71 @@ function AbortProbe({ canAbort, onAbort }: { canAbort: boolean; onAbort: () => v return null; } +test('short model labels size the trigger naturally without growing the model slot', () => { + const view = renderPicker(); + const trigger = screen.getByRole('button', { name: 'Model and reasoning settings' }); + const root = trigger.parentElement!; + const model = screen.getByText('Astra'); + const reasoning = screen.getByText('Extra high'); + + assert.ok(root.classList.contains('w-fit')); + assert.ok(root.classList.contains('max-w-full')); + assert.ok(root.classList.contains('sm:max-w-56')); + assert.ok(root.classList.contains('shrink-0')); + assert.ok(trigger.classList.contains('w-fit')); + assert.ok(trigger.classList.contains('max-w-full')); + assert.ok(model.classList.contains('min-w-0')); + assert.ok(model.classList.contains('truncate')); + assert.equal(model.classList.contains('flex-1'), false); + assert.ok(reasoning.classList.contains('shrink-0')); + assert.ok(reasoning.classList.contains('whitespace-nowrap')); + assert.match(trigger.textContent ?? '', /Astra.*·.*Extra high/); + + view.unmount(); +}); + +test('long model labels retain ellipsis while reasoning stays readable', () => { + const longLabel = 'Astra with an intentionally long model name for narrow panes'; + const view = renderPicker({ + value: 'openai-codex/long-astra', + modelOptions: [{ + value: 'openai-codex/long-astra', + label: longLabel, + effort: { values: [{ value: 'xhigh' }] }, + }], + }); + const trigger = screen.getByRole('button', { name: 'Model and reasoning settings' }); + const model = screen.getByText(longLabel); + const reasoning = screen.getByText('Extra high'); + + assert.ok(model.classList.contains('min-w-0')); + assert.ok(model.classList.contains('truncate')); + assert.equal(model.classList.contains('flex-1'), false); + assert.ok(reasoning.classList.contains('shrink-0')); + assert.ok(reasoning.classList.contains('whitespace-nowrap')); + assert.match(trigger.textContent ?? '', /Extra high/); + + view.unmount(); +}); + +test('loading keeps a visible fixed-width model skeleton in the content-sized trigger', () => { + const view = renderPicker({ + value: 'default', + modelOptions: [], + loading: true, + }); + const trigger = screen.getByRole('button', { name: 'Model and reasoning settings' }); + const skeleton = trigger.querySelector('span.bg-muted'); + + assert.equal(trigger.getAttribute('aria-busy'), 'true'); + assert.equal(trigger.hasAttribute('disabled'), true); + assert.ok(skeleton); + assert.ok(skeleton.classList.contains('w-24')); + assert.equal(skeleton.classList.contains('w-full'), false); + + view.unmount(); +}); + test('Escape from an empty search dismisses the portal, restores trigger focus, and allows reopening', async () => { const { trigger, search, selectedModels, selectedEfforts } = await openPicker(); diff --git a/src/components/chat/view/ModelAndReasoningPicker.tsx b/src/components/chat/view/ModelAndReasoningPicker.tsx index 43e39a38..f67aa8e3 100644 --- a/src/components/chat/view/ModelAndReasoningPicker.tsx +++ b/src/components/chat/view/ModelAndReasoningPicker.tsx @@ -340,26 +340,26 @@ export default function ModelAndReasoningPicker({ }; return ( -
+
diff --git a/src/components/chat/view/SessionWorktreePicker.dom.bun.test.tsx b/src/components/chat/view/SessionWorktreePicker.dom.bun.test.tsx index dce7be65..02930d1e 100644 --- a/src/components/chat/view/SessionWorktreePicker.dom.bun.test.tsx +++ b/src/components/chat/view/SessionWorktreePicker.dom.bun.test.tsx @@ -1,51 +1,24 @@ import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; -import { act, cleanup, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'; -import { createInstance } from 'i18next'; -import { createElement } from 'react'; -import { I18nextProvider } from 'react-i18next'; +import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; -import english from '../../../i18n/locales/en/chat.json'; import { useChatComposerState } from '../hooks/useChatComposerState'; import { useFileOpenResolver } from '../../../hooks/useFileOpenResolver'; import { useProjectGitSummary } from '../../workspace/hooks/useProjectGitSummary'; import { useProjectChanges } from '../../workspace/hooks/useProjectChanges'; -import SessionWorktreePicker from './SessionWorktreePicker'; - -const i18n = createInstance(); -await i18n.init({ lng: 'en', resources: { en: { chat: english } } }); const originalFetch = globalThis.fetch; afterEach(() => { cleanup(); globalThis.fetch = originalFetch; localStorage.clear(); }); -test('new-session selector is labelled and can choose an isolated worktree', () => { - const changes: boolean[] = []; - render(createElement(I18nextProvider, { i18n }, createElement(SessionWorktreePicker, { value: false, onChange: (value) => changes.push(value) }))); - const select = screen.getByRole('combobox', { name: 'Run location' }) as HTMLSelectElement; - assert.equal(select.value, 'project'); - fireEvent.change(select, { target: { value: 'worktree' } }); - assert.deepEqual(changes, [true]); -}); - -test('a persisted worktree shows its location and cannot be switched in place', () => { - render(createElement(I18nextProvider, { i18n }, createElement(SessionWorktreePicker, { - value: false, onChange: () => assert.fail('Cannot change an existing session'), sessionId: 'session-one', - location: { mode: 'worktree', cwd: '/repo/.gjc-worktrees/job-session-one', projectPath: '/repo', jobId: 'job-session-one' }, - }))); - assert.equal(screen.queryByRole('combobox'), null); - assert.ok(screen.getByTitle('/repo/.gjc-worktrees/job-session-one')); - assert.ok(screen.getByText('Worktree')); -}); - -test('composer creates through the worktree route, then sends the allocated app identity', async () => { +test('composer creates through the ordinary route, then sends the allocated app identity', async () => { const requests: Array<{ url: string; body?: Record }> = []; const sent: unknown[] = []; globalThis.fetch = (async (input, options) => { const url = String(input); requests.push({ url, ...(options?.body ? { body: JSON.parse(String(options.body)) } : {}) }); - const body = url.includes('/files') ? [] : url.includes('/worktree-sessions') - ? { success: true, data: { sessionId: 'worktree-app-session', projectPath: '/fixture/project' } } + const body = url.includes('/files') ? [] : url.endsWith('/providers/sessions') + ? { success: true, data: { sessionId: 'project-app-session', projectPath: '/fixture/project' } } : { success: true, data: { commands: [], skills: [], isWorkspace: false, candidates: [] } }; return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); }) as typeof fetch; @@ -57,14 +30,13 @@ test('composer creates through the worktree route, then sends the allocated app sendMessage: (message) => { sent.push(message); return true; }, scrollToBottom() {}, addMessage() {}, setIsUserScrolledUp() {}, setPendingPermissionRequests() {}, })); act(() => { - view.result.current.setUseWorktree(true); view.result.current.handleInputChange({ target: { value: 'fixture prompt', selectionStart: 14 } } as never); }); await act(async () => { await view.result.current.handleSubmit({ preventDefault() {} } as never); }); - const create = requests.find(({ url }) => url.includes('/worktree-sessions')); + const create = requests.find(({ url }) => url.endsWith('/providers/sessions')); assert.deepEqual(create?.body, { provider: 'gjc', projectPath: '/fixture/project' }); - assert.equal(requests.some(({ url, body }) => url.endsWith('/providers/sessions') && body), false); - assert.ok(sent.some((message) => (message as { type: string; sessionId: string }).type === 'chat.send' && (message as { sessionId: string }).sessionId === 'worktree-app-session')); + assert.equal(requests.some(({ url }) => url.includes('/worktree-sessions')), false); + assert.ok(sent.some((message) => (message as { type: string; sessionId: string }).type === 'chat.send' && (message as { sessionId: string }).sessionId === 'project-app-session')); }); test('file references resolve through the selected session to its worktree', async () => { @@ -93,13 +65,14 @@ test('an unavailable session directory never opens a relative file at the server assert.deepEqual(opened, []); }); -test('failed worktree creation keeps the draft and does not send or fall back to a project session', async () => { +test('failed session creation keeps the draft and does not send or fall back to a worktree session', async () => { const requests: string[] = []; const messages: Array<{ type?: string }> = []; globalThis.fetch = (async (input) => { const url = String(input); requests.push(url); - return new Response(JSON.stringify(url.includes('/files') ? [] : url.includes('/worktree-sessions') ? { error: { message: 'A Git repository is required.' } } : {}), { status: url.includes('/worktree-sessions') ? 400 : 200 }); + const create = url.endsWith('/providers/sessions'); + return new Response(JSON.stringify(url.includes('/files') ? [] : create ? { error: { message: 'Unable to create a session.' } } : {}), { status: create ? 400 : 200 }); }) as typeof fetch; const view = renderHook(() => useChatComposerState({ selectedProject: { projectId: 'project-one', fullPath: '/fixture/project', displayName: 'Project' }, @@ -108,13 +81,13 @@ test('failed worktree creation keeps the draft and does not send or fall back to sendMessage: () => assert.fail('Must not send'), scrollToBottom() {}, addMessage: (message) => messages.push(message), setIsUserScrolledUp() {}, setPendingPermissionRequests() {}, })); act(() => { - view.result.current.setUseWorktree(true); view.result.current.handleInputChange({ target: { value: 'keep this draft', selectionStart: 15 } } as never); }); await act(async () => { await view.result.current.handleSubmit({ preventDefault() {} } as never); }); assert.equal(view.result.current.input, 'keep this draft'); assert.ok(messages.some((message) => message.type === 'error')); - assert.equal(requests.some((url) => url.endsWith('/providers/sessions')), false); + assert.ok(requests.some((url) => url.endsWith('/providers/sessions'))); + assert.equal(requests.some((url) => url.includes('/worktree-sessions')), false); }); test('file references retry a pending worktree and preserve explicit absolute paths', async () => { diff --git a/src/components/chat/view/SessionWorktreePicker.tsx b/src/components/chat/view/SessionWorktreePicker.tsx deleted file mode 100644 index 4628ec3e..00000000 --- a/src/components/chat/view/SessionWorktreePicker.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { GitBranch } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; - -import type { SessionLocation } from '../hooks/useSessionLocation'; - -type Props = { - value: boolean; - onChange: (enabled: boolean) => void; - sessionId?: string | null; - location?: SessionLocation; - disabled?: boolean; -}; - -/** Select once, before session creation; existing sessions retain their location. */ -export default function SessionWorktreePicker({ value, onChange, sessionId, location, disabled }: Props) { - const { t } = useTranslation('chat'); - if (sessionId) { - if (location?.mode !== 'worktree') return null; - return ( - - - {location.cwd ? t('sessionWorktree.worktree') : t('sessionWorktree.preparing')} - - ); - } - return ( - - ); -} diff --git a/src/i18n/locales/de/chat.json b/src/i18n/locales/de/chat.json index e984108a..a9f2f9ce 100644 --- a/src/i18n/locales/de/chat.json +++ b/src/i18n/locales/de/chat.json @@ -64,13 +64,6 @@ "stopSpeaking": "Stopp", "loading": "Laden…" }, - "sessionWorktree": { - "label": "Ausführungsort", - "project": "Projekt", - "newWorktree": "Neuer Worktree", - "worktree": "Worktree", - "preparing": "Worktree wird vorbereitet" - }, "input": { "placeholder": "Fragen Sie {{provider}}, verwenden Sie / für Befehle oder @, um Dateien hinzuzufügen ...", "modelReasoning": { diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index 9dc75d75..53d7365b 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -64,13 +64,6 @@ "stopSpeaking": "Stop", "loading": "Loading…" }, - "sessionWorktree": { - "label": "Run location", - "project": "Project", - "newWorktree": "New worktree", - "worktree": "Worktree", - "preparing": "Preparing worktree" - }, "input": { "placeholder": "Ask {{provider}}, use / for commands, or @ to add files…", "modelReasoning": { diff --git a/src/i18n/locales/fr/chat.json b/src/i18n/locales/fr/chat.json index 69ed1367..d465a4e4 100644 --- a/src/i18n/locales/fr/chat.json +++ b/src/i18n/locales/fr/chat.json @@ -64,13 +64,6 @@ "stopSpeaking": "Arrêter", "loading": "Chargement…" }, - "sessionWorktree": { - "label": "Dossier d’exécution", - "project": "Projet", - "newWorktree": "Nouveau worktree", - "worktree": "Worktree", - "preparing": "Préparation du worktree" - }, "input": { "placeholder": "Demandez {{provider}}, utilisez / pour les commandes, ou @ pour ajouter des fichiers…", "modelReasoning": { diff --git a/src/i18n/locales/it/chat.json b/src/i18n/locales/it/chat.json index afb9388c..3a16ce9c 100644 --- a/src/i18n/locales/it/chat.json +++ b/src/i18n/locales/it/chat.json @@ -64,13 +64,7 @@ "stopSpeaking": "Fermati", "loading": "Caricamento…" }, - "sessionWorktree": { - "label": "Directory di esecuzione", - "project": "Progetto", - "newWorktree": "Nuovo worktree", - "worktree": "Worktree", - "preparing": "Preparazione del worktree" - }, + "input": { "placeholder": "Chiedi {{provider}}, usa / per i comandi o @ per aggiungere file...", "modelReasoning": { diff --git a/src/i18n/locales/ja/chat.json b/src/i18n/locales/ja/chat.json index 95b3f4b8..7fca299b 100644 --- a/src/i18n/locales/ja/chat.json +++ b/src/i18n/locales/ja/chat.json @@ -64,13 +64,7 @@ "stopSpeaking": "停止。", "loading": "読み込み中…" }, - "sessionWorktree": { - "label": "実行場所", - "project": "プロジェクト", - "newWorktree": "新しいワークツリー", - "worktree": "ワークツリー", - "preparing": "ワークツリーを準備中" - }, + "input": { "placeholder": "/ でコマンド、@ でファイル指定、または {{provider}} に何でも聞いてください...。", "modelReasoning": { diff --git a/src/i18n/locales/ko/chat.json b/src/i18n/locales/ko/chat.json index 5a216c6e..9a15cdad 100644 --- a/src/i18n/locales/ko/chat.json +++ b/src/i18n/locales/ko/chat.json @@ -64,13 +64,6 @@ "stopSpeaking": "중단", "loading": "불러오는 중…" }, - "sessionWorktree": { - "label": "실행 위치", - "project": "프로젝트", - "newWorktree": "새 워크트리", - "worktree": "워크트리", - "preparing": "워크트리 준비 중" - }, "input": { "placeholder": "{{provider}}에게 질문하세요, /로 명령어를 사용하거나 @로 파일을 추가하세요…", "modelReasoning": { diff --git a/src/i18n/locales/ru/chat.json b/src/i18n/locales/ru/chat.json index bc42905e..cfa78a5e 100644 --- a/src/i18n/locales/ru/chat.json +++ b/src/i18n/locales/ru/chat.json @@ -64,13 +64,7 @@ "stopSpeaking": "Останавливаться", "loading": "Загрузка…" }, - "sessionWorktree": { - "label": "Место выполнения", - "project": "Проект", - "newWorktree": "Новое рабочее дерево", - "worktree": "Рабочее дерево", - "preparing": "Подготовка рабочего дерева" - }, + "input": { "placeholder": "Спросите {{provider}}, используйте / для команд или @ для добавления файлов…", "modelReasoning": { diff --git a/src/i18n/locales/tr/chat.json b/src/i18n/locales/tr/chat.json index 6ec04c7c..9e6ed38a 100644 --- a/src/i18n/locales/tr/chat.json +++ b/src/i18n/locales/tr/chat.json @@ -64,13 +64,6 @@ "stopSpeaking": "Durmak", "loading": "Yükleniyor…" }, - "sessionWorktree": { - "label": "Çalışma konumu", - "project": "Proje", - "newWorktree": "Yeni çalışma ağacı", - "worktree": "Çalışma ağacı", - "preparing": "Çalışma ağacı hazırlanıyor" - }, "input": { "placeholder": "'ya sorun, komutlar için / tuşunu veya dosya eklemek için @ tuşunu kullanın… {{provider}}", "modelReasoning": { diff --git a/src/i18n/locales/zh-CN/chat.json b/src/i18n/locales/zh-CN/chat.json index 26846ed9..6b6451b3 100644 --- a/src/i18n/locales/zh-CN/chat.json +++ b/src/i18n/locales/zh-CN/chat.json @@ -64,13 +64,6 @@ "stopSpeaking": "停止", "loading": "加载中……" }, - "sessionWorktree": { - "label": "运行位置", - "project": "项目", - "newWorktree": "新建工作树", - "worktree": "工作树", - "preparing": "正在准备工作树" - }, "input": { "placeholder": "询问{{provider}},使用/发送命令,或@添加文件……", "modelReasoning": { diff --git a/src/i18n/locales/zh-TW/chat.json b/src/i18n/locales/zh-TW/chat.json index ff0d1ab2..c2d69338 100644 --- a/src/i18n/locales/zh-TW/chat.json +++ b/src/i18n/locales/zh-TW/chat.json @@ -64,13 +64,6 @@ "stopSpeaking": "停止", "loading": "載入中…" }, - "sessionWorktree": { - "label": "執行位置", - "project": "專案", - "newWorktree": "新增工作樹", - "worktree": "工作樹", - "preparing": "正在準備工作樹" - }, "input": { "placeholder": "請問 {{provider}},使用 / 進行指令,或 @ 添加檔案…", "modelReasoning": { diff --git a/website/src/page.js b/website/src/page.js index d9a5e20c..8c1313db 100644 --- a/website/src/page.js +++ b/website/src/page.js @@ -60,7 +60,7 @@ export function renderLandingPage() { ${appleIcon()} Download for macOS -

Apple Silicon · macOS 11+ · Notarized by Apple

+

Apple Silicon · macOS 13+ · Notarized by Apple

Download for Linux @@ -112,7 +112,7 @@ export function renderLandingPage() {

macOS

-

Apple Silicon · macOS 11+

+

Apple Silicon · macOS 13+

First-launch instructions
diff --git a/website/src/releases.js b/website/src/releases.js index 85181d8d..a17201f1 100644 --- a/website/src/releases.js +++ b/website/src/releases.js @@ -10,10 +10,10 @@ export const GAJAE_CODE_URL = 'https://github.com/devswha/gajae-code'; export const APPLE_GATEKEEPER_HELP_URL = 'https://support.apple.com/102445'; export const RELEASE = { - version: '2.0.0-beta.9', - tag: 'v2.0.0-beta.9', + version: '2.0.0-beta.10', + tag: 'v2.0.0-beta.10', channel: 'beta', - publishedLabel: '2026-09-06', + publishedLabel: '2026-09-07', }; function releaseDownloadBase(tag = RELEASE.tag) { diff --git a/website/tests/page.test.js b/website/tests/page.test.js index e554e7f1..f36b45c9 100644 --- a/website/tests/page.test.js +++ b/website/tests/page.test.js @@ -103,7 +103,7 @@ test('keeps page and social metadata aligned with the desktop app positioning', test('states that the macOS beta is notarized and keeps the legacy Gatekeeper path for older builds', () => { const html = renderLandingPage(); assert.match(html, /Public beta/); - assert.match(html, /Apple Silicon · macOS 11\+ · Notarized by Apple/); + assert.match(html, /Apple Silicon · macOS 13\+ · Notarized by Apple/); assert.equal(html.includes('Not notarized'), false); assert.equal(html.includes('has not been notarized'), false); assert.match(html, /System Settings → Privacy & Security/);