diff --git a/.github/computer-use-ref b/.github/computer-use-ref
index 7bba69e9e..7895c364a 100644
--- a/.github/computer-use-ref
+++ b/.github/computer-use-ref
@@ -1 +1 @@
-fc92b70417cdae941ba8b104a6e7b3b5b52a07e2
+31a5d26a1e696b86cc130efc50d6f3459131eba9
diff --git a/.github/workflows/README.md b/.github/workflows/README.md
index 1e09ed1ed..0e0987906 100644
--- a/.github/workflows/README.md
+++ b/.github/workflows/README.md
@@ -37,9 +37,18 @@ 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.
+
+ 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,25 +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** (optional while Apple enrollment is pending)
- - `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.
- - 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/ci.yml b/.github/workflows/ci.yml
index d24782fb7..9adbb47a2 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 d776821a3..6291612b0 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 883b2e8e4..c40554cb0 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
@@ -249,12 +242,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
@@ -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'
@@ -417,37 +384,20 @@ jobs:
- 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"
@@ -459,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"
@@ -470,21 +420,8 @@ 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-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 +429,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 +490,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: |
@@ -575,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'
@@ -582,11 +528,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,21 +546,30 @@ 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 }}"
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
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]
@@ -808,19 +763,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 7215e3c0c..b8bbc85b2 100644
--- a/docs/computer-control.md
+++ b/docs/computer-control.md
@@ -58,7 +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.
-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.
+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).
+
+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/native/macos/cli.entitlements.plist b/native/macos/cli.entitlements.plist
new file mode 100644
index 000000000..46f675661
--- /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/src/actions/gitSessionSnapshot.ts b/src/actions/gitSessionSnapshot.ts
index c0a00f0f1..ba1b3cf1f 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/actions/web.ts b/src/actions/web.ts
index 89213b1ac..626c4979d 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/computer/cuaInstaller.ts b/src/computer/cuaInstaller.ts
index 33bf73bd1..aaa18dd6c 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/src/core/agent/AgentLifecycleRunner.ts b/src/core/agent/AgentLifecycleRunner.ts
index 382fcbd5e..3dbcfaa27 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);
@@ -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.
@@ -815,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() },
@@ -1391,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;
@@ -1644,10 +1645,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 +1660,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/src/core/agent/ReactLoopRunner.ts b/src/core/agent/ReactLoopRunner.ts
index 1464d3dff..868b6c557 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 2f09a68db..cdcf60816 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/mcpStartupScenario.ts b/src/testing/scenarios/mcpStartupScenario.ts
index dbcbc349b..92ace6a32 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/src/testing/scenarios/searchStartupScenario.ts b/src/testing/scenarios/searchStartupScenario.ts
new file mode 100644
index 000000000..fce94ccd0
--- /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/ci/releaseWorkflow.test.ts b/tests/ci/releaseWorkflow.test.ts
index d39c9da32..55432bc06 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 = {}) {
@@ -138,6 +138,12 @@ 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('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', () => {
const workflow = loadReleaseWorkflow();
const targets = workflow.jobs.build.strategy?.matrix?.include ?? [];
@@ -167,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) => {
@@ -194,78 +200,66 @@ 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'));
+ 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')('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;
+ 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-signing-native-'));
- const binaryDirectory = path.join(directory, 'binaries');
- const app = path.join(binaryDirectory, 'Autohand Computer Use.app');
+ 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("signing-fixture");\n');
- const executable = path.join(binaryDirectory, 'autohand-probe');
+ 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(binaryDirectory, 'ahtraces-probe'));
- copyFileSync(executable, path.join(app, 'Contents/MacOS/probe'));
+ 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.signing-fixture
+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('--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')
- .replaceAll('${{ matrix.target }}', 'darwin-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, `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');
+ 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 });
}
@@ -488,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');
@@ -523,14 +514,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 +550,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',
@@ -577,9 +568,15 @@ 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');
+ 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',
diff --git a/tests/computer/cuaInstaller.test.ts b/tests/computer/cuaInstaller.test.ts
index 01dadd971..dac5d8562 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({
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 000000000..7537d6f58
--- /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/core/agent/AgentLifecycleRunner.startup-snapshot.test.ts b/tests/core/agent/AgentLifecycleRunner.startup-snapshot.test.ts
new file mode 100644
index 000000000..1ddd03063
--- /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 afe363cbf..cf321723f 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/search/accountSearchConfig.test.ts b/tests/search/accountSearchConfig.test.ts
index c47ce0d9b..7456c7baa 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/skills/SkillsRegistry.spec.ts b/tests/skills/SkillsRegistry.spec.ts
index 339469e7c..e08ad7095 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/tuistory/built-cli-composer.tuistory.test.ts b/tests/tuistory/built-cli-composer.tuistory.test.ts
index ef6bb18c0..84bb54d87 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: {
diff --git a/tests/tuistory/mcp-startup.tuistory.test.ts b/tests/tuistory/mcp-startup.tuistory.test.ts
index 77a904182..990bfd4d5 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');
diff --git a/tests/tuistory/search-startup.tuistory.test.ts b/tests/tuistory/search-startup.tuistory.test.ts
new file mode 100644
index 000000000..d09b97029
--- /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);
+});
diff --git a/tests/ui/terminalMarkdown.test.ts b/tests/ui/terminalMarkdown.test.ts
index b429c9962..bf60fc9c1 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', { 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');
});