From a65e6d0d83f1b41162488d83f1567f68e6672fec Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Mon, 2 Nov 2026 20:37:53 +1300 Subject: [PATCH 1/6] Mount the interactive composer before background initialization Initialize the shared Ink composer before manager and MCP startup can perform synchronous work. Preserve single renderer ownership and cover first paint, silent MCP handshakes, first responses, and exit in both screen modes. Co-authored-by: Autohand Evolve --- src/core/agent/AgentLifecycleRunner.ts | 11 ++-- ...AgentLifecycleRunner.startup-order.test.ts | 50 +++++++++++++++++++ .../built-cli-composer.tuistory.test.ts | 3 +- 3 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 tests/core/agent/AgentLifecycleRunner.startup-order.test.ts diff --git a/src/core/agent/AgentLifecycleRunner.ts b/src/core/agent/AgentLifecycleRunner.ts index 382fcbd5..9f838f94 100644 --- a/src/core/agent/AgentLifecycleRunner.ts +++ b/src/core/agent/AgentLifecycleRunner.ts @@ -571,6 +571,8 @@ export async function runAgentInteractive(host: AgentLifecycleHost, initialInstr host.mcpStartupCoordinator.prepareForInteractiveStartup(); + await initializeInteractiveComposer(host); + // Start ALL initialization in background so prompt appears instantly. // The user can start typing while managers initialize. // When they submit, we await initReady before processing. @@ -1644,10 +1646,7 @@ async function executeQueuedMobileComposerCommand( } } -export async function runAgentInteractiveLoop(host: AgentLifecycleHost): Promise { - // Initialize Ink UI early so the composer is ready before the first idle check. - // This ensures consistent UI from startup instead of falling back to readline - // and then switching to Ink after the first prompt. +async function initializeInteractiveComposer(host: AgentLifecycleHost): Promise { if (host.useInkRenderer && !host.inkRenderer) { await host.initializeUI(undefined, undefined, true); if (host.restoredChatMessages?.length && host.inkRenderer?.setChatMessages) { @@ -1662,6 +1661,10 @@ export async function runAgentInteractiveLoop(host: AgentLifecycleHost): Promise } host.inkRenderer?.setPendingSuggestion?.(host.pendingSuggestion ?? undefined); } + } + +export async function runAgentInteractiveLoop(host: AgentLifecycleHost): Promise { + await initializeInteractiveComposer(host); while (true) { // Check if we should exit immediately (SIGINT/SIGTERM received) diff --git a/tests/core/agent/AgentLifecycleRunner.startup-order.test.ts b/tests/core/agent/AgentLifecycleRunner.startup-order.test.ts new file mode 100644 index 00000000..7537d6f5 --- /dev/null +++ b/tests/core/agent/AgentLifecycleRunner.startup-order.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { runAgentInteractive, runAgentInteractiveLoop } from '../../../src/core/agent/AgentLifecycleRunner.js'; + +const stdinTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); +afterEach(() => { + if (stdinTTY) Object.defineProperty(process.stdin, 'isTTY', stdinTTY); + else Reflect.deleteProperty(process.stdin, 'isTTY'); + vi.restoreAllMocks(); +}); + +describe('interactive first paint', () => { + it.each([false, true])('mounts the composer before starting background initialization; fullscreen=%s', async fullScreenMode => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + const mounting = Promise.withResolvers(); + const events: string[] = []; + const renderer = {}; + const host = { + runtime: { config: { provider: 'openrouter', features: { fullScreenMode } }, options: {} }, + pendingInkInstructions: [], + useInkRenderer: true, + inkRenderer: undefined as typeof renderer | undefined, + mcpStartupCoordinator: { prepareForInteractiveStartup: vi.fn() }, + performBackgroundInit: vi.fn(async () => { events.push('background'); }), + initializeUI: vi.fn(async () => { + await mounting.promise; + host.inkRenderer = renderer; + events.push('mounted'); + }), + setComposerIdle: vi.fn(() => { events.push('idle'); }), + installExitSignalHandlers: vi.fn(), + removeExitSignalHandlers: vi.fn(), + shouldExit: true, + closeSession: vi.fn(async () => {}), + runInteractiveLoop: async () => runAgentInteractiveLoop(host), + }; + + const startup = runAgentInteractive(host); + try { + await vi.waitFor(() => expect(host.initializeUI).toHaveBeenCalledOnce()); + expect(host.performBackgroundInit).not.toHaveBeenCalled(); + } finally { + mounting.resolve(); + await startup; + } + + expect(events).toEqual(['mounted', 'idle', 'background']); + expect(host.initializeUI).toHaveBeenCalledOnce(); + expect(host.removeExitSignalHandlers).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/tuistory/built-cli-composer.tuistory.test.ts b/tests/tuistory/built-cli-composer.tuistory.test.ts index ef6bb18c..84bb54d8 100644 --- a/tests/tuistory/built-cli-composer.tuistory.test.ts +++ b/tests/tuistory/built-cli-composer.tuistory.test.ts @@ -52,11 +52,12 @@ registerBuiltCliCleanup(); describe('interactive built CLI Tuistory tests: composer, tips, announcements, auth, and goals', () => { - it('answers the first message and exits promptly while an MCP server never finishes its handshake', async () => { + it.each([false, true])('answers the first message and exits promptly while an MCP server never finishes its handshake; fullscreen=%s', async fullScreenMode => { const openRouterServer = await createMockOpenRouterServer('MCP gate cleared.'); mockServers.push(openRouterServer); const session = await launchInteractive({ config: { + features: { fullScreenMode }, openrouter: { baseUrl: openRouterServer.baseUrl }, agent: { sessionRetryLimit: 0 }, mcp: { From 05ab51952e46e39b0191cd455d98c3eb07061196 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Mon, 2 Nov 2026 20:38:03 +1300 Subject: [PATCH 2/6] Modernize macOS runners and validate notarized release artifacts Pin CLI CI, installers, builds, and transported-artifact checks to macOS 26 for Apple Silicon and Intel. Preserve the Computer Use host deployment target, apply Bun runtime entitlements, and validate stapled tickets and Gatekeeper acceptance before publication. Document the Apple credential setup and add native signing coverage. Co-authored-by: Autohand Evolve --- .github/workflows/README.md | 18 ++++- .github/workflows/ci.yml | 2 +- .../installer-release-compatibility.yml | 2 +- .github/workflows/release.yml | 30 +++++++-- docs/computer-control.md | 4 +- native/macos/cli.entitlements.plist | 12 ++++ tests/ci/releaseWorkflow.test.ts | 65 +++++++++++++++++-- 7 files changed, 118 insertions(+), 15 deletions(-) create mode 100644 native/macos/cli.entitlements.plist diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 1e09ed1e..c63143a1 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -40,6 +40,15 @@ resolutions. Weekly Dependabot updates keep the GitHub Actions versions current. ticket. While credentials are unavailable, alpha and stable releases use ad-hoc signatures. Both modes verify each transported Actions artifact on a native Apple Silicon or Intel runner before publication. + Developer ID signing applies `native/macos/cli.entitlements.plist` to the Bun + executables for JavaScript execution and native addons. The Swift Computer Use + host is signed separately without those runtime exceptions. + + macOS build, verification, and CI jobs use `macos-26` (Apple Silicon) and + `macos-26-intel` (Intel). GitHub retired the macOS 14 runner image on + [November 2, 2026](https://github.blog/changelog/2026-10-01-github-actions-macos-14-runner-image-retirement/). + The Computer Use host is compiled explicitly for macOS 13, matching its bundle + deployment target, so the runner upgrade does not raise its minimum OS. 6. **Generates release notes** from the correct previous release tag @@ -132,7 +141,7 @@ Add these secrets in GitHub Settings → Secrets → Actions: 2. **`AHTRACES_REPO_TOKEN`** (required for release and CI builds) - Fine-grained read-only token for the private `autohandai/ahtraces` repository -3. **macOS signing and notarization secrets** (optional while Apple enrollment is pending) +3. **macOS signing and notarization secrets** (required for notarized releases) - `APPLICATION_CERT_BASE64`: base64-encoded Developer ID Application `.p12` - `CERT_PASSWORD`: password for the signing certificate archive - `DEVELOPER_NAME`: organization name in the Developer ID certificate @@ -142,6 +151,13 @@ Add these secrets in GitHub Settings → Secrets → Actions: - Configure all six secrets to sign with Developer ID and notarize macOS artifacts. Partially configured signing fails in the prepare job rather than silently downgrading a configured Developer ID release. + - Apple Developer enrollment does not configure GitHub Actions automatically. + Make these secrets available to this repository and enable the Release workflow + before dispatching a release. The CLI builds its Computer Use host from the + pinned component revision and signs it here; component CI alone does not + notarize the host shipped by the CLI. + - Developer ID releases validate the stapled ticket and Gatekeeper acceptance + again after downloading the Computer Use app on a fresh verification runner. - Alpha and stable releases with none of these secrets use temporary ad-hoc signing. Signatures are verified before upload and again after downloading the artifacts, but Apple does not notarize these builds. Downloaded apps may require **Open Anyway** in diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d24782fb..9adbb47a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,7 +140,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [macos-latest, ubuntu-latest, windows-latest] + os: [macos-26, ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v7 diff --git a/.github/workflows/installer-release-compatibility.yml b/.github/workflows/installer-release-compatibility.yml index d776821a..6291612b 100644 --- a/.github/workflows/installer-release-compatibility.yml +++ b/.github/workflows/installer-release-compatibility.yml @@ -18,7 +18,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-26] channel: [stable, alpha] runs-on: ${{ matrix.os }} steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 883b2e8e..535304b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -249,12 +249,12 @@ jobs: strategy: matrix: include: - - os: macos-latest + - os: macos-26 target: darwin-arm64 artifact: autohand-macos-arm64 tracesArtifact: ahtraces-macos-arm64 computerUseArtifact: autohand-computer-use-darwin-arm64.tar.gz - - os: macos-15-intel + - os: macos-26-intel target: darwin-x64 artifact: autohand-macos-x64 tracesArtifact: ahtraces-macos-x64 @@ -413,6 +413,13 @@ jobs: AUTOHAND_BUILD_VERSION: ${{ needs.prepare.outputs.version }} run: | ./computer-use-component/autohand/scripts/build-macos-host.sh --output "./binaries/Autohand Computer Use.app" --version "$AUTOHAND_BUILD_VERSION" + # The component builder defaults to its runner's macOS version. Keep + # the executable compatible with the bundle's declared minimum OS. + /usr/bin/swiftc -O -target "$(uname -m)-apple-macosx13.0" \ + ./computer-use-component/autohand/macos/main.swift \ + -module-cache-path "$RUNNER_TEMP/computer-use-swift-cache" \ + -framework ApplicationServices -framework CoreGraphics \ + -o "./binaries/Autohand Computer Use.app/Contents/MacOS/AutohandComputerUse" - name: Sign and notarize macOS artifacts if: runner.os == 'macOS' && needs.prepare.outputs.macos_signing_mode == 'developer-id' @@ -442,9 +449,11 @@ jobs: IDENTITY="Developer ID Application: ${DEVELOPER_NAME} (${TEAM_ID})" codesign --force --timestamp --options runtime --sign "$IDENTITY" \ + --entitlements "./native/macos/cli.entitlements.plist" \ ./binaries/${{ matrix.artifact }} codesign --verify --strict --verbose=4 ./binaries/${{ matrix.artifact }} codesign --force --timestamp --options runtime --sign "$IDENTITY" \ + --entitlements "./native/macos/cli.entitlements.plist" \ ./binaries/${{ matrix.tracesArtifact }} codesign --verify --strict --verbose=4 ./binaries/${{ matrix.tracesArtifact }} codesign --force --timestamp --options runtime --sign "$IDENTITY" \ @@ -484,7 +493,7 @@ jobs: -C ./binaries "Autohand Computer Use.app" - name: Verify binary - if: runner.os != 'Windows' && !contains(matrix.target, 'arm64') || matrix.os == 'macos-latest' && contains(matrix.target, 'arm64') + if: runner.os != 'Windows' && !contains(matrix.target, 'arm64') || matrix.os == 'macos-26' && contains(matrix.target, 'arm64') run: | chmod +x ./binaries/${{ matrix.artifact }} file ./binaries/${{ matrix.artifact }} @@ -492,7 +501,7 @@ jobs: file ./binaries/${{ matrix.tracesArtifact }} - name: Smoke test binary - if: runner.os != 'Windows' && !contains(matrix.target, 'arm64') && !contains(matrix.target, 'musl') || matrix.os == 'macos-latest' && contains(matrix.target, 'arm64') + if: runner.os != 'Windows' && !contains(matrix.target, 'arm64') && !contains(matrix.target, 'musl') || matrix.os == 'macos-26' && contains(matrix.target, 'arm64') shell: bash run: | # Helper: run a command with a timeout (works on both Linux and macOS) @@ -553,7 +562,7 @@ jobs: } - name: Verify fresh alpha installation - if: runner.os != 'Windows' && !contains(matrix.target, 'arm64') && !contains(matrix.target, 'musl') || matrix.os == 'macos-latest' && contains(matrix.target, 'arm64') + if: runner.os != 'Windows' && !contains(matrix.target, 'arm64') && !contains(matrix.target, 'musl') || matrix.os == 'macos-26' && contains(matrix.target, 'arm64') shell: bash timeout-minutes: 10 run: | @@ -582,11 +591,11 @@ jobs: strategy: matrix: include: - - os: macos-latest + - os: macos-26 artifact: autohand-macos-arm64 tracesArtifact: ahtraces-macos-arm64 computerUseArtifact: autohand-computer-use-darwin-arm64.tar.gz - - os: macos-15-intel + - os: macos-26-intel artifact: autohand-macos-x64 tracesArtifact: ahtraces-macos-x64 computerUseArtifact: autohand-computer-use-darwin-x64.tar.gz @@ -600,7 +609,10 @@ jobs: - name: Verify transported macOS binary shell: bash + env: + MACOS_SIGNING_MODE: ${{ needs.prepare.outputs.macos_signing_mode }} run: | + set -euo pipefail binary="./binaries/${{ matrix.artifact }}" ahtraces="./binaries/${{ matrix.tracesArtifact }}" computer_use_archive="./binaries/${{ matrix.computerUseArtifact }}" @@ -615,6 +627,10 @@ jobs: mkdir -p computer-use-verification tar -xzf "$computer_use_archive" -C computer-use-verification codesign --verify --deep --strict --verbose=4 "computer-use-verification/Autohand Computer Use.app" + if [ "$MACOS_SIGNING_MODE" = "developer-id" ]; then + xcrun stapler validate "computer-use-verification/Autohand Computer Use.app" + spctl -a -vv -t exec "computer-use-verification/Autohand Computer Use.app" + fi release: needs: [prepare, build, verify-macos-artifacts] diff --git a/docs/computer-control.md b/docs/computer-control.md index 7215e3c0..1613e5fe 100644 --- a/docs/computer-control.md +++ b/docs/computer-control.md @@ -58,7 +58,9 @@ macOS requires the user to approve its protected permission prompts. Autohand op Before a native app request on macOS, Autohand checks the managed host's effective permissions. If a grant is missing, the interactive CLI asks **Trust Autohand Computer Use?**, with **Cancel** selected by default. Choose **Trust and set up** only if you trust the download. This explicit choice is required even with `--yes` or Auto mode. Autohand requests Accessibility and Screen Recording, then verifies the effective OS grants before continuing to inference. Declining or leaving permissions unapproved stops the turn. Later requests reuse the OS grants. Command mode and RPC print manual setup guidance instead of showing the dialog. Ordinary coding requests do not run this check. -While Apple developer enrollment is pending, macOS release packages use ad-hoc signatures and are **not notarized**. If macOS blocks the app, try opening `~/Applications/Autohand Computer Use.app`, then choose **Open Anyway** in **System Settings → Privacy & Security** and confirm **Open**. Follow [Apple's instructions](https://support.apple.com/en-us/102445), then approve Accessibility and Screen Recording and run `autohand computer doctor`. Updates may require approval again because ad-hoc signatures do not preserve a Developer ID permission identity. Releases automatically use Developer ID and notarization once all Apple credentials are configured. +The CLI release workflow signs and notarizes macOS packages with Developer ID when all Apple credentials are configured. It staples the Computer Use app's ticket and verifies it again after artifact transport. See the [release signing setup](../.github/workflows/README.md#repository-secrets). + +Releases built without Apple credentials use ad-hoc signatures and are **not notarized**; the release notes identify these builds. If macOS blocks such a build, try opening `~/Applications/Autohand Computer Use.app`, then choose **Open Anyway** in **System Settings → Privacy & Security** and confirm **Open**. Follow [Apple's instructions](https://support.apple.com/en-us/102445), then approve Accessibility and Screen Recording and run `autohand computer doctor`. Updates may require approval again because ad-hoc signatures do not preserve a Developer ID permission identity. Permission-status subprocesses and result polling each have a five-second timeout; granting permissions during setup has a longer interactive timeout. `autohand computer doctor --json` reports `mcpReady: false` and exits unsuccessfully when a required permission is missing. diff --git a/native/macos/cli.entitlements.plist b/native/macos/cli.entitlements.plist new file mode 100644 index 00000000..46f67566 --- /dev/null +++ b/native/macos/cli.entitlements.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + diff --git a/tests/ci/releaseWorkflow.test.ts b/tests/ci/releaseWorkflow.test.ts index d39c9da3..45504eff 100644 --- a/tests/ci/releaseWorkflow.test.ts +++ b/tests/ci/releaseWorkflow.test.ts @@ -138,6 +138,13 @@ function runVersionStep(manualVersion: string): string { } describe('release workflow', () => { + it('keeps the Computer Use deployment target independent of the macOS runner version', () => { + const build = loadReleaseWorkflow().jobs.build.steps.find(step => step.name === 'Build Autohand Computer Use host'); + expect(build?.run).toContain('-target "$(uname -m)-apple-macosx13.0"'); + expect(build?.run).toContain('computer-use-component/autohand/macos/main.swift'); + expect(build?.run).toContain('-o "./binaries/Autohand Computer Use.app/Contents/MacOS/AutohandComputerUse"'); + }); + it('builds and bundles both musl architectures alongside glibc Linux', () => { const workflow = loadReleaseWorkflow(); const targets = workflow.jobs.build.strategy?.matrix?.include ?? []; @@ -271,6 +278,52 @@ describe('release workflow', () => { } }); + it.skipIf(process.platform !== 'darwin')('preserves Bun runtime entitlements when signing hardened executables', () => { + const script = loadReleaseWorkflow().jobs.build.steps.find(step => step.name === 'Sign and notarize macOS artifacts')?.run; + expect(script).toBeDefined(); + const directory = mkdtempSync(path.join(tmpdir(), 'autohand-hardened-signing-')); + const binaries = path.join(directory, 'binaries'); + const app = path.join(binaries, 'Autohand Computer Use.app'); + try { + mkdirSync(path.join(app, 'Contents/MacOS'), { recursive: true }); + const source = path.join(directory, 'probe.js'); + writeFileSync(source, 'console.log("hardened-signing-fixture");\n'); + const executable = path.join(binaries, 'autohand-probe'); + execFileSync('bun', ['build', source, '--compile', '--outfile', executable], { stdio: 'pipe', timeout: 20_000 }); + copyFileSync(executable, path.join(binaries, 'ahtraces-probe')); + copyFileSync('/usr/bin/true', path.join(app, 'Contents/MacOS/probe')); + writeFileSync(path.join(app, 'Contents/Info.plist'), ` + +CFBundleExecutableprobe +CFBundleIdentifierai.autohand.hardened-signing-fixture +CFBundlePackageTypeAPPL +`); + const signing = spawnSync('bash', ['-euo', 'pipefail', '-c', script! + .split('mkdir -p ./binaries/notarization')[0]! + .replace(/IDENTITY=.*/, 'IDENTITY="-"') + .replaceAll('--timestamp', '--timestamp=none') + .replaceAll('./native/macos/cli.entitlements.plist', path.join(REPOSITORY_ROOT, 'native/macos/cli.entitlements.plist')) + .replaceAll('${{ matrix.artifact }}', 'autohand-probe') + .replaceAll('${{ matrix.tracesArtifact }}', 'ahtraces-probe')], { + cwd: directory, encoding: 'utf8', timeout: 20_000, + env: { ...process.env, DEVELOPER_NAME: 'fixture', TEAM_ID: 'fixture', APPLE_ID: 'fixture', APP_SPECIFIC_PASSWORD: 'fixture' }, + }); + expect(signing.status, signing.stderr).toBe(0); + for (const name of ['autohand-probe', 'ahtraces-probe']) { + const entitlements = execFileSync('codesign', ['-d', '--entitlements', '-', '--xml', path.join(binaries, name)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + expect(entitlements).toContain('com.apple.security.cs.allow-jit'); + expect(entitlements).toContain('com.apple.security.cs.allow-unsigned-executable-memory'); + expect(entitlements).toContain('com.apple.security.cs.disable-library-validation'); + expect(entitlements).not.toContain('com.apple.security.get-task-allow'); + const result = spawnSync(path.join(binaries, name), { encoding: 'utf8', timeout: 5_000 }); + expect(result.status, result.stderr + String(result.signal ?? '')).toBe(0); + expect(result.stdout.trim()).toBe('hardened-signing-fixture'); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + it('keeps local native compile scripts aligned with release optional dependency handling', () => { const packageJson = JSON.parse(readFileSync(path.join(REPOSITORY_ROOT, 'package.json'), 'utf8')) as { scripts: Record; @@ -523,14 +576,14 @@ describe('release workflow', () => { expect(buildSteps[uploadIndex]?.with?.path).toContain('${{ matrix.tracesArtifact }}'); expect(buildTargets).toEqual(expect.arrayContaining([ { - os: 'macos-latest', + os: 'macos-26', target: 'darwin-arm64', artifact: 'autohand-macos-arm64', tracesArtifact: 'ahtraces-macos-arm64', computerUseArtifact: 'autohand-computer-use-darwin-arm64.tar.gz', }, { - os: 'macos-15-intel', + os: 'macos-26-intel', target: 'darwin-x64', artifact: 'autohand-macos-x64', tracesArtifact: 'ahtraces-macos-x64', @@ -559,13 +612,13 @@ describe('release workflow', () => { expect(transportJob?.['runs-on']).toBe('${{ matrix.os }}'); expect(transportJob?.strategy?.matrix?.include).toEqual([ { - os: 'macos-latest', + os: 'macos-26', artifact: 'autohand-macos-arm64', tracesArtifact: 'ahtraces-macos-arm64', computerUseArtifact: 'autohand-computer-use-darwin-arm64.tar.gz', }, { - os: 'macos-15-intel', + os: 'macos-26-intel', artifact: 'autohand-macos-x64', tracesArtifact: 'ahtraces-macos-x64', computerUseArtifact: 'autohand-computer-use-darwin-x64.tar.gz', @@ -580,6 +633,10 @@ describe('release workflow', () => { expect(verifyStep?.run).toContain('"$binary" --version < /dev/null'); expect(verifyStep?.run).toContain('ahtraces'); expect(verifyStep?.run).toContain('Autohand Computer Use.app'); + expect(verifyStep?.env?.MACOS_SIGNING_MODE).toBe('${{ needs.prepare.outputs.macos_signing_mode }}'); + expect(verifyStep?.run).toContain('if [ "$MACOS_SIGNING_MODE" = "developer-id" ]'); + expect(verifyStep?.run).toContain('xcrun stapler validate'); + expect(verifyStep?.run).toContain('spctl -a -vv -t exec'); expect(workflow.jobs.release.needs).toEqual([ 'prepare', 'build', From 596855dbe58800e585020afc72c4a33e5726c8e9 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Mon, 2 Nov 2026 21:14:53 +1300 Subject: [PATCH 3/6] Keep repository snapshots off the interactive startup path Defer the Git baseline scan until the first instruction, where the existing finalizer still protects pre-existing user changes. Reset stale baselines on fresh and restored sessions without scanning before the composer renders. Cover silent MCP startup and user-edit isolation with regression tests. Co-authored-by: Autohand Evolve --- src/actions/gitSessionSnapshot.ts | 8 +++ src/core/agent/AgentLifecycleRunner.ts | 7 ++- src/testing/scenarios/mcpStartupScenario.ts | 11 ++++ ...ntLifecycleRunner.startup-snapshot.test.ts | 26 +++++++++ tests/gitAutoCommit.isolation.test.ts | 25 +++++++++ tests/tuistory/mcp-startup.tuistory.test.ts | 53 +++++++++++++++++-- 6 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 tests/core/agent/AgentLifecycleRunner.startup-snapshot.test.ts diff --git a/src/actions/gitSessionSnapshot.ts b/src/actions/gitSessionSnapshot.ts index c0a00f0f..ba1b3cf1 100644 --- a/src/actions/gitSessionSnapshot.ts +++ b/src/actions/gitSessionSnapshot.ts @@ -107,6 +107,14 @@ export interface SessionGitState { sessionGitSnapshotFinalized?: boolean; } +/** A new session captures its baseline at the first instruction, after setup. */ +export function resetSessionGitSnapshot(runtime: SessionGitState | undefined): void { + if (!runtime) return; + delete runtime.sessionGitSnapshot; + delete runtime.sessionGitSnapshotError; + runtime.sessionGitSnapshotFinalized = false; +} + export function initializeSessionGitSnapshot(runtime: SessionGitState | undefined, force = false): void { if (!runtime?.workspaceRoot) return; if (!force && runtime.sessionGitSnapshot !== undefined) return; diff --git a/src/core/agent/AgentLifecycleRunner.ts b/src/core/agent/AgentLifecycleRunner.ts index 9f838f94..3dbcfaa2 100644 --- a/src/core/agent/AgentLifecycleRunner.ts +++ b/src/core/agent/AgentLifecycleRunner.ts @@ -5,7 +5,7 @@ */ import { extensionRuntimeHost } from '../../extensions/ExtensionRuntimeHost.js'; import chalk from 'chalk'; -import { initializeSessionGitSnapshot, type SessionGitState } from '../../actions/gitSessionSnapshot.js'; +import { resetSessionGitSnapshot, type SessionGitState } from '../../actions/gitSessionSnapshot.js'; import { isAxoInput } from '../../ui/axo/axoCommand.js'; import { isStartupTimingEnabled, startupTimeline } from '../../startup/startupTimeline.js'; import { buildOutputSchemaInstruction, buildOutputSchemaRepairInstruction, checkOutputAgainstSchema, type OutputSchemaSpec } from '../../modes/outputSchema.js'; @@ -344,7 +344,7 @@ export async function startFreshAgentSession( } } - initializeSessionGitSnapshot(host.runtime, true); + resetSessionGitSnapshot(host.runtime); const startedAt = Date.now(); await host.resetConversationContext(); host.resetAgentStateForFreshSession(startedAt); @@ -817,7 +817,6 @@ export async function shutdownAgentRuntimeResources(host: AgentLifecycleHost): P } export async function initializeAgentManagers(host: AgentLifecycleHost): Promise { - initializeSessionGitSnapshot(host.runtime); if (host.runtime?.options?.bare === true) { await runWithConcurrency([ { label: 'session_manager', run: async () => host.sessionManager.initialize() }, @@ -1393,7 +1392,7 @@ export async function runAgentCommandMode( } export async function restoreAgentSessionState(host: AgentLifecycleHost, sessionOrId: string | Session) { - initializeSessionGitSnapshot(host.runtime, true); + resetSessionGitSnapshot(host.runtime); const session = typeof sessionOrId === 'string' ? await host.sessionManager.loadSession(sessionOrId) : sessionOrId; diff --git a/src/testing/scenarios/mcpStartupScenario.ts b/src/testing/scenarios/mcpStartupScenario.ts index dbcbc349..92ace6a3 100644 --- a/src/testing/scenarios/mcpStartupScenario.ts +++ b/src/testing/scenarios/mcpStartupScenario.ts @@ -1,4 +1,15 @@ import type { Session } from 'tuistory'; +import { waitForTerminalScreen } from '../drivers/tuistory-driver.js'; + +export async function typeWhileMcpStarts(session: Session): Promise { + await session.waitForText('❯', { timeout: 5_000 }); + await session.type('Keep this startup draft'); + return waitForTerminalScreen(session, { + timeout: 3_000, + waitFor: screen => screen.split('\n').slice(-session.currentRows) + .some(line => line.trimStart().startsWith('❯ Keep this startup draft')), + }); +} export async function runMcpStartupFailureScenario(session: Session): Promise { await session.waitForText('❯', { timeout: 20_000 }); diff --git a/tests/core/agent/AgentLifecycleRunner.startup-snapshot.test.ts b/tests/core/agent/AgentLifecycleRunner.startup-snapshot.test.ts new file mode 100644 index 00000000..1ddd0306 --- /dev/null +++ b/tests/core/agent/AgentLifecycleRunner.startup-snapshot.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { initializeAgentManagers } from '../../../src/core/agent/AgentLifecycleRunner.js'; +import { initializeSessionGitSnapshot } from '../../../src/actions/gitSessionSnapshot.js'; + +vi.mock('../../../src/actions/gitSessionSnapshot.js', async importOriginal => ({ + ...await importOriginal(), + initializeSessionGitSnapshot: vi.fn(), +})); + +afterEach(() => vi.clearAllMocks()); + +describe('interactive startup Git baseline', () => { + it.each([false, true])('initializes managers without a synchronous repository scan; bare=%s', async bare => { + const initialize = vi.fn(async () => {}); + const manager = { initialize }; + await initializeAgentManagers({ + runtime: { workspaceRoot: '/workspace', options: { bare } }, + getParallelismLimit: () => 8, + sessionManager: manager, projectManager: manager, memoryManager: manager, + skillsRegistry: manager, hookManager: manager, + workspaceFileCollector: { collectWorkspaceFiles: initialize }, + }); + expect(initialize).toHaveBeenCalledTimes(bare ? 3 : 6); + expect(initializeSessionGitSnapshot).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/gitAutoCommit.isolation.test.ts b/tests/gitAutoCommit.isolation.test.ts index afe363cb..cf321723 100644 --- a/tests/gitAutoCommit.isolation.test.ts +++ b/tests/gitAutoCommit.isolation.test.ts @@ -4,6 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { captureAutoCommitSnapshot, executeAutoCommit, getAutoCommitInfo } from '../src/actions/git.js'; +import { finalizeSessionGitSnapshot, resetSessionGitSnapshot, type SessionGitState } from '../src/actions/gitSessionSnapshot.js'; describe('session-scoped auto-commit (F-0151)', () => { let root: string; @@ -17,6 +18,30 @@ describe('session-scoped auto-commit (F-0151)', () => { }); afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + it('defers a new session baseline until its first instruction and preserves intervening user edits', async () => { + const runtime: SessionGitState = { + workspaceRoot: root, sessionGitSnapshot: captureAutoCommitSnapshot(root), + sessionGitSnapshotFinalized: true, sessionGitSnapshotError: 'previous session', + }; + resetSessionGitSnapshot(runtime); + expect(runtime.sessionGitSnapshot).toBeUndefined(); + expect(runtime.sessionGitSnapshotError).toBeUndefined(); + expect(runtime.sessionGitSnapshotFinalized).toBe(false); + fs.writeFileSync(path.join(root, 'README.md'), 'user edit before first instruction\n'); + fs.writeFileSync(path.join(root, 'notes.txt'), 'user notes before first instruction\n'); + finalizeSessionGitSnapshot(runtime); + const snapshot = runtime.sessionGitSnapshot; + if (!snapshot) throw new Error('The first instruction must capture a Git baseline'); + fs.writeFileSync(path.join(root, 'feature.ts'), 'session feature\n'); + const result = await executeAutoCommit(root, 'Add session feature', true, snapshot); + expect(result.success).toBe(true); + expect(git('show', '--pretty=', '--name-only', 'HEAD')).toBe('feature.ts'); + expect(git('status', '--porcelain')).toContain(' M README.md'); + expect(git('status', '--porcelain')).toContain('?? notes.txt'); + expect(fs.readFileSync(path.join(root, 'README.md'), 'utf8')).toBe('user edit before first instruction\n'); + expect(fs.readFileSync(path.join(root, 'notes.txt'), 'utf8')).toBe('user notes before first instruction\n'); + }); + it('commits only the new test and leaves pre-existing edits and untracked notes alone', async () => { fs.writeFileSync(path.join(root, 'README.md'), 'user edit\n'); fs.writeFileSync(path.join(root, 'notes.txt'), 'private user notes\n'); diff --git a/tests/tuistory/mcp-startup.tuistory.test.ts b/tests/tuistory/mcp-startup.tuistory.test.ts index 77a90418..990bfd4d 100644 --- a/tests/tuistory/mcp-startup.tuistory.test.ts +++ b/tests/tuistory/mcp-startup.tuistory.test.ts @@ -1,10 +1,57 @@ -import { describe, expect, it } from 'vitest'; -import { chmod, readFile, writeFile } from 'node:fs/promises'; +import { describe, expect, it, vi } from 'vitest'; +import { access, chmod, mkdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { runMcpStartupFailureScenario } from '../../src/testing/scenarios/mcpStartupScenario.js'; +import { runMcpStartupFailureScenario, typeWhileMcpStarts } from '../../src/testing/scenarios/mcpStartupScenario.js'; import { createTempAutohandHome, exitInteractive, launchBuiltAutohand } from './helpers/autohandTuistory.js'; describe('MCP startup recovery guidance', () => { + it.skipIf(process.platform === 'win32')('accepts a fullscreen draft while MCP is silent and before an expensive Git snapshot', async () => { + const state = await createTempAutohandHome({ config: { + features: { fullScreenMode: true, automaticSpecialists: false }, + ui: { promptSuggestions: false }, agent: { autoMemory: false }, + } }); + const bin = path.join(state.autohandHome, 'bin'); + const snapshotStarted = path.join(state.autohandHome, 'snapshot.started'); + const snapshotRelease = path.join(state.autohandHome, 'snapshot.release'); + const mcpStarted = path.join(state.autohandHome, 'mcp.started'); + const mcpScript = path.join(state.autohandHome, 'silent-mcp.cjs'); + await mkdir(bin); + await writeFile(path.join(bin, 'git'), [ + '#!/usr/bin/env node', + 'const fs = require("node:fs");', + 'const args = process.argv.slice(2);', + 'if (args.join(" ") === "ls-files -z --cached --others --exclude-standard") {', + `fs.writeFileSync(${JSON.stringify(snapshotStarted)}, "started");`, + 'const deadline = Date.now() + 10_000;', + `while (!fs.existsSync(${JSON.stringify(snapshotRelease)}) && Date.now() < deadline) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);`, + '}', + 'const result = require("node:child_process").spawnSync("/usr/bin/git", args, { stdio: "inherit" });', + 'process.exit(result.status ?? 1);', + ].join('\n')); + await chmod(path.join(bin, 'git'), 0o755); + await writeFile(mcpScript, `require("node:fs").writeFileSync(${JSON.stringify(mcpStarted)}, "started"); process.stdin.resume(); setInterval(() => {}, 1_000);`); + const config = JSON.parse(await readFile(state.configPath, 'utf8')) as Record; + await writeFile(state.configPath, JSON.stringify({ ...config, mcp: { enabled: true, servers: [ + { name: 'silent-startup', transport: 'stdio', command: process.execPath, args: [mcpScript], autoConnect: true }, + ] } })); + let session: Awaited> | undefined; + try { + session = await launchBuiltAutohand(['--path', state.workspaceRoot, '--config', state.configPath], { + autohandHome: state.autohandHome, cwd: state.workspaceRoot, cols: 120, rows: 36, waitForData: false, + env: { PATH: `${bin}:${process.env.PATH ?? ''}` }, + }); + await typeWhileMcpStarts(session); + expect(session.getRawOutput()).toContain('\x1b[?1049h'); + await expect(access(snapshotStarted)).rejects.toMatchObject({ code: 'ENOENT' }); + await vi.waitFor(() => access(mcpStarted), { timeout: 5_000 }); + await exitInteractive(session); + } finally { + await writeFile(snapshotRelease, 'release'); + session?.close(); await session?.waitForExit(5_000); + await state.cleanup(); + } + }, 30_000); + it.skipIf(process.platform === 'win32')('shows the Docker registry failure while preserving the configured launch target', async () => { const state = await createTempAutohandHome(); const command = path.join(state.workspaceRoot, 'docker'); From c278bd8bb853bc911985b96af077b648984e63da Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Mon, 2 Nov 2026 23:06:25 +1300 Subject: [PATCH 4/6] Defer account search discovery until the first agent turn Prepare local search settings without holding the composer on the optional 3.5-second account capability request. Resolve shared, cancellable discovery before advertising web_search, retaining provider precedence and retry safety. Cover local configuration, stale accounts, cancellation, and startup typing in primary-screen and fullscreen terminal sessions. Co-authored-by: Autohand Evolve --- src/actions/web.ts | 52 ++++++++++++++--- src/core/agent/ReactLoopRunner.ts | 3 +- src/index.ts | 4 +- .../scenarios/searchStartupScenario.ts | 12 ++++ tests/search/accountSearchConfig.test.ts | 42 +++++++++++++- .../tuistory/search-startup.tuistory.test.ts | 57 +++++++++++++++++++ 6 files changed, 159 insertions(+), 11 deletions(-) create mode 100644 src/testing/scenarios/searchStartupScenario.ts create mode 100644 tests/tuistory/search-startup.tuistory.test.ts diff --git a/src/actions/web.ts b/src/actions/web.ts index 89213b1a..626c4979 100644 --- a/src/actions/web.ts +++ b/src/actions/web.ts @@ -17,6 +17,7 @@ import { getCommandCoordination, signalCoordinatedProcess, spawnCoordinatedProce import { hasBrowserBridgeOutput } from '../browser/browserToolBridge.js'; import type { AutohandConfig, SearchProvider } from '../types.js'; import { ManagedWebSearch, managedSearchIdentity } from '../search/managedWebSearch.js'; +import { awaitCliLifecycleStep } from '../runtime/CliRuntimeResourceOwner.js'; export interface WebSearchResult { title: string; @@ -84,6 +85,10 @@ let globalSearchConfig: SearchConfig = { let managedSearch: ManagedWebSearch | undefined; let managedSearchAvailable = false; let searchConfigurationRevision = 0; +let pendingSearchConfiguration: { + resolve(signal?: AbortSignal): Promise; + task?: Promise; +} | undefined; /** * Configure the global search provider settings @@ -98,6 +103,7 @@ export function configureSearchFromSettings( ): void { managedSearch = undefined; managedSearchAvailable = false; + pendingSearchConfiguration = undefined; searchConfigurationRevision += 1; configureSearch({ provider: providerOverride ?? settings.provider ?? 'browser-profile', @@ -107,23 +113,54 @@ export function configureSearchFromSettings( }); } -export async function configureSearchFromAccount( +export function prepareSearchFromAccount( config: Pick, providerOverride?: SearchProvider, signal?: AbortSignal, -): Promise { +): void { configureSearchFromSettings(config.search, providerOverride); const revision = searchConfigurationRevision; const identity = managedSearchIdentity(config); const client = identity ? new ManagedWebSearch(identity) : undefined; const selected = providerOverride ?? config.search?.provider; const checkIncluded = !selected || selected === 'browser-profile' || selected === 'autohand'; - const included = checkIncluded && client ? await client.available(signal) : false; - if (revision !== searchConfigurationRevision) return; - signal?.throwIfAborted(); - if (included && !selected) configureSearch({ provider: 'autohand' }); managedSearch = client; - managedSearchAvailable = included; + if (!checkIncluded || !client) return; + pendingSearchConfiguration = { + resolve: async turnSignal => { + const requestSignal = signal && turnSignal ? AbortSignal.any([signal, turnSignal]) : signal ?? turnSignal; + const included = await client.available(requestSignal); + if (revision !== searchConfigurationRevision) return; + requestSignal?.throwIfAborted(); + if (included && !selected) configureSearch({ provider: 'autohand' }); + managedSearchAvailable = included; + }, + }; +} + +export async function ensureSearchConfiguration(signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + while (pendingSearchConfiguration) { + const configuration = pendingSearchConfiguration; + const task = configuration.task ??= configuration.resolve(signal); + try { + if (signal) await awaitCliLifecycleStep(task, signal); + else await task; + } catch (error) { + if (configuration.task === task) configuration.task = undefined; + throw error; + } + if (pendingSearchConfiguration === configuration) pendingSearchConfiguration = undefined; + } +} + +export async function configureSearchFromAccount( + config: Pick, + providerOverride?: SearchProvider, + signal?: AbortSignal, +): Promise { + prepareSearchFromAccount(config, providerOverride, signal); + await ensureSearchConfiguration(signal); } /** @@ -590,6 +627,7 @@ function htmlToText(html: string): string { */ export async function webSearch(query: string, options: WebSearchOptions = {}): Promise { throwIfAborted(options.signal); + await ensureSearchConfiguration(options.signal); const maxResults = options.maxResults ?? 5; const searchType = options.searchType ?? 'general'; diff --git a/src/core/agent/ReactLoopRunner.ts b/src/core/agent/ReactLoopRunner.ts index 1464d3df..868b6c55 100644 --- a/src/core/agent/ReactLoopRunner.ts +++ b/src/core/agent/ReactLoopRunner.ts @@ -8,7 +8,7 @@ import type { GoalMutationResult } from '../../goals/types.js'; import { formatToolObservation } from './AgentToolOutputRuntime.js'; import type { PermissionManager } from '../../permissions/PermissionManager.js'; import { getProviderConfig } from '../../config.js'; -import { isSearchConfigured } from '../../actions/web.js'; +import { ensureSearchConfiguration, isSearchConfigured } from '../../actions/web.js'; import { DEFAULT_TOOL_OUTPUT_MODE, formatToolOutputForDisplay, type ToolOutputDisplay, type ToolOutputMode } from '../../ui/toolOutput.js'; import { COMPUTER_USE_TOOL_PREFIX } from '../../computer/computerUseOutput.js'; import { getPlanModeManager } from '../../commands/plan.js'; @@ -558,6 +558,7 @@ export async function runAgentReactLoop( } const refreshRuntimeTools = async () => { + await ensureSearchConfiguration(abortController.signal); await syncDynamicRuntimeExtensions(host, host.runtime); let definitions = host.toolManager.toFunctionDefinitions(); diff --git a/src/index.ts b/src/index.ts index 2f09a68d..cdcf6081 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2123,11 +2123,11 @@ async function runCLI(options: InternalCLIOptions): Promise { } // Configure web search provider from CLI flag, config file, or environment - const { configureSearchFromAccount } = await awaitCliLifecycleStep( + const { prepareSearchFromAccount } = await awaitCliLifecycleStep( import('./actions/web.js'), commandLifecycleController.signal, ); - await configureSearchFromAccount(config, options.searchEngine, commandLifecycleController.signal); + prepareSearchFromAccount(config, options.searchEngine, commandLifecycleController.signal); // Pipe mode: read stdin once if piped, then compose with prompt text (if any). // This must happen before AutohandAgent construction because dependency diff --git a/src/testing/scenarios/searchStartupScenario.ts b/src/testing/scenarios/searchStartupScenario.ts new file mode 100644 index 00000000..fce94ccd --- /dev/null +++ b/src/testing/scenarios/searchStartupScenario.ts @@ -0,0 +1,12 @@ +import type { Session } from 'tuistory'; +import { waitForTerminalScreen } from '../drivers/tuistory-driver.js'; + +export async function typeBeforeSearchCapabilityCheck(session: Session): Promise { + await session.waitForText('❯', { timeout: 10_000 }); + await session.type('Keep this search startup draft'); + return waitForTerminalScreen(session, { + timeout: 3_000, + waitFor: screen => screen.split('\n').slice(-session.currentRows) + .some(line => line.trimStart().startsWith('❯ Keep this search startup draft')), + }); +} diff --git a/tests/search/accountSearchConfig.test.ts b/tests/search/accountSearchConfig.test.ts index c47ce0d9..7456c7ba 100644 --- a/tests/search/accountSearchConfig.test.ts +++ b/tests/search/accountSearchConfig.test.ts @@ -1,11 +1,51 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { configureSearchFromAccount, getSearchConfig, isSearchConfigured, webSearch } from '../../src/actions/web.js'; +import { configureSearchFromAccount, ensureSearchConfiguration, getSearchConfig, isSearchConfigured, prepareSearchFromAccount, webSearch } from '../../src/actions/web.js'; import { SETTINGS_REGISTRY, settings } from '../../src/commands/settings.js'; vi.mock('../../src/config.js', async original => ({ ...await original(), saveConfig: vi.fn().mockResolvedValue(undefined) })); vi.mock('fs', async original => ({ ...await original(), existsSync: () => false })); afterEach(() => { vi.unstubAllGlobals(); }); describe('account-aware search configuration', () => { + it('prepares local settings without a request and resolves account availability once on demand', async () => { + const fetch = vi.fn().mockResolvedValue(Response.json({ provider: 'cloudflare', available: true, tier: 'lite' })); + vi.stubGlobal('fetch', fetch); + prepareSearchFromAccount({ auth: { token: 'ahc_paid' } }); + expect(fetch).not.toHaveBeenCalled(); + expect(getSearchConfig().provider).toBe('browser-profile'); + expect(isSearchConfigured()).toBe(false); + await Promise.all([ensureSearchConfiguration(), ensureSearchConfiguration()]); + expect(fetch).toHaveBeenCalledOnce(); + expect(getSearchConfig().provider).toBe('autohand'); + expect(isSearchConfigured()).toBe(true); + await ensureSearchConfiguration(); + expect(fetch).toHaveBeenCalledOnce(); + }); + + it('cancels an on-demand capability check and allows a later turn to retry', async () => { + const fetch = vi.fn().mockImplementationOnce(() => new Promise(() => {})) + .mockResolvedValueOnce(Response.json({ provider: 'cloudflare', available: true, tier: 'lite' })); + vi.stubGlobal('fetch', fetch); + prepareSearchFromAccount({ auth: { token: 'ahc_paid' } }); + const controller = new AbortController(); + const ready = ensureSearchConfiguration(controller.signal); + controller.abort(new DOMException('Turn cancelled', 'AbortError')); + await expect(ready).rejects.toThrow('Turn cancelled'); + expect(isSearchConfigured()).toBe(false); + await ensureSearchConfiguration(); + expect(isSearchConfigured()).toBe(true); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('discards an unstarted account check when an explicit provider replaces it', async () => { + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + prepareSearchFromAccount({ auth: { token: 'ahc_old' } }); + await configureSearchFromAccount({ search: { provider: 'exa', exaApiKey: 'own-key' } }); + await ensureSearchConfiguration(); + expect(fetch).not.toHaveBeenCalled(); + expect(getSearchConfig().provider).toBe('exa'); + expect(isSearchConfigured()).toBe(true); + }); it('selects included search only after the account API confirms availability', async () => { const fetch = vi.fn().mockResolvedValueOnce(Response.json({ provider: 'cloudflare', available: true, tier: 'lite' })) .mockResolvedValueOnce(Response.json({ provider: 'cloudflare', query: 'docs', results: [] })); diff --git a/tests/tuistory/search-startup.tuistory.test.ts b/tests/tuistory/search-startup.tuistory.test.ts new file mode 100644 index 00000000..d09b9702 --- /dev/null +++ b/tests/tuistory/search-startup.tuistory.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; +import { access, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { typeBeforeSearchCapabilityCheck } from '../../src/testing/scenarios/searchStartupScenario.js'; +import { createTempAutohandHome, exitInteractive, launchBuiltAutohand } from './helpers/autohandTuistory.js'; + +describe('search capability startup', () => { + it.each([false, true])('accepts a draft before checking account search (fullscreen=%s)', async fullScreenMode => { + const state = await createTempAutohandHome({ config: { + features: { fullScreenMode, automaticSpecialists: false }, + ui: { promptSuggestions: false }, agent: { autoMemory: false }, + mcp: { enabled: false, servers: [] }, + } }); + const started = path.join(state.autohandHome, 'capability.started'); + const aborted = path.join(state.autohandHome, 'capability.aborted'); + const preload = path.join(state.autohandHome, 'search-fetch.mjs'); + await writeFile(preload, ` + import { writeFileSync } from 'node:fs'; + globalThis.fetch = async (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url === 'https://dev.autohand.ai/api/native/web-search') { + writeFileSync(${JSON.stringify(started)}, 'started'); + return new Promise((resolve, reject) => { + const keepAlive = setInterval(() => {}, 1_000); + const abort = () => { clearInterval(keepAlive); writeFileSync(${JSON.stringify(aborted)}, 'aborted'); reject(init.signal.reason); }; + if (init.signal.aborted) abort(); + else init.signal.addEventListener('abort', abort, { once: true }); + }); + } + return new Response(null, { status: 503 }); + }; + `); + let session: Awaited> | undefined; + try { + session = await launchBuiltAutohand(['--path', state.workspaceRoot, '--config', state.configPath, '--offline', '--no-daemon'], { + autohandHome: state.autohandHome, cwd: state.workspaceRoot, cols: 120, rows: 36, waitForData: false, + env: { NODE_OPTIONS: `--import=${pathToFileURL(preload).href}` }, + }); + await typeBeforeSearchCapabilityCheck(session); + await expect(access(started)).rejects.toMatchObject({ code: 'ENOENT' }); + await session.press('enter'); + await vi.waitFor(() => access(started), { timeout: 10_000 }); + await session.press('escape'); + try { + await vi.waitFor(() => access(aborted), { timeout: 2_000 }); + } catch { + throw new Error(`Capability request did not cancel. Screen:\n${await session.text({ immediate: true })}`); + } + await exitInteractive(session); + } finally { + session?.close(); + await session?.waitForExit(5_000); + await state.cleanup(); + } + }, 30_000); +}); From 2365b2a2060839f5af73cc43f11af4771dc46252 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 3 Nov 2026 00:12:08 +1300 Subject: [PATCH 5/6] Require Apple notarization for CLI releases and repair unsigned Computer Use engines Use the shared Autohand Developer ID certificate and App Store Connect API credentials, block publication without signing, and pin the separately notarized macOS engine revision. Verify managed native signatures before reusing an installed engine and preserve explicit custom driver paths. Co-authored-by: Autohand Evolve --- .github/computer-use-ref | 2 +- .github/workflows/README.md | 46 ++++----- .github/workflows/release.yml | 148 +++++++--------------------- docs/computer-control.md | 6 +- src/computer/cuaInstaller.ts | 33 ++++++- tests/ci/releaseWorkflow.test.ts | 116 +++++----------------- tests/computer/cuaInstaller.test.ts | 18 +++- 7 files changed, 130 insertions(+), 239 deletions(-) diff --git a/.github/computer-use-ref b/.github/computer-use-ref index 7bba69e9..d3b1d71d 100644 --- a/.github/computer-use-ref +++ b/.github/computer-use-ref @@ -1 +1 @@ -fc92b70417cdae941ba8b104a6e7b3b5b52a07e2 +f0d26f14fa176c0bf6ca557f7cd7797f4c5ea374 diff --git a/.github/workflows/README.md b/.github/workflows/README.md index c63143a1..0e098790 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -37,9 +37,9 @@ resolutions. Weekly Dependabot updates keep the GitHub Actions versions current. 5. **Signs macOS binaries and Autohand Computer Use**. When all Apple credentials are configured, it uses Developer ID, notarizes the artifacts, and staples the app - ticket. While credentials are unavailable, alpha and stable releases use ad-hoc - signatures. Both modes verify each transported Actions artifact on a native Apple - Silicon or Intel runner before publication. + ticket. Every alpha and stable release requires Apple credentials. Missing + credentials stop publication. Transported artifacts are verified on a native + Apple Silicon or Intel runner before publication. Developer ID signing applies `native/macos/cli.entitlements.plist` to the Bun executables for JavaScript execution and native addons. The Swift Computer Use host is signed separately without those runtime exceptions. @@ -141,32 +141,20 @@ Add these secrets in GitHub Settings → Secrets → Actions: 2. **`AHTRACES_REPO_TOKEN`** (required for release and CI builds) - Fine-grained read-only token for the private `autohandai/ahtraces` repository -3. **macOS signing and notarization secrets** (required for notarized releases) - - `APPLICATION_CERT_BASE64`: base64-encoded Developer ID Application `.p12` - - `CERT_PASSWORD`: password for the signing certificate archive - - `DEVELOPER_NAME`: organization name in the Developer ID certificate - - `TEAM_ID`: Apple Developer team identifier - - `APPLE_ID`: Apple account used by `notarytool` - - `APP_SPECIFIC_PASSWORD`: app-specific password for that Apple account - - Configure all six secrets to sign with Developer ID and notarize macOS artifacts. - Partially configured signing fails in the prepare job rather than silently - downgrading a configured Developer ID release. - - Apple Developer enrollment does not configure GitHub Actions automatically. - Make these secrets available to this repository and enable the Release workflow - before dispatching a release. The CLI builds its Computer Use host from the - pinned component revision and signs it here; component CI alone does not - notarize the host shipped by the CLI. - - Developer ID releases validate the stapled ticket and Gatekeeper acceptance - again after downloading the Computer Use app on a fresh verification runner. - - Alpha and stable releases with none of these secrets use temporary ad-hoc signing. Signatures are - verified before upload and again after downloading the artifacts, but Apple - does not notarize these builds. Downloaded apps may require **Open Anyway** in - System Settings → Privacy & Security. See [Apple's instructions](https://support.apple.com/en-us/102445). - - Ad-hoc signing does not preserve the Developer ID permission identity across - updates. Users may need to grant Accessibility and Screen Recording access - to Autohand Computer Use again after an update. The CLI asks for explicit trust - before first-use permission setup, even with `--yes`, and checks the effective - OS grants before allowing desktop requests to proceed. +3. **macOS signing and notarization secrets** (required for every release) + - `CSC_LINK`: base64-encoded Developer ID Application `.p12` + - `CSC_KEY_PASSWORD`: password for the signing certificate archive + - `APPLE_TEAM_ID`: Autohand's Apple Developer team identifier + - `APPLE_API_KEY`: raw App Store Connect API private key (`.p8`) + - `APPLE_API_KEY_ID`: API key identifier + - `APPLE_API_ISSUER`: App Store Connect issuer identifier + - The same credential names are used by Autohand Computer Use and Desktop. + The component importer derives the Developer ID identity from the P12 and + checks its team. No Apple ID password or upstream Cua provisioning profile + is required. Credentials are written only to the runner's temporary folder. + - Signing and notarization are mandatory for alpha and stable releases; there + is no ad-hoc publication fallback. The host's stapled ticket and Gatekeeper + acceptance are validated again after artifact transport. 4. **`MODEL_CATALOG_PR_TOKEN`** (optional for model catalog pull requests) - Fine-grained token with repository Contents, Issues, and Pull requests read/write access diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 535304b0..19ba75cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,31 +67,24 @@ jobs: if: steps.check.outputs.should_release == 'true' env: RELEASE_CHANNEL: ${{ steps.determine.outputs.channel }} - APPLICATION_CERT_BASE64: ${{ secrets.APPLICATION_CERT_BASE64 }} - CERT_PASSWORD: ${{ secrets.CERT_PASSWORD }} - DEVELOPER_NAME: ${{ secrets.DEVELOPER_NAME }} - TEAM_ID: ${{ secrets.TEAM_ID }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }} + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} run: | set -euo pipefail - required=(APPLICATION_CERT_BASE64 CERT_PASSWORD DEVELOPER_NAME TEAM_ID APPLE_ID APP_SPECIFIC_PASSWORD) + required=(CSC_LINK CSC_KEY_PASSWORD APPLE_TEAM_ID APPLE_API_KEY APPLE_API_KEY_ID APPLE_API_ISSUER) missing=() for name in "${required[@]}"; do - if [ -z "${!name}" ]; then - missing+=("$name") - fi + if [ -z "${!name:-}" ]; then missing+=("$name"); fi done - - if [ "${#missing[@]}" -eq 0 ]; then - echo "mode=developer-id" >> "$GITHUB_OUTPUT" - elif [ "${#missing[@]}" -eq "${#required[@]}" ]; then - echo "mode=adhoc" >> "$GITHUB_OUTPUT" - echo "::warning::macOS artifacts will be ad-hoc signed, not notarized, while Apple credentials are unavailable. Users must trust the app on first use and may need Open Anyway and renewed Computer Use permissions after updates." - else - echo "::error::Incomplete macOS signing configuration. Missing: ${missing[*]}. Configure all Apple credentials for notarization, or omit all of them for the temporary ad-hoc signing path." + if [ "${#missing[@]}" -ne 0 ]; then + echo "::error::Apple signing is required for every release. Missing: ${missing[*]}." exit 1 fi + echo "mode=developer-id" >> "$GITHUB_OUTPUT" - name: Setup Node.js uses: actions/setup-node@v7 @@ -373,39 +366,13 @@ jobs: - name: Import macOS signing certificate if: runner.os == 'macOS' && needs.prepare.outputs.macos_signing_mode == 'developer-id' env: - APPLICATION_CERT_BASE64: ${{ secrets.APPLICATION_CERT_BASE64 }} - CERT_PASSWORD: ${{ secrets.CERT_PASSWORD }} - run: | - set -euo pipefail - test -n "$APPLICATION_CERT_BASE64" || { - echo "::error::APPLICATION_CERT_BASE64 is required for macOS releases" - exit 1 - } - test -n "$CERT_PASSWORD" || { - echo "::error::CERT_PASSWORD is required for macOS releases" - exit 1 - } - - KEYCHAIN_PASSWORD=$(uuidgen) - echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> "$GITHUB_ENV" - security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain - security default-keychain -s build.keychain - security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain - security set-keychain-settings -t 3600 -l build.keychain - - printf '%s' "$APPLICATION_CERT_BASE64" | base64 --decode > application.p12 - security import application.p12 -k build.keychain -P "$CERT_PASSWORD" \ - -T /usr/bin/codesign > /dev/null 2>&1 - rm application.p12 - security set-key-partition-list -S apple-tool:,apple:,codesign: \ - -s -k "$KEYCHAIN_PASSWORD" build.keychain > /dev/null 2>&1 - - CERT_COUNT=$(security find-identity -v -p codesigning build.keychain \ - | grep -c "Developer ID Application" || true) - if [ "$CERT_COUNT" -eq 0 ]; then - echo "::error::No Developer ID Application certificate was imported" - exit 1 - fi + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: bash computer-use-component/autohand/scripts/import-apple-signing.sh - name: Build Autohand Computer Use host if: runner.os == 'macOS' @@ -413,50 +380,24 @@ jobs: AUTOHAND_BUILD_VERSION: ${{ needs.prepare.outputs.version }} run: | ./computer-use-component/autohand/scripts/build-macos-host.sh --output "./binaries/Autohand Computer Use.app" --version "$AUTOHAND_BUILD_VERSION" - # The component builder defaults to its runner's macOS version. Keep - # the executable compatible with the bundle's declared minimum OS. - /usr/bin/swiftc -O -target "$(uname -m)-apple-macosx13.0" \ - ./computer-use-component/autohand/macos/main.swift \ - -module-cache-path "$RUNNER_TEMP/computer-use-swift-cache" \ - -framework ApplicationServices -framework CoreGraphics \ - -o "./binaries/Autohand Computer Use.app/Contents/MacOS/AutohandComputerUse" - name: Sign and notarize macOS artifacts if: runner.os == 'macOS' && needs.prepare.outputs.macos_signing_mode == 'developer-id' env: - DEVELOPER_NAME: ${{ secrets.DEVELOPER_NAME }} - TEAM_ID: ${{ secrets.TEAM_ID }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} run: | set -euo pipefail - test -n "$DEVELOPER_NAME" || { - echo "::error::DEVELOPER_NAME is required for macOS releases" - exit 1 - } - test -n "$TEAM_ID" || { - echo "::error::TEAM_ID is required for macOS releases" - exit 1 - } - test -n "$APPLE_ID" || { - echo "::error::APPLE_ID is required for macOS notarization" - exit 1 - } - test -n "$APP_SPECIFIC_PASSWORD" || { - echo "::error::APP_SPECIFIC_PASSWORD is required for macOS notarization" - exit 1 - } - - IDENTITY="Developer ID Application: ${DEVELOPER_NAME} (${TEAM_ID})" - codesign --force --timestamp --options runtime --sign "$IDENTITY" \ + IDENTITY="$AUTOHAND_CODESIGN_IDENTITY" + codesign --force --timestamp --options runtime --sign "$IDENTITY" --keychain "$AUTOHAND_CODESIGN_KEYCHAIN" \ --entitlements "./native/macos/cli.entitlements.plist" \ ./binaries/${{ matrix.artifact }} codesign --verify --strict --verbose=4 ./binaries/${{ matrix.artifact }} - codesign --force --timestamp --options runtime --sign "$IDENTITY" \ + codesign --force --timestamp --options runtime --sign "$IDENTITY" --keychain "$AUTOHAND_CODESIGN_KEYCHAIN" \ --entitlements "./native/macos/cli.entitlements.plist" \ ./binaries/${{ matrix.tracesArtifact }} codesign --verify --strict --verbose=4 ./binaries/${{ matrix.tracesArtifact }} - codesign --force --timestamp --options runtime --sign "$IDENTITY" \ + codesign --force --timestamp --options runtime --sign "$IDENTITY" --keychain "$AUTOHAND_CODESIGN_KEYCHAIN" \ "./binaries/Autohand Computer Use.app" codesign --verify --deep --strict --verbose=4 "./binaries/Autohand Computer Use.app" @@ -468,9 +409,9 @@ jobs: ditto -c -k --keepParent ./binaries/notarization \ ./binaries/autohand-macos-notarization.zip xcrun notarytool submit ./binaries/autohand-macos-notarization.zip \ - --apple-id "$APPLE_ID" \ - --team-id "$TEAM_ID" \ - --password "$APP_SPECIFIC_PASSWORD" \ + --key "$APPLE_API_KEY_PATH" \ + --key-id "$APPLE_API_KEY_ID" \ + --issuer "$APPLE_API_ISSUER" \ --wait --timeout 20m xcrun stapler staple "./binaries/Autohand Computer Use.app" spctl -a -vv -t exec "./binaries/Autohand Computer Use.app" @@ -479,19 +420,6 @@ jobs: tar -czf "./binaries/autohand-computer-use-${{ matrix.target }}.tar.gz" \ -C ./binaries "Autohand Computer Use.app" - - name: Ad-hoc sign macOS artifacts - if: runner.os == 'macOS' && needs.prepare.outputs.macos_signing_mode == 'adhoc' - run: | - set -euo pipefail - codesign --force --sign - --timestamp=none ./binaries/${{ matrix.artifact }} - codesign --verify --strict --verbose=4 ./binaries/${{ matrix.artifact }} - codesign --force --sign - --timestamp=none ./binaries/${{ matrix.tracesArtifact }} - codesign --verify --strict --verbose=4 ./binaries/${{ matrix.tracesArtifact }} - codesign --force --sign - --timestamp=none "./binaries/Autohand Computer Use.app" - codesign --verify --deep --strict --verbose=4 "./binaries/Autohand Computer Use.app" - tar -czf "./binaries/autohand-computer-use-${{ matrix.target }}.tar.gz" \ - -C ./binaries "Autohand Computer Use.app" - - name: Verify binary if: runner.os != 'Windows' && !contains(matrix.target, 'arm64') || matrix.os == 'macos-26' && contains(matrix.target, 'arm64') run: | @@ -584,6 +512,15 @@ jobs: ./binaries/autohand-computer-use-*.tar.gz retention-days: 1 + - name: Remove temporary Apple signing credentials + if: always() && runner.os == 'macOS' + shell: bash + run: | + if [ -f "$RUNNER_TEMP/autohand-signing.keychain-db" ]; then + security delete-keychain "$RUNNER_TEMP/autohand-signing.keychain-db" + fi + rm -f "$RUNNER_TEMP/AutohandNotary.p8" "$RUNNER_TEMP/autohand-signing.p12" + verify-macos-artifacts: needs: [prepare, build] if: needs.prepare.outputs.should_release == 'true' @@ -824,19 +761,6 @@ jobs: --output release-notes.md cat release-notes.md - - name: Document ad-hoc macOS signing - if: needs.prepare.outputs.macos_signing_mode == 'adhoc' - run: | - cat >> release-notes.md <<'EOF' - - ### macOS signing and first use - - These macOS binaries and the Computer Use app are ad-hoc signed, not notarized by Apple while our Developer ID setup is pending. - On first use, trust Autohand Computer Use only if you trust the download. If macOS blocks it, try opening the app, then choose **Open Anyway** in System Settings → Privacy & Security and confirm **Open**. - See [Apple's instructions](https://support.apple.com/en-us/102445). - Computer Use may require Accessibility and Screen Recording permissions again after updates. - EOF - - name: Create Release uses: softprops/action-gh-release@v3 with: diff --git a/docs/computer-control.md b/docs/computer-control.md index 1613e5fe..b8bbc85b 100644 --- a/docs/computer-control.md +++ b/docs/computer-control.md @@ -58,9 +58,11 @@ macOS requires the user to approve its protected permission prompts. Autohand op Before a native app request on macOS, Autohand checks the managed host's effective permissions. If a grant is missing, the interactive CLI asks **Trust Autohand Computer Use?**, with **Cancel** selected by default. Choose **Trust and set up** only if you trust the download. This explicit choice is required even with `--yes` or Auto mode. Autohand requests Accessibility and Screen Recording, then verifies the effective OS grants before continuing to inference. Declining or leaving permissions unapproved stops the turn. Later requests reuse the OS grants. Command mode and RPC print manual setup guidance instead of showing the dialog. Ordinary coding requests do not run this check. -The CLI release workflow signs and notarizes macOS packages with Developer ID when all Apple credentials are configured. It staples the Computer Use app's ticket and verifies it again after artifact transport. See the [release signing setup](../.github/workflows/README.md#repository-secrets). +Every published macOS release uses Autohand's Developer ID Application certificate and Apple notarization. The CLI and ahtraces binaries retain their Bun runtime entitlements; the Computer Use app has a stapled ticket. Release jobs stop when signing credentials are missing. See the [release signing setup](../.github/workflows/README.md#repository-secrets). -Releases built without Apple credentials use ad-hoc signatures and are **not notarized**; the release notes identify these builds. If macOS blocks such a build, try opening `~/Applications/Autohand Computer Use.app`, then choose **Open Anyway** in **System Settings → Privacy & Security** and confirm **Open**. Follow [Apple's instructions](https://support.apple.com/en-us/102445), then approve Accessibility and Screen Recording and run `autohand computer doctor`. Updates may require approval again because ad-hoc signatures do not preserve a Developer ID permission identity. +The macOS engine comes from a separate immutable `computer-use-v0.28.2-autohand.1` revision signed by Autohand AI Limited. Its executable, cursor helper, native module, and SDK library are all signed and notarized. Installation repairs an older managed engine whose native files lack Autohand's Developer ID signature, even when its engine version is already compatible. Explicitly configured custom driver paths retain their existing behavior. + +Apple notarization addresses unidentified-developer and developer-verification blocks. Accessibility and Screen Recording still require the user's normal macOS permission consent. Permission-status subprocesses and result polling each have a five-second timeout; granting permissions during setup has a longer interactive timeout. `autohand computer doctor --json` reports `mcpReady: false` and exits unsuccessfully when a required permission is missing. diff --git a/src/computer/cuaInstaller.ts b/src/computer/cuaInstaller.ts index 33bf73bd..aaa18dd6 100644 --- a/src/computer/cuaInstaller.ts +++ b/src/computer/cuaInstaller.ts @@ -3,7 +3,7 @@ * Copyright 2026 Autohand AI LLC * SPDX-License-Identifier: Apache-2.0 */ -import { spawn } from 'node:child_process'; +import { execFile, spawn } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; import { chmod, @@ -17,6 +17,7 @@ import { } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { promisify } from 'node:util'; import { CUA_DRIVER_VERSION, inspectCuaDriver, resolveCuaDriverPath } from './cuaDriver.js'; import { killAfter } from '../utils/processTimeout.js'; import { discardResponseBody } from '../utils/responseBody.js'; @@ -31,6 +32,9 @@ export { CUA_DRIVER_VERSION } from './cuaDriver.js'; export const COMPUTER_USE_RELEASE_BASE_URL = 'https://github.com/autohandai/computer-use/releases/download'; export const COMPUTER_USE_RELEASE_TAG = `computer-use-v${CUA_DRIVER_VERSION}`; +export const COMPUTER_USE_MACOS_RELEASE_TAG = `computer-use-v${CUA_DRIVER_VERSION}-autohand.1`; + +const execFileAsync = promisify(execFile); const DOWNLOAD_TIMEOUT_MS = 120_000; const INSTALL_TIMEOUT_MS = 10 * 60_000; @@ -47,12 +51,12 @@ interface ComputerUseReleaseDefinition { const RELEASE_ASSETS: Readonly> = { 'darwin/arm64': { name: `cua-driver-rs-${CUA_DRIVER_VERSION}-darwin-universal-binary.tar.gz`, - sha256: 'e1c859785107914c3432acda574f627a028a00704ce2bb6c37b2075e6ad16512', + sha256: '3b7a4a1a8730379145c195f4413c47ad0e4f50612ff1e8ae005bed514f49361c', format: 'tar.gz', }, 'darwin/x64': { name: `cua-driver-rs-${CUA_DRIVER_VERSION}-darwin-universal-binary.tar.gz`, - sha256: 'e1c859785107914c3432acda574f627a028a00704ce2bb6c37b2075e6ad16512', + sha256: '3b7a4a1a8730379145c195f4413c47ad0e4f50612ff1e8ae005bed514f49361c', format: 'tar.gz', }, 'linux/arm64': { @@ -138,7 +142,7 @@ export function resolveComputerUseReleaseAsset( } return { ...definition, - url: `${COMPUTER_USE_RELEASE_BASE_URL}/${COMPUTER_USE_RELEASE_TAG}/${definition.name}`, + url: `${COMPUTER_USE_RELEASE_BASE_URL}/${platform === 'darwin' ? COMPUTER_USE_MACOS_RELEASE_TAG : COMPUTER_USE_RELEASE_TAG}/${definition.name}`, }; } @@ -316,6 +320,7 @@ export interface InstallCuaDriverOptions { buildPlan?: (options: BuildCuaInstallerPlanOptions) => CuaInstallerPlan; resolveDriverPath?: typeof resolveCuaDriverPath; inspectDriver?: typeof inspectCuaDriver; + isManagedMacOSRuntimeSigned?: (driverPath: string) => Promise; prepareComputerUse?: (options: PrepareComputerUseOptions) => Promise; } @@ -323,6 +328,21 @@ export type InstallCuaDriverResult = | { status: 'reused'; path: string; version: string; computerUse?: ComputerUsePreparationResult } | { status: 'installed'; path: string; version: string; computerUse?: ComputerUsePreparationResult }; +export async function isManagedMacOSRuntimeSigned(driverPath: string): Promise { + try { + const names = ['cua-driver', 'cua-cursor-theme', 'cua_driver_node_runtime.node', 'libcua_driver_sdk.dylib']; + for (const name of names) { + const file = path.join(path.dirname(driverPath), name); + await execFileAsync('/usr/bin/codesign', ['--verify', '--strict', file], { timeout: 5_000, killSignal: 'SIGKILL' }); + const { stderr } = await execFileAsync('/usr/bin/codesign', ['-dv', '--verbose=4', file], { timeout: 5_000, killSignal: 'SIGKILL' }); + if (!stderr.includes('TeamIdentifier=J59GX2DRN2') || !stderr.includes('Authority=Developer ID Application:')) return false; + } + return true; + } catch { + return false; + } +} + async function withComputerUseHost( result: InstallCuaDriverResult, platform: NodeJS.Platform, @@ -356,7 +376,10 @@ export async function installCuaDriver( ); if (existingPath && mayReuseExisting && options.force !== true) { const existing = await inspectDriver(existingPath); - if (existing.status === 'ready' && existing.supported) { + const requiresSignedRuntime = platform === 'darwin' && process.env.AUTOHAND_CUA_DRIVER_PATH === undefined; + const signedRuntime = !requiresSignedRuntime + || await (options.isManagedMacOSRuntimeSigned ?? isManagedMacOSRuntimeSigned)(existingPath); + if (existing.status === 'ready' && existing.supported && signedRuntime) { return withComputerUseHost( { status: 'reused', path: existing.path, version: existing.version }, platform, diff --git a/tests/ci/releaseWorkflow.test.ts b/tests/ci/releaseWorkflow.test.ts index 45504eff..e1c50152 100644 --- a/tests/ci/releaseWorkflow.test.ts +++ b/tests/ci/releaseWorkflow.test.ts @@ -62,12 +62,12 @@ function loadReleaseSteps(): WorkflowStep[] { } const MACOS_SECRETS = [ - 'APPLICATION_CERT_BASE64', - 'CERT_PASSWORD', - 'DEVELOPER_NAME', - 'TEAM_ID', - 'APPLE_ID', - 'APP_SPECIFIC_PASSWORD', + 'CSC_LINK', + 'CSC_KEY_PASSWORD', + 'APPLE_TEAM_ID', + 'APPLE_API_KEY', + 'APPLE_API_KEY_ID', + 'APPLE_API_ISSUER', ] as const; function runMacOsSigningPreflight(channel: string, credentials: Record = {}) { @@ -140,9 +140,8 @@ function runVersionStep(manualVersion: string): string { describe('release workflow', () => { it('keeps the Computer Use deployment target independent of the macOS runner version', () => { const build = loadReleaseWorkflow().jobs.build.steps.find(step => step.name === 'Build Autohand Computer Use host'); - expect(build?.run).toContain('-target "$(uname -m)-apple-macosx13.0"'); - expect(build?.run).toContain('computer-use-component/autohand/macos/main.swift'); - expect(build?.run).toContain('-o "./binaries/Autohand Computer Use.app/Contents/MacOS/AutohandComputerUse"'); + expect(build?.run).toContain('computer-use-component/autohand/scripts/build-macos-host.sh'); + expect(build?.run).not.toContain('/usr/bin/swiftc'); }); it('builds and bundles both musl architectures alongside glibc Linux', () => { @@ -174,11 +173,11 @@ describe('release workflow', () => { expect(step?.shell).toBe('bash'); }); - it.each(['alpha', 'release'])('allows %s to use ad-hoc signing while Apple credentials are unavailable', channel => { + it.each(['alpha', 'release'])('blocks %s publication when Apple signing credentials are unavailable', channel => { const result = runMacOsSigningPreflight(channel); - expect(result.status, result.diagnostics).toBe(0); - expect(result.output).toBe('mode=adhoc\n'); - expect(result.diagnostics).toContain('not notarized'); + expect(result.status, result.diagnostics).toBe(1); + expect(result.output).toBe(''); + expect(result.diagnostics).toContain('CSC_LINK'); }); it.each(['alpha', 'release'])('selects Developer ID signing for fully configured %s releases', (channel) => { @@ -201,81 +200,22 @@ describe('release workflow', () => { }, ); - it('passes the selected signing mode to mutually exclusive macOS build steps', () => { + it('requires Developer ID signing and API notarization without a release fallback', () => { const workflow = loadReleaseWorkflow(); - const preflight = workflow.jobs.prepare.steps.find((step) => step.name === 'Select macOS signing mode'); + const preflight = workflow.jobs.prepare.steps.find(step => step.name === 'Select macOS signing mode'); expect(preflight?.id).toBe('macos-signing'); expect(preflight?.if).toBe("steps.check.outputs.should_release == 'true'"); - expect(preflight?.env?.RELEASE_CHANNEL).toBe('${{ steps.determine.outputs.channel }}'); for (const name of MACOS_SECRETS) { expect(preflight?.env?.[name]).toBe('${{ secrets.' + name + ' }}'); } expect(workflow.jobs.prepare.outputs?.macos_signing_mode).toBe('${{ steps.macos-signing.outputs.mode }}'); - const steps = workflow.jobs.build.steps; - const adhoc = steps.find((step) => step.name === 'Ad-hoc sign macOS artifacts'); - expect(adhoc?.if).toBe("runner.os == 'macOS' && needs.prepare.outputs.macos_signing_mode == 'adhoc'"); - expect(adhoc?.run).toContain('codesign --force --sign - --timestamp=none'); - expect(adhoc?.run).toContain('codesign --verify --strict --verbose=4'); - expect(adhoc?.run).toContain('codesign --verify --deep --strict --verbose=4'); - expect(adhoc?.run).toContain('matrix.artifact'); - expect(adhoc?.run).toContain('matrix.tracesArtifact'); - expect(adhoc?.run).toContain('Autohand Computer Use.app'); - expect(adhoc?.run).toContain('autohand-computer-use-${{ matrix.target }}.tar.gz'); - expect(adhoc?.run).not.toMatch(/notarytool|stapler|spctl/); - expect(steps.indexOf(adhoc!)).toBeGreaterThan(steps.findIndex((step) => step.name === 'Build Autohand Computer Use host')); - expect(steps.indexOf(adhoc!)).toBeLessThan(steps.findIndex((step) => step.name === 'Smoke test binary')); - }); - - it('labels ad-hoc macOS downloads in the published release notes', () => { - const steps = loadReleaseSteps(); - const notice = steps.find((step) => step.name === 'Document ad-hoc macOS signing'); - expect(notice?.if).toBe("needs.prepare.outputs.macos_signing_mode == 'adhoc'"); - expect(notice?.run).toContain('>> release-notes.md'); - expect(notice?.run).toContain('not notarized'); - expect(notice?.run).toContain('Open Anyway'); - expect(steps.indexOf(notice!)).toBeGreaterThan(steps.findIndex((step) => step.name === 'Generate release notes')); - expect(steps.indexOf(notice!)).toBeLessThan(steps.findIndex((step) => step.name === 'Create Release')); - }); - - it.skipIf(process.platform !== 'darwin')('runs the ad-hoc signing step and verifies archived Bun artifacts on macOS', () => { - const script = loadReleaseWorkflow().jobs.build.steps.find( - (step) => step.name === 'Ad-hoc sign macOS artifacts', - )?.run; - expect(script).toBeDefined(); - const directory = mkdtempSync(path.join(tmpdir(), 'autohand-signing-native-')); - const binaryDirectory = path.join(directory, 'binaries'); - const app = path.join(binaryDirectory, 'Autohand Computer Use.app'); - try { - mkdirSync(path.join(app, 'Contents/MacOS'), { recursive: true }); - const source = path.join(directory, 'probe.js'); - writeFileSync(source, 'console.log("signing-fixture");\n'); - const executable = path.join(binaryDirectory, 'autohand-probe'); - execFileSync('bun', ['build', source, '--compile', '--outfile', executable], { stdio: 'pipe', timeout: 20_000 }); - copyFileSync(executable, path.join(binaryDirectory, 'ahtraces-probe')); - copyFileSync(executable, path.join(app, 'Contents/MacOS/probe')); - writeFileSync(path.join(app, 'Contents/Info.plist'), ` - -CFBundleExecutableprobe -CFBundleIdentifierai.autohand.signing-fixture -CFBundlePackageTypeAPPL -`); - const signing = spawnSync('bash', ['-euo', 'pipefail', '-c', script! - .replaceAll('${{ matrix.artifact }}', 'autohand-probe') - .replaceAll('${{ matrix.tracesArtifact }}', 'ahtraces-probe') - .replaceAll('${{ matrix.target }}', 'darwin-probe')], { - cwd: directory, encoding: 'utf8', timeout: 20_000, - }); - expect(signing.status, `Bun ${execFileSync('bun', ['--version'], { encoding: 'utf8' }).trim()}: ${signing.stderr}`).toBe(0); - expect(execFileSync(executable, { encoding: 'utf8', timeout: 5_000 }).trim()).toBe('signing-fixture'); - const restored = path.join(directory, 'restored'); - mkdirSync(restored); - execFileSync('tar', ['-xzf', path.join(binaryDirectory, 'autohand-computer-use-darwin-probe.tar.gz'), '-C', restored]); - const restoredApp = path.join(restored, 'Autohand Computer Use.app'); - execFileSync('codesign', ['--verify', '--deep', '--strict', restoredApp], { stdio: 'pipe', timeout: 5_000 }); - expect(execFileSync(path.join(restoredApp, 'Contents/MacOS/probe'), { encoding: 'utf8', timeout: 5_000 }).trim()).toBe('signing-fixture'); - } finally { - rmSync(directory, { recursive: true, force: true }); - } + expect(workflow.jobs.build.steps.some(step => step.name === 'Ad-hoc sign macOS artifacts')).toBe(false); + expect(workflow.jobs.release.steps.some(step => step.name === 'Document ad-hoc macOS signing')).toBe(false); + const sign = workflow.jobs.build.steps.find(step => step.name === 'Sign and notarize macOS artifacts'); + expect(sign?.run).toContain('--key "$APPLE_API_KEY_PATH"'); + expect(sign?.run).toContain('--key-id "$APPLE_API_KEY_ID"'); + expect(sign?.run).toContain('--issuer "$APPLE_API_ISSUER"'); + expect(sign?.run).not.toContain('--apple-id'); }); it.skipIf(process.platform !== 'darwin')('preserves Bun runtime entitlements when signing hardened executables', () => { @@ -302,6 +242,7 @@ describe('release workflow', () => { .split('mkdir -p ./binaries/notarization')[0]! .replace(/IDENTITY=.*/, 'IDENTITY="-"') .replaceAll('--timestamp', '--timestamp=none') + .replaceAll('--keychain "$AUTOHAND_CODESIGN_KEYCHAIN"', '') .replaceAll('./native/macos/cli.entitlements.plist', path.join(REPOSITORY_ROOT, 'native/macos/cli.entitlements.plist')) .replaceAll('${{ matrix.artifact }}', 'autohand-probe') .replaceAll('${{ matrix.tracesArtifact }}', 'ahtraces-probe')], { @@ -541,17 +482,14 @@ describe('release workflow', () => { expect(uploadIndex).toBeGreaterThan(smokeIndex); expect(certificateStep?.if).toBe("runner.os == 'macOS' && needs.prepare.outputs.macos_signing_mode == 'developer-id'"); expect(certificateStep?.env).toMatchObject({ - APPLICATION_CERT_BASE64: '${{ secrets.APPLICATION_CERT_BASE64 }}', - CERT_PASSWORD: '${{ secrets.CERT_PASSWORD }}', + CSC_LINK: '${{ secrets.CSC_LINK }}', + CSC_KEY_PASSWORD: '${{ secrets.CSC_KEY_PASSWORD }}', }); - expect(certificateStep?.run).toContain('security import application.p12'); - expect(certificateStep?.run).toContain('Developer ID Application'); + expect(certificateStep?.run).toContain('autohand/scripts/import-apple-signing.sh'); expect(signStep?.if).toBe("runner.os == 'macOS' && needs.prepare.outputs.macos_signing_mode == 'developer-id'"); expect(signStep?.env).toMatchObject({ - DEVELOPER_NAME: '${{ secrets.DEVELOPER_NAME }}', - TEAM_ID: '${{ secrets.TEAM_ID }}', - APPLE_ID: '${{ secrets.APPLE_ID }}', - APP_SPECIFIC_PASSWORD: '${{ secrets.APP_SPECIFIC_PASSWORD }}', + APPLE_API_KEY_ID: '${{ secrets.APPLE_API_KEY_ID }}', + APPLE_API_ISSUER: '${{ secrets.APPLE_API_ISSUER }}', }); expect(signStep?.run).toContain('codesign --force --timestamp --options runtime'); expect(signStep?.run).toContain('codesign --verify --strict --verbose=4'); diff --git a/tests/computer/cuaInstaller.test.ts b/tests/computer/cuaInstaller.test.ts index 01dadd97..dac5d856 100644 --- a/tests/computer/cuaInstaller.test.ts +++ b/tests/computer/cuaInstaller.test.ts @@ -39,7 +39,7 @@ describe('Autohand Computer Use release assets', () => { expect(asset).toMatchObject({ name, format }); expect(asset.url).toBe( - `${COMPUTER_USE_RELEASE_BASE_URL}/${COMPUTER_USE_RELEASE_TAG}/${name}`, + `${COMPUTER_USE_RELEASE_BASE_URL}/${platform === 'darwin' ? 'computer-use-v0.28.2-autohand.1' : COMPUTER_USE_RELEASE_TAG}/${name}`, ); expect(asset.sha256).toMatch(/^[0-9a-f]{64}$/u); }); @@ -106,6 +106,21 @@ describe('verifyCuaInstallerAsset', () => { }); describe('installCuaDriver', () => { + it('repairs a compatible macOS engine whose managed native files lack Autohand Developer ID signatures', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'autohand-signing-upgrade-')); + tempRoots.push(root); + const binDirectory = path.join(root, 'bin'); + const fetchImpl = vi.fn(async () => { throw new Error('signed download started'); }); + await expect(installCuaDriver({ + platform: 'darwin', architecture: 'arm64', binDirectory, + resolveDriverPath: () => path.join(binDirectory, 'cua-driver'), + inspectDriver: async driverPath => ({ status: 'ready', path: driverPath, version: CUA_DRIVER_VERSION, supported: true }), + isManagedMacOSRuntimeSigned: async () => false, + prepareComputerUse: vi.fn(), fetchImpl, + })).rejects.toThrow('signed download started'); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + it('prepares Autohand Computer Use and requests its permissions when reusing a macOS engine', async () => { const prepareComputerUse = vi.fn(async () => ({ status: 'ready' as const, @@ -126,6 +141,7 @@ describe('installCuaDriver', () => { supported: true, }), prepareComputerUse, + isManagedMacOSRuntimeSigned: async () => true, }); expect(prepareComputerUse).toHaveBeenCalledWith({ From e8b7d9e0e74de5ea6be9e28434740e61fc7b9c1b Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 3 Nov 2026 10:10:24 +1300 Subject: [PATCH 6/6] Validate notarized CLI artifacts after transport Require Apple notarization checks for standalone CLI and ahtraces binaries, pin the strengthened component verifier, and make packaged-asset and file-URL assertions independent of source-tree paths and spaces in the checkout. Co-authored-by: Autohand Evolve --- .github/computer-use-ref | 2 +- .github/workflows/release.yml | 2 ++ tests/ci/releaseWorkflow.test.ts | 2 ++ tests/skills/SkillsRegistry.spec.ts | 8 ++++---- tests/ui/terminalMarkdown.test.ts | 4 +++- 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/computer-use-ref b/.github/computer-use-ref index d3b1d71d..7895c364 100644 --- a/.github/computer-use-ref +++ b/.github/computer-use-ref @@ -1 +1 @@ -f0d26f14fa176c0bf6ca557f7cd7797f4c5ea374 +31a5d26a1e696b86cc130efc50d6f3459131eba9 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19ba75cc..c40554cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -556,10 +556,12 @@ jobs: chmod +x "$binary" file "$binary" codesign --verify --strict --verbose=4 "$binary" + codesign --verify --strict -R=notarized --check-notarization "$binary" "$binary" --version < /dev/null chmod +x "$ahtraces" file "$ahtraces" codesign --verify --strict --verbose=4 "$ahtraces" + codesign --verify --strict -R=notarized --check-notarization "$ahtraces" "$ahtraces" --version < /dev/null mkdir -p computer-use-verification tar -xzf "$computer_use_archive" -C computer-use-verification diff --git a/tests/ci/releaseWorkflow.test.ts b/tests/ci/releaseWorkflow.test.ts index e1c50152..55432bc0 100644 --- a/tests/ci/releaseWorkflow.test.ts +++ b/tests/ci/releaseWorkflow.test.ts @@ -568,6 +568,8 @@ describe('release workflow', () => { path: 'binaries', }); expect(verifyStep?.run).toContain('codesign --verify --strict --verbose=4'); + expect(verifyStep?.run).toContain('codesign --verify --strict -R=notarized --check-notarization "$binary"'); + expect(verifyStep?.run).toContain('codesign --verify --strict -R=notarized --check-notarization "$ahtraces"'); expect(verifyStep?.run).toContain('"$binary" --version < /dev/null'); expect(verifyStep?.run).toContain('ahtraces'); expect(verifyStep?.run).toContain('Autohand Computer Use.app'); diff --git a/tests/skills/SkillsRegistry.spec.ts b/tests/skills/SkillsRegistry.spec.ts index 339469e7..e08ad709 100644 --- a/tests/skills/SkillsRegistry.spec.ts +++ b/tests/skills/SkillsRegistry.spec.ts @@ -81,7 +81,7 @@ ${body} const goalWriter = registry.getSkill('goal-writer'); expect(goalWriter).not.toBeNull(); expect(goalWriter?.source).toBe('builtin'); - expect(goalWriter?.path).toContain('src/skills/builtin/goal-writer/SKILL.md'); + expect(goalWriter?.path).toContain(path.join('skills', 'builtin', 'goal-writer', 'SKILL.md')); expect(goalWriter?.body).toContain('completion contract'); expect(goalWriter?.body).toContain('acceptance_criteria'); expect(goalWriter?.body).toContain('Never\n add criteria the user did not approve'); @@ -89,19 +89,19 @@ ${body} const deepResearch = registry.getSkill('deep-research'); expect(deepResearch).not.toBeNull(); expect(deepResearch?.source).toBe('builtin'); - expect(deepResearch?.path).toContain('src/skills/builtin/deep-research/SKILL.md'); + expect(deepResearch?.path).toContain(path.join('skills', 'builtin', 'deep-research', 'SKILL.md')); expect(deepResearch?.body).toContain('cited research report'); const extensionBuilder = registry.getSkill('extension-builder'); expect(extensionBuilder).not.toBeNull(); expect(extensionBuilder?.source).toBe('builtin'); - expect(extensionBuilder?.path).toContain('src/skills/builtin/extension-builder/SKILL.md'); + expect(extensionBuilder?.path).toContain(path.join('skills', 'builtin', 'extension-builder', 'SKILL.md')); expect(extensionBuilder?.body).toContain('Pi'); const brainstorm = registry.getSkill('brainstorm'); expect(brainstorm).not.toBeNull(); expect(brainstorm?.source).toBe('builtin'); - expect(brainstorm?.path).toContain('src/skills/builtin/brainstorm/SKILL.md'); + expect(brainstorm?.path).toContain(path.join('skills', 'builtin', 'brainstorm', 'SKILL.md')); expect(brainstorm?.body).toContain('Software Architect'); expect(brainstorm?.body).toContain('Product Owner'); expect(brainstorm?.body).toContain('Product Manager'); diff --git a/tests/ui/terminalMarkdown.test.ts b/tests/ui/terminalMarkdown.test.ts index b429c996..bf60fc9c 100644 --- a/tests/ui/terminalMarkdown.test.ts +++ b/tests/ui/terminalMarkdown.test.ts @@ -5,6 +5,8 @@ */ import { stripVTControlCharacters } from 'node:util'; import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; import stringWidth from 'string-width'; import { markdownRenderingSample } from '../../src/testing/scenarios/markdownRenderingScenario.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -47,7 +49,7 @@ describe('renderMarkdownForTerminal', () => { const rendered = renderMarkdownForTerminal('[Docs](https://example.test)\n\n![Preview](./preview.png)', { color: false }); expect(rendered).toContain('\u001b]8;;https://example.test\u0007Docs'); - expect(rendered).toContain(`\u001b]8;;file://${process.cwd()}/preview.png\u0007Image: Preview`); + expect(rendered).toContain(`\u001b]8;;${pathToFileURL(resolve('preview.png')).href}\u0007Image: Preview`); expect(stripVTControlCharacters(rendered)).toBe('Docs\n\nImage: Preview'); });