diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5d58d447..9b9c2492 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -228,3 +228,184 @@ jobs:
path: test-results/e2e-safe-receipt.json
if-no-files-found: error
retention-days: 7
+
+ linux:
+ name: Linux ${{ matrix.arch }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: x64
+ runner: ubuntu-24.04
+ - arch: arm64
+ runner: ubuntu-24.04-arm
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 60
+ steps:
+ - name: Check out source
+ uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
+
+ - name: Use Node.js 22
+ uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
+ with:
+ node-version: 22.22.3
+ cache: npm
+
+ - name: Install Linux test dependencies
+ run: sudo apt-get update && sudo apt-get install --yes rpm xvfb pkg-config libsecret-1-dev libselinux1-dev gnome-keyring dbus-x11
+
+ - name: Install locked dependencies
+ run: npm ci
+
+ - name: Verify TypeScript and lint
+ run: npm run type-check && npm run lint
+
+ - name: Run Pi extension, Linux contract, and native helper tests
+ run: npm run test:pi-extensions && npm run test:linux-contracts && npm run test:linux-native
+
+ - name: Verify Bot authority with an isolated persistent keyring
+ run: npm run test:secret-service-integration
+
+ - name: Build, install, and verify Linux distributions
+ run: |
+ set -euo pipefail
+ npm run dist:linux
+ node scripts/verify-linux-package.mjs release/linux-distribution
+ node scripts/prepare-linux-update-feed.mjs release/linux-distribution "${{ matrix.arch }}" "$(node -p 'require("./package.json").version')"
+ sudo apt-get install --yes ./release/linux-distribution/*.deb
+ dpkg --verify aiden-agent
+ ! ldd "/opt/Aiden Agent/aiden-agent" | grep -q "not found"
+ test -f /usr/share/applications/com.sambitcreate.aiden-agent.desktop
+ grep -F 'StartupWMClass=com.sambitcreate.aiden-agent' /usr/share/applications/com.sambitcreate.aiden-agent.desktop
+ expected_version="$(node -p 'require("./package.json").version')"
+ test "$(aiden-agent --no-sandbox --version)" = "$expected_version"
+ chmod +x ./release/linux-distribution/*.AppImage
+ appimage_output="$(./release/linux-distribution/*.AppImage --appimage-extract-and-run --no-sandbox --version)"
+ grep -F "$expected_version" <<<"$appimage_output"
+
+ - name: Prepare baseline-verified RPM for Fedora acceptance
+ if: matrix.arch == 'x64'
+ shell: bash
+ run: |
+ set -euo pipefail
+ shopt -s nullglob
+ rpm_files=(release/linux-distribution/*.rpm)
+ test "${#rpm_files[@]}" -eq 1
+ rpm_name="$(basename "${rpm_files[0]}")"
+ (
+ cd release/linux-distribution
+ sha256sum "$rpm_name" > rpm.sha256
+ )
+
+ - name: Upload baseline-verified RPM for Fedora acceptance
+ if: matrix.arch == 'x64'
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
+ with:
+ name: linux-rpm-x64-${{ github.run_id }}-${{ github.run_attempt }}
+ path: |
+ release/linux-distribution/*.rpm
+ release/linux-distribution/rpm.sha256
+ if-no-files-found: error
+ retention-days: 1
+
+ - name: Smoke the installed GUI without a keyring session
+ shell: bash
+ run: |
+ set -euo pipefail
+ smoke_root="$RUNNER_TEMP/aiden-linux-smoke-${{ matrix.arch }}"
+ mkdir -p "$smoke_root/home" "$smoke_root/config" "$smoke_root/cache" "$smoke_root/data"
+ set +e
+ HOME="$smoke_root/home" \
+ XDG_CONFIG_HOME="$smoke_root/config" \
+ XDG_CACHE_HOME="$smoke_root/cache" \
+ XDG_DATA_HOME="$smoke_root/data" \
+ timeout --signal=KILL 15s xvfb-run --auto-servernum aiden-agent --no-sandbox \
+ >"$smoke_root/output.log" 2>&1
+ status=$?
+ set -e
+ # A living GUI is the success condition. Kill the whole timeout
+ # process group instead of asking Electron to perform a production
+ # shutdown inside an incomplete headless desktop session.
+ if [[ "$status" -ne 137 ]]; then
+ cat "$smoke_root/output.log"
+ exit 1
+ fi
+ if grep -Eiq '(FATAL|symbol lookup error|error while loading shared libraries|Failed to start Aiden Agent)' "$smoke_root/output.log"; then
+ cat "$smoke_root/output.log"
+ exit 1
+ fi
+
+ - name: Run deterministic Electron E2E gate
+ if: matrix.arch == 'x64'
+ run: xvfb-run --auto-servernum npm run test:e2e
+
+ - name: Build sanitized E2E failure receipt
+ if: ${{ failure() && matrix.arch == 'x64' }}
+ run: npm run diagnostics:failure-receipt -- test-results/e2e-safe-receipt.json electron-e2e test-failed
+
+ - name: Upload sanitized E2E failure receipt
+ if: ${{ failure() && matrix.arch == 'x64' }}
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
+ with:
+ name: playwright-e2e-linux-${{ github.run_id }}-${{ github.run_attempt }}
+ path: test-results/e2e-safe-receipt.json
+ if-no-files-found: error
+ retention-days: 7
+
+ linux-rpm:
+ name: Linux x64 · Fedora RPM
+ needs: linux
+ runs-on: ubuntu-24.04
+ container: fedora:44
+ timeout-minutes: 60
+ steps:
+ - name: Install Fedora build prerequisites
+ run: dnf install --assumeyes git gcc gcc-c++ make python3 pkgconf-pkg-config libsecret-devel libselinux-devel cargo rust dbus-daemon
+
+ - name: Check out source
+ uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
+
+ - name: Use Node.js 22
+ uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
+ with:
+ node-version: 22.22.3
+ cache: npm
+
+ - name: Install locked dependencies
+ run: npm ci
+
+ - name: Verify Fedora Pi extensions, contracts, and native helpers
+ run: npm run type-check && npm run test:pi-extensions && npm run test:linux-contracts && npm run test:linux-native
+
+ - name: Download baseline-verified RPM
+ uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e
+ with:
+ name: linux-rpm-x64-${{ github.run_id }}-${{ github.run_attempt }}
+ path: release/linux-rpm-smoke
+
+ - name: Install and verify baseline-verified RPM package
+ run: |
+ set -euo pipefail
+ shopt -s nullglob
+ rpm_files=(release/linux-rpm-smoke/*.rpm)
+ test "${#rpm_files[@]}" -eq 1
+ (
+ cd release/linux-rpm-smoke
+ sha256sum --check rpm.sha256
+ )
+ expected_version="$(node -p 'require("./package.json").version')"
+ test "$(rpm -qp --queryformat '%{NAME} %{VERSION} %{ARCH}' "${rpm_files[0]}")" = \
+ "aiden-agent $expected_version x86_64"
+ dnf install --assumeyes "${rpm_files[0]}"
+ node scripts/verify-linux-package.mjs "/opt/Aiden Agent"
+ rpm_verify_output="$(rpm --verify aiden-agent || true)"
+ if [ -n "$rpm_verify_output" ]; then
+ # electron-builder deliberately enables its setuid fallback when
+ # user namespaces do not work (including containerized CI). Keep
+ # every other RPM integrity difference fatal and prove the exact
+ # privileged file owner/mode before accepting that one transition.
+ test "$rpm_verify_output" = '.M....... /opt/Aiden Agent/chrome-sandbox'
+ test "$(stat -c '%a:%U:%G' '/opt/Aiden Agent/chrome-sandbox')" = '4755:root:root'
+ fi
+ ! ldd "/opt/Aiden Agent/aiden-agent" | grep -q "not found"
+ test "$(aiden-agent --no-sandbox --version)" = "$expected_version"
diff --git a/.github/workflows/linux-installers.yml b/.github/workflows/linux-installers.yml
new file mode 100644
index 00000000..6f8c3582
--- /dev/null
+++ b/.github/workflows/linux-installers.yml
@@ -0,0 +1,78 @@
+name: Build Linux installers
+
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: aiden-linux-installers-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ name: Linux ${{ matrix.arch }} installers
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: x64
+ runner: ubuntu-24.04
+ - arch: arm64
+ runner: ubuntu-24.04-arm
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 90
+ steps:
+ - name: Check out source
+ uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
+
+ - name: Use Node.js 22
+ uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
+ with:
+ node-version: 22.22.3
+ cache: npm
+
+ - name: Install Linux package dependencies
+ run: sudo apt-get update && sudo apt-get install --yes rpm xvfb pkg-config libsecret-1-dev libselinux1-dev
+
+ - name: Install locked dependencies
+ run: npm ci
+
+ - name: Verify Linux contracts and installer selection
+ run: npm run type-check && npm run lint && npm run test:linux-contracts && node --test scripts/install.test.mjs
+
+ - name: Build and verify Linux installers
+ run: |
+ set -euo pipefail
+ npm run models:refresh
+ npm run dist:linux
+ node scripts/verify-linux-package.mjs release/linux-distribution
+ node scripts/prepare-linux-update-feed.mjs release/linux-distribution "${{ matrix.arch }}" "$(node -p 'require("./package.json").version')"
+ sudo apt-get install --yes ./release/linux-distribution/*.deb
+ dpkg --verify aiden-agent
+ expected_version="$(node -p 'require("./package.json").version')"
+ test "$(aiden-agent --no-sandbox --version)" = "$expected_version"
+ chmod +x ./release/linux-distribution/*.AppImage
+ ./release/linux-distribution/*.AppImage --appimage-extract-and-run --no-sandbox --version | grep -F "$expected_version"
+
+ - name: Stage installer artifacts
+ run: |
+ set -euo pipefail
+ cp install.sh release/linux-distribution/install.sh
+ cd release/linux-distribution
+ sha256sum -- *.AppImage *.deb *.rpm latest-linux*.yml install.sh > SHA256SUMS
+
+ - name: Upload verified Linux installers
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
+ with:
+ name: aiden-linux-installers-${{ matrix.arch }}-${{ github.sha }}
+ path: |
+ release/linux-distribution/*.AppImage
+ release/linux-distribution/*.deb
+ release/linux-distribution/*.rpm
+ release/linux-distribution/latest-linux*.yml
+ release/linux-distribution/install.sh
+ release/linux-distribution/SHA256SUMS
+ if-no-files-found: error
+ retention-days: 7
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e2876f87..f46f9f3e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,4 +1,4 @@
-name: Release macOS
+name: Release desktop
on:
push:
@@ -14,7 +14,7 @@ permissions:
contents: write
concurrency:
- group: aiden-agent-macos-release
+ group: aiden-agent-desktop-release
cancel-in-progress: false
jobs:
@@ -149,6 +149,190 @@ jobs:
if: ${{ steps.version.outputs.publish == 'true' }}
run: npm run test:e2e:diagnostics:packaged
+ - name: Stage verified macOS release assets
+ if: ${{ steps.version.outputs.publish == 'true' }}
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
+ with:
+ name: release-macos
+ path: release/distribution
+ if-no-files-found: error
+ retention-days: 2
+
+ linux-release:
+ # Manual dispatches from other refs must not publish unattested Linux assets.
+ if: ${{ vars.RELEASES_ENABLED == 'true' && github.ref == 'refs/heads/main' }}
+ permissions:
+ contents: read
+ id-token: write
+ attestations: write
+ name: Linux ${{ matrix.arch }} release
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: x64
+ runner: ubuntu-24.04
+ - arch: arm64
+ runner: ubuntu-24.04-arm
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 90
+ environment: release
+ steps:
+ - name: Check out source
+ uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
+
+ - name: Use Node.js 22
+ uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
+ with:
+ node-version: 22.22.3
+ cache: npm
+
+ - name: Resolve the declared release version
+ id: version
+ shell: bash
+ run: |
+ set -euo pipefail
+ base_version="$(node -p "require('./package.json').version")"
+ base_tag_match="$(git ls-remote --tags origin "refs/tags/v${base_version}")"
+ base_tag_exists=false
+ if [[ -n "$base_tag_match" ]]; then
+ base_tag_exists=true
+ fi
+ selection="$(node scripts/prepare-ci-release.mjs "$base_tag_exists")"
+ release_version="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).version)' "$selection")"
+ release_tag="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).tag)' "$selection")"
+ should_publish="$(node -e 'process.stdout.write(String(JSON.parse(process.argv[1]).publish))' "$selection")"
+ echo "version=$release_version" >> "$GITHUB_OUTPUT"
+ echo "tag=$release_tag" >> "$GITHUB_OUTPUT"
+ echo "publish=$should_publish" >> "$GITHUB_OUTPUT"
+
+ - name: Install Linux package dependencies
+ if: ${{ steps.version.outputs.publish == 'true' }}
+ run: sudo apt-get update && sudo apt-get install --yes rpm xvfb pkg-config libsecret-1-dev libselinux1-dev
+
+ - name: Install locked dependencies
+ if: ${{ steps.version.outputs.publish == 'true' }}
+ run: npm ci
+
+ - name: Verify Linux contracts, diagnostics, and native helpers
+ if: ${{ steps.version.outputs.publish == 'true' }}
+ run: npm run type-check && npm run lint && npm run test:diagnostics && npm run test:linux-contracts && npm run test:linux-native
+
+ - name: Build and verify Linux distributions
+ if: ${{ steps.version.outputs.publish == 'true' }}
+ run: |
+ set -euo pipefail
+ npm run models:refresh
+ npm run dist:linux
+ node scripts/verify-linux-package.mjs release/linux-distribution
+ node scripts/prepare-linux-update-feed.mjs release/linux-distribution "${{ matrix.arch }}" "$(node -p 'require("./package.json").version')"
+ sudo apt-get install --yes ./release/linux-distribution/*.deb
+ dpkg --verify aiden-agent
+ ! ldd "/opt/Aiden Agent/aiden-agent" | grep -q "not found"
+ expected_version="$(node -p 'require("./package.json").version')"
+ test "$(aiden-agent --no-sandbox --version)" = "$expected_version"
+ chmod +x ./release/linux-distribution/*.AppImage
+ appimage_output="$(./release/linux-distribution/*.AppImage --appimage-extract-and-run --no-sandbox --version)"
+ grep -F "$expected_version" <<<"$appimage_output"
+
+ - name: Smoke the exact release GUI without a keyring session
+ if: ${{ steps.version.outputs.publish == 'true' }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ smoke_root="$RUNNER_TEMP/aiden-linux-release-smoke-${{ matrix.arch }}"
+ mkdir -p "$smoke_root/home" "$smoke_root/config" "$smoke_root/cache" "$smoke_root/data"
+ set +e
+ HOME="$smoke_root/home" \
+ XDG_CONFIG_HOME="$smoke_root/config" \
+ XDG_CACHE_HOME="$smoke_root/cache" \
+ XDG_DATA_HOME="$smoke_root/data" \
+ timeout --signal=KILL 15s xvfb-run --auto-servernum aiden-agent --no-sandbox \
+ >"$smoke_root/output.log" 2>&1
+ status=$?
+ set -e
+ # A living GUI is the success condition. Kill the whole timeout
+ # process group instead of asking Electron to perform a production
+ # shutdown inside an incomplete headless desktop session.
+ if [[ "$status" -ne 137 ]]; then
+ cat "$smoke_root/output.log"
+ exit 1
+ fi
+ if grep -Eiq '(FATAL|symbol lookup error|error while loading shared libraries|Failed to start Aiden Agent)' "$smoke_root/output.log"; then
+ cat "$smoke_root/output.log"
+ exit 1
+ fi
+
+ - name: Attest verified Linux packages and update feeds
+ if: ${{ github.ref == 'refs/heads/main' && steps.version.outputs.publish == 'true' }}
+ uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
+ with:
+ subject-path: |
+ release/linux-distribution/*.AppImage
+ release/linux-distribution/*.deb
+ release/linux-distribution/*.rpm
+ release/linux-distribution/latest-linux*.yml
+ create-storage-record: false
+ push-to-registry: false
+
+ - name: Stage verified Linux release assets
+ if: ${{ steps.version.outputs.publish == 'true' }}
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
+ with:
+ name: release-linux-${{ matrix.arch }}
+ path: |
+ release/linux-distribution/*.AppImage
+ release/linux-distribution/*.deb
+ release/linux-distribution/*.rpm
+ release/linux-distribution/latest-linux*.yml
+ if-no-files-found: error
+ retention-days: 2
+
+ publish:
+ if: vars.RELEASES_ENABLED == 'true'
+ name: Publish verified desktop release
+ needs:
+ - release
+ - linux-release
+ runs-on: ubuntu-24.04
+ timeout-minutes: 20
+ environment: release
+ steps:
+ - name: Check out source
+ uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
+
+ - name: Use Node.js 22
+ uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
+ with:
+ node-version: 22.22.3
+
+ - name: Resolve release identity
+ id: version
+ shell: bash
+ run: |
+ set -euo pipefail
+ base_version="$(node -p "require('./package.json').version")"
+ base_tag_match="$(git ls-remote --tags origin "refs/tags/v${base_version}")"
+ base_tag_exists=false
+ if [[ -n "$base_tag_match" ]]; then
+ base_tag_exists=true
+ fi
+ selection="$(node scripts/prepare-ci-release.mjs "$base_tag_exists")"
+ release_version="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).version)' "$selection")"
+ release_tag="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).tag)' "$selection")"
+ should_publish="$(node -e 'process.stdout.write(String(JSON.parse(process.argv[1]).publish))' "$selection")"
+ echo "version=$release_version" >> "$GITHUB_OUTPUT"
+ echo "tag=$release_tag" >> "$GITHUB_OUTPUT"
+ echo "publish=$should_publish" >> "$GITHUB_OUTPUT"
+
+ - name: Download verified desktop assets
+ if: ${{ steps.version.outputs.publish == 'true' }}
+ uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e
+ with:
+ pattern: release-*
+ path: release/distribution
+ merge-multiple: true
+
- name: Publish verified release assets
if: ${{ steps.version.outputs.publish == 'true' }}
shell: bash
@@ -156,6 +340,4 @@ jobs:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ steps.version.outputs.tag }}
RELEASE_VERSION: ${{ steps.version.outputs.version }}
- run: |
- set -euo pipefail
- bash scripts/publish-github-release.sh release/distribution
+ run: bash scripts/publish-github-release.sh release/distribution
diff --git a/.papercuts/troubleshooting.md b/.papercuts/troubleshooting.md
index e96b4e76..f764c71e 100644
--- a/.papercuts/troubleshooting.md
+++ b/.papercuts/troubleshooting.md
@@ -171,6 +171,33 @@ real preload bridge, use System Events to click the real native button by its
exact accessibility label, and verify a post-start diagnostic event plus
`uploadToServer: false`.
+## Linux desktop reconciliation
+
+- A long-lived platform branch can contain hundreds of duplicated feature commits while the platform port itself is one checkpoint. Preserve both histories in a merge commit, but build the result from the latest shared tree plus that checkpoint's intent; a normal textual merge lets stale parallel history overwrite newer features and multiplies conflicts.
+- A Linux Electron artifact with native Node modules cannot be truthfully accepted from a macOS build host. Build and inspect it on native x64/arm64 Linux runners, then install the generated DEB/RPM and smoke the executable; cross-target metadata checks alone do not prove the active `node-pty`, Sherpa ONNX, or C-helper layout.
+- Remote Access E2E must not assume Aiden's default private port is globally unused. Assert disabled state through the test process's own IPC snapshot, then derive the committed health-check port from that same installation after enablement so another running Aiden profile cannot create a false pass or failure.
+- Native Linux acceptance can look green while exercising stale macOS-built outputs or skipping behind a Darwin-only guard. The Linux suite must build both production and test helpers, run the real adapter-to-helper boundary, and launch a freshly installed package from an empty XDG profile.
+- A native generation token derived from `stat` is platform-shaped: Linux has no birth-time fields and emits seven components, while macOS emits nine. Validate those two exact wire shapes at the TypeScript boundary instead of assuming the macOS token or accepting every intermediate field count.
+- Compiling native helpers on a new distro can silently raise the package's glibc floor even when the Electron shell still launches. Inspect every packaged helper, `.node`, and `.so` symbol table and fail packaging above the declared baseline; avoid C-library parsers whose symbol version was retargeted by newer libc headers when a bounded local parser is straightforward.
+- Debian virtual packages can satisfy an unversioned `libasound2` dependency with an OSS compatibility shim that lacks Electron's required ALSA symbols. Use a versioned `libasound2t64 | libasound2` alternative so apt selects the real ALSA implementation on both time64 and older Debian families.
+- The legacy arm64 AppImage launcher links against the unversioned development name `libz.so`, so a normal desktop with only `libz.so.1` silently fails before Electron starts. Pin electron-builder's static AppImage runtime and execute the release artifact on a clean target-architecture system; installing `zlib` development files would only hide the defect.
+- A packaged dependency can retain Mach-O and PE files with a `.node` suffix beside the active Linux prebuild. Native compatibility verification must identify ELF magic before invoking `objdump`; the suffix alone is not an operating-system contract.
+- macOS-to-Linux Docker source transfers can materialize AppleDouble `._*` metadata sidecars. Playwright must ignore those names explicitly or it will parse a `._*.spec.ts` binary sidecar as JavaScript even though the real tests and application build are healthy.
+- A hermetic Electron E2E fixture can preserve `PATH` and locale yet still discard the display transport established by `xvfb-run`. On Linux, pass through only the X11/Wayland connection variables (`DISPLAY`, `XAUTHORITY`, `WAYLAND_DISPLAY`, `XDG_RUNTIME_DIR`, and `XDG_SESSION_TYPE`); when checking the child, account for the exact non-secret desktop/accessibility variables Chromium/GTK inject after launch while continuing to reject ambient credential variables.
+- electron-builder's RPM post-install script changes `chrome-sandbox` from the archived `0755` mode to `4755` when user namespaces are unavailable, so a clean `rpm --verify` reports one expected mode difference in containers. Accept only that exact line after separately proving `4755:root:root`; any additional RPM verification output remains fatal.
+- Fail-closed Linux secret storage must not make keyless local providers unusable. A keyless-to-keyless provider transition cannot expose or bind a secret, so save and portable-config reconciliation may bypass the secret backend for that exact case; any transition from or to a keyed connection still requires credential reconciliation.
+- Electron's conventional Linux `window-all-closed` quit policy conflicts with an explicitly enabled background Remote Access listener. Let a synchronously observed running listener own application lifetime after the last window closes; normal Linux last-window close still quits when no background service is active, and an explicit Quit still performs the complete shutdown drain.
+- A freshly authenticated Linux Tailscale daemon lets an unprivileged desktop app read status but rejects Serve writes until `sudo tailscale set --operator=$USER` is granted once. Recognize that exact CLI rejection only after confirming the Serve fingerprint stayed unchanged, then surface the remediation instead of collapsing it into an uncertain mutation.
+- Probing a Linux Tailscale node's own MagicDNS HTTPS name from that same OrbStack VM can time out even while another tailnet peer reaches it immediately. Hold the scoped route open and probe from a separate peer when accepting the iOS network direction; always verify the temporary handler and listener are gone afterward.
+- Darwin can deliver or coalesce a directory's already-queued creation notification after `fs.watch` registration. Before a watcher integration test acquires the lease whose invalidation it means to observe, establish a quiet baseline so a rapid follow-up edit is the event under test rather than a registration race.
+- Playwright's `fill("")` and `clear()` use the same select-and-delete path as a manual text-clearing test, so swapping among those APIs does not avoid an intermittent Electron/Xvfb deletion miss. When the product benefits from it, expose an accessible clear action and exercise that real user path while retaining an exact empty-value assertion.
+
+## Linux parity audit — 2026-09-11
+
+- The documented `.memory/` directory is absent in this checkout; used checked-in Linux documentation and plans for project context.
+- Linux support PR #71 is historically green but currently conflicts with main; stacked fixes PR #89 is separate and has failing verification/Linux jobs. Pulling the support branch alone does not include those fixes.
+- PR #89 hosted logs pinpoint TS2322 at `main/services/aiden-remote-service.ts:1181`: returned `permission_denied` is missing from the service status `tailscaleErrorCode` union, blocking macOS and both Linux verification jobs.
+
## Pi journal promotion recovery
A promoted v4 journal may legitimately retain a `.v3-backup` after its migration
@@ -429,3 +456,94 @@ symlink with this checkout's own npm ci. Full type-check and lint then passed.
- E2E chat-title expectations assume the deterministic chat-model route. On a Mac where the native Foundation Models helper reports `ready`, automatic titles come from Apple Intelligence instead, so `chat-message-queue` sidebar-title lookups fail locally while passing in CI; probe the helper or move it aside before treating those failures as regressions.
- `git add` on the tracked-but-ignored `.papercuts/troubleshooting.md` still needs `-f` after conflict resolution.
+
+- Phase 2 merge has 31 conflicts spanning shared features and Linux integrations; resolve by intent and inspect automatic merges, because branch histories duplicate prior feature work.
+
+- Package conflict resolution dropped the Linux-only `bonjour-service` dependency; restored the exact pinned version before rerunning remote tests.
+- Linux mobile revision-conflict text said "changed on the paired desktop" while recovery UI searched "changed on the desktop"; aligned the guard and added coverage.
+
+- Linux ARM64 Xvfb exposed hidden browser annotation-preview and recording startup timeouts despite macOS Electron passing; keep real target-platform interaction tests as an integration gate.
+
+- Current main AGENTS file still carried the older release-only models.dev wording. Reconciled it to the root user-provided manual-action policy alongside the source restoration; cache reads remain offline and runtime limits stay bundled.
+
+- 2026-09-12: libsecret low-level D-Bus encoding/path declarations require SECRET_API_SUBJECT_TO_CHANGE; strict native compilation caught the missing declaration before runtime verification.
+
+- 2026-09-12: Real Linux Settings acceptance exposed a stale “This Mac settings” test assertion; use the existing platform label. Node coverage injects NODE_V8_COVERAGE into fake helpers despite explicit spawn env, so assert and account for the instrumentation field.
+- 2026-09-12: Reusing a container snapshot with Xvfb state stalled xvfb-run readiness; a fresh explicit display restored the bounded Electron test.
+
+- 2026-09-12: ARM64 OrbStack recording reproduces outside Aiden; renderer SIGILL at cntd matches libyuv fab11704 (SME without SVE on Apple ARM). Environment disable flags are excluded from Chromium builds; requires an upstream-fixed Electron binary. Debugger used an isolated SYS_PTRACE container.
+
+- 2026-09-12: electron-updater quitAndInstall swallows installer failures; Linux handoff must track actual install result so protected shutdown still quits on failure. Real AppImage mount acceptance required fuse3 plus /dev/fuse and SYS_ADMIN in an isolated container.
+
+- 2026-09-12: Portal hold review caught ordinary shortcut reconciliation reclaiming its chord and async Settings writes lacking commit-time revision fencing. Fixed both with regression tests. Hosted x64 revealed native chrome leaves573px content in600px window, exposing ModelPad minimum-size overflow.
+
+- 2026-09-12: Linux empty-chat migration correctly preserved candidates because V2 subagent storage rejected native seven-field generations. Shared strict seven/nine-field validation fixes deletion/restart without weakening evidence requirements.
+
+- 2026-09-12: OrbStack kernel exposes capability,landlock,yama,bpf without SELinux. Fedora userspace in this host cannot validate enforcing SELinux admission; require a separately booted SELinux-capable kernel.
+
+- 2026-09-12: Providers header action group exceeded narrow Linux content by17px; max-width allows its existing wrap. Migration E2E seeded index while Electron could normalize/write it; moved disk setup after verified shutdown. Native setSize also precedes renderer resize, so geometry tests now await content width.
+
+- 2026-09-12: Isolated Fedora VM setup: Homebrew QEMU installation could not resolve a capstone bottle on macOS27; checking the official UTM bundle as a bounded alternative.
+
+- 2026-09-12: Fedora RPM CI reached the new portal suite but lacked dbus-run-session. Fedora44 provides it in dbus-daemon; add that explicit prerequisite rather than skip native acceptance.
+
+- 2026-09-12: Real GNOME50GlobalShortcuts rejected direct helperlaunch with NotAllowed: An app id is required. Privatebusmock didnotmodelhostRegistry registration; add fixedAidenDesktopID registration beforeportalrequests and validate realcompositor.
+
+- 2026-09-12: Fedora44 SELinux development interfaces expose dev_rw_null, not dev_read_write_null. Prototype base-policy compile failed safely and cleanup completed; checked installed interface before retry.
+
+- 2026-09-12: Scoped SELinux deny also blocks the root runner's unconfined-domain /proc reads. Keep the deny intact; native holder verifies its own domain before exposing its synthetic socket, while root uses service liveness for hardened restart evidence.
+
+- 2026-09-12: Fedora stock dontaudit suppresses unconfined reads of domain-labeled /proc files. SELinux probe now records this explicit evidence limit, requires exact-PID enforcing AVCs for file/socket, and avoids global -DB. Appended audit-byte capture avoids ausearch recent-window ambiguity.
+
+- 2026-09-12: GNOME 50.4 delivered Ctrl+D activation without release when Control was released first; plain F8 delivered both. Portal results expose only a human-readable description, so safe binding detection cannot use the requested accelerator. Keep GNOME hold unavailable until reliable release behavior can be established.
+
+- 2026-09-12: SELinux delegation probe could not use systemd-run --pipe because dbus-broker rejected forwarding SSH-origin descriptors. Dedicated synthetic report files avoid that unrelated path. An outgoing exec also hit fd/use denial on its executable before main; record this as a launch-policy limitation, and test fork/domain-change inheritance separately rather than count a failed launch as isolation.
+
+- 2026-09-12: Electron role overlay initially failed CIL set-expression compilation; corrected syntax and removed the temporary base module before retrying. The fixture now denies main execute_no_trans and explicitly transitions its shell/bin test commands, avoiding an Electron-only role check.
+
+- 2026-09-12: Fedora domtrans_pattern did not grant the target domain entrypoint permission. The first Electron role test failed zygote exec; loaded-policy inspection identified the missing child entrypoint. Added explicit fixture entrypoint declarations without disabling the sandbox. Stock policy reports memfd_class=0, so ordinary file-exec checks do not claim complete anonymous-memory execution coverage.
+
+- 2026-09-12: Electron zygote sets NoNewPrivs before exec. A diagnostic without the execute_no_trans subtraction launched but left zygote descendants in main_t, correctly failing role verification. Kernel-source review identified the dedicated main-to-child process2 nnp_transition permission; the next run restores execute_no_trans denial and tests that narrow transition grant. Extra GTK image-loader descendants also need inventory coverage.
+
+- 2026-09-12: With the narrow NNP transition grant, observed Electron children entered child_t and GUI/network/commands worked. The verifier still rejected a systemd cgroup snapshot that omitted main/Node processes despite live receipts. Treat cgroup membership as incomplete inventory; collect the dedicated fixture domains and pin processes with pidfds before cleanup. Desktop scope migration remains a production-containment concern.
+
+- 2026-09-12: Combined Electron IPC fixture needed matching Fedora nodejs22-devel N-API headers. The normal guest package transaction also updated OpenSSL 3.5.5 to 3.5.8; Electron and Node versions remained unchanged. Record this environment change with the next VM evidence.
+
+- 2026-09-12: Combined Electron IPC fixture initially lacked socket getopt permission for SO_PEERSEC label inspection. Added metadata access while retaining the protected read/write denial; subsequent actual-main/Node-utility IPC cells passed with one sender creating all four pairs.
+
+- 2026-09-12: Denying the native IPC sender access to its own executable caused startup failure after transition. Keep its required execute/map permission while denying outsider entry; final run 4 passed all cells and cleanup.
+
+- 2026-09-12: electron-builder mutates Linux payloads after afterPack: targets add update configuration, package-type and AppArmor files, and package installation can change chrome-sandbox mode. An inventory generated in afterPack would be stale. Inventory must cover the finalized extracted payload rather than exclude these files.
+
+- 2026-09-12: Fedora SSH became unavailable between phases; UTM reported the dedicated VM stopped. Restarted the existing VM before new acceptance and will recheck enforcing/session prerequisites. Earlier receipts remain scoped to their recorded runs.
+
+- 2026-09-12: UTM restart via its saved shortcut stalled before QEMU launch and timed out. Reopened the existing VM bundle with the verified mounted UTM app through Finder, then started it normally. SSH returned; SELinux is enforcing and GNOME session 3 resumed.
+
+- 2026-09-12: Broad runtime import searches matched generated browser scripts with extremely long lines. Subsequent source inspection excludes generated/injected files and caps line width to keep evidence readable.
+
+- 2026-09-12: Native staging cleanup initially combined O_PATH with the shared O_NONBLOCK flag. Unlike older open calls, openat2 rejects that combination; kernel 6.19 source confirms EINVAL. Separate metadata-open flags and cover failure cleanup before acceptance.
+
+- 2026-09-12: The real RPM contains a directory named `shared` that Fedora labels `container_ro_file_t` through a filename transition even beneath a `var_lib_t` staging store. Synthetic trees missed this. The stager must set and restore an exact creation context around fresh destination objects instead of accepting arbitrary transitioned labels or globally relabeling the host.
+
+- 2026-09-12: Native managed-payload tests link libselinux and are registered in Linux contracts. Ubuntu CI/release therefore need `libselinux1-dev`; the Fedora contract container needs `libselinux-devel` plus its Rust toolchain explicitly rather than relying on runner state.
+
+- 2026-09-12: Both phase-17 reviewers found that temporary-generation `mkdirat` happened just before a fallible descriptor reopen and before cleanup was armed. A reopen failure could leave a private `.staging-*` directory. Make creation transactional and inject the post-mkdir/pre-open failure in the root suite.
+
+- 2026-09-12: Fedora native staging tests required cargo/rust/rust-std-static 1.98.1, acl 2.4.0 (libacl upgraded from 2.3.2), libgit2 1.9.7, libssh2 1.11.1 and llhttp 9.3.1. Transaction retained in /tmp/aiden-phase17-dnf.log; SELinux remains enforcing.
+- 2026-09-12: The disposable Fedora UTM bundle lost its application association after suspension, so opening it stalled until Finder explicitly selected the verified mounted UTM 4.7.5 app and resumed the suspended VM. Confirm SSH and `getenforce` before rerunning root acceptance.
+
+- 2026-09-12: Opening an untrusted source pathname for reading before fstat could touch a raced device. The stager now classifies through O_PATH first, then reopens only its own pinned regular-file descriptor through fixed procfs, with identity checks. Strict caller-path resolution remains separate from that intentional self-descriptor operation.
+- 2026-09-12: The existing release workflow already builds Linux packages on both native architectures, but the latest public release predates that work and contains only arm64 macOS assets. Keep the standalone installer-build workflow non-publishing and make Intel Mac selection fail explicitly until a signed x64 artifact has passed release acceptance.
+- 2026-09-12: An absolute `sysctl` path made Rosetta selection hard to exercise with the installer's command-level fixture. Keep standard discovery commands on the controlled `PATH`; reserve absolute paths for macOS verification and installation tools whose identity matters.
+- 2026-09-12: Initial cross-platform installer tests covered selection but exposed two trust gaps on inspection: `/etc/os-release` matching omitted the `=` delimiter, and macOS download-only returned before Apple verification. Cover the real token shape and authenticate platform artifacts before every success exit.
+- 2026-09-12: The standalone installer test passed under Node while scoped ESLint still rejected implicit Node globals. Import `process` and `Buffer` explicitly so the registered release suite and lint use the same module contract.
+- 2026-09-12: OrbStack's `orb run` parser rejects a standalone `--` separator. Use `orb run -p -w ABSOLUTE_PATH sh ./install.sh ...`; the real Ubuntu plan then selected the x64 DEB filename.
+- 2026-09-12: Real signed-DMG download-only acceptance passed but macOS 27 deprecated `hdiutil attach`. Use `diskutil image attach --readOnly --nobrowse --mountPoint` for attachment while retaining bounded detach cleanup.
+- 2026-09-12: Both installer reviewers found the root-owned Mac staging directory could not be inspected by the caller, its predictable backup path could collide, fresh-install verification lacked rollback, and the AppImage used a predictable followed staging path. Use one exclusive transaction directory per install, expose the root-owned Mac directory as traverse-only for verification, track promotion independently of backup existence, and reject non-regular destinations.
+- 2026-09-12: Linux AppImage promotion uses GNU `mv -T` and `ln -T` to make a raced directory fail instead of changing destination semantics. The macOS-hosted Linux fixture must emulate those Linux flags rather than silently weakening the production command.
+- 2026-09-12: Shell cleanup traps can run between an external move and the next assignment. Arm rollback state before every filesystem mutation, and retain the private transaction directory if restoring the prior app fails.
+- 2026-09-12: A missing-tool fixture that retains `/usr/bin` is host-dependent because Ubuntu Actions preinstalls `gh`. Build a closed fixture PATH from explicit tool symlinks so the absence assertion means the same thing on macOS and Linux.
+- 2026-09-12: Closing a fixture PATH also hides the shell executable from Node's process launcher. Invoke the known `/bin/sh` directly while keeping commands inside the test process constrained to the fixture tools.
+- 2026-09-12: Green Pullfrog status can coexist with newly posted or older unresolved review threads. Query GraphQL `reviewThreads` at the exact head and triage every unresolved comment before closure.
+- 2026-09-12: A minimal native-helper transfer to the Fedora fixture must include `native/shared/aiden-platform.h`; the source-relative include is not supplied by the JavaScript build wrapper.
+- 2026-09-12: The first SELinux metadata regression changed only UID, so a GID mismatch produced the expected conflict before label copying. Matching both identities showed Fedora permits `bin_t` preservation; use a denied file capability to exercise the fail-closed copy path.
diff --git a/README.md b/README.md
index a1b2c95c..9abb73a1 100644
--- a/README.md
+++ b/README.md
@@ -5,18 +5,31 @@
- A native-feeling macOS workspace for chatting with local or hosted AI models and safely working inside the folders you choose.
+ A native-feeling desktop workspace for chatting with local or hosted AI models and safely working inside the folders you choose.
```sh
-brew install --cask sambitcreate/tap/aiden-agent
+curl -fsSL https://raw.githubusercontent.com/sambitcreate/aiden-agent/main/install.sh | sh
```
+The installer selects the native package for macOS or a supported Linux family,
+verifies the published checksum, authenticates Linux build provenance with the
+GitHub CLI, and verifies Apple signing plus Gatekeeper acceptance on macOS.
+Homebrew remains available with `brew install --cask sambitcreate/tap/aiden-agent`.
+Intel Mac installation will become available when a signed x64 DMG is published;
+the installer currently fails rather than selecting the incompatible arm64 DMG.
+
+The release pipeline now produces Linux x64 and arm64 AppImage, `.deb`, and
+`.rpm` packages. The current public `v0.40.0` release predates that pipeline and
+contains only the arm64 Mac build; Linux packages and the installer become
+available with the next accepted release. See the
+[Linux install and compatibility guide](docs/linux.md).
+

## Why Aiden
-I don't come from a coding background. I'd been bouncing between the coding agents that exist, and each one had a piece of what I wanted without any of them being the whole thing. I loved **Codex** for its restrained, lovely desktop UX, **Opencode** for letting me bring whatever model and provider I wanted and also looking great in both the terminal and desktop, and **Cursor** for its nimbleness and UI, and I used **Claude Code** for the models lol. But the one terminal agent kept coming back to was **Pi**, by **Mario Zechner**, for the plugin system I could shape to my own workflow. What **Pi** lacked was a GUI, and I wanted the extensibility with a real interface on top of it. The first version was a native **SwiftUI** app, but within two weeks it was clear that building a coding agent inside **SwiftUI** was the wrong fight for someone who doesn't already write code, so the project pivoted to **Electron**. I was playing around with **Glaze**, **Raycast**'s AI app maker. I figured, let me just recreate Aiden in Glaze, ran out of credits inside an hour, used **Codex** to grab the code out of Glaze, and a week later this is what happened. **Aiden** runs on the **Pi** agent runtime and gives it a Mac-native workspace.
+I don't come from a coding background. I'd been bouncing between the coding agents that exist, and each one had a piece of what I wanted without any of them being the whole thing. I loved **Codex** for its restrained, lovely desktop UX, **Opencode** for letting me bring whatever model and provider I wanted and also looking great in both the terminal and desktop, and **Cursor** for its nimbleness and UI, and I used **Claude Code** for the models lol. But the one terminal agent kept coming back to was **Pi**, by **Mario Zechner**, for the plugin system I could shape to my own workflow. What **Pi** lacked was a GUI, and I wanted the extensibility with a real interface on top of it. The first version was a native **SwiftUI** app, but within two weeks it was clear that building a coding agent inside **SwiftUI** was the wrong fight for someone who doesn't already write code, so the project pivoted to **Electron**. I was playing around with **Glaze**, **Raycast**'s AI app maker. I figured, let me just recreate Aiden in Glaze, ran out of credits inside an hour, used **Codex** to grab the code out of Glaze, and a week later this is what happened. **Aiden** runs on the **Pi** agent runtime and gives it a native-feeling desktop workspace.
## Features
@@ -24,11 +37,13 @@ I don't come from a coding background. I'd been bouncing between the coding agen
- **Command palette and shortcuts** - `⌘K` searches commands, chats, models, providers, Settings, and appearance actions. One typed command system also powers native menus, visible shortcut labels, transactional global hotkeys, and the searchable Keyboard Shortcuts editor.
- **Commands and explicit skills** - type `/` at the start of the composer to search Aiden app commands, or `$` to search the active workspace's available skills. Commands reuse canonical app workflows; an explicitly selected skill is revalidated for the active workspace, applies to one accepted message, and persists only safe display provenance.
- **Native Subagents** - a foreground chat can delegate up to four fresh `scout`, `planner`, or `reviewer` tasks. Children are read/search-only, inherit the approved workspace and model, stop with the parent, and appear as live chips plus an inspectable **Subagents** view in Environment.
+- **Bots and Web Search** - macOS can create durable Bots with explicit capability grants, persistent conversations, image understanding, and Telegram control. Every desktop can configure explicit Web Search routes from the expanded provider catalog; Bots remain hidden on Linux until their native security bindings are supported there.
- **Workspaces and managed worktrees** - use folders, scratch workspaces, or isolated managed worktrees with three access levels, workspace-scoped tools, Ask-mode approvals, guarded creation/deletion, and crash-aware cleanup.
- **Models and the Model Pad** - choose from Pi's native hosted-provider catalog, local Ollama or LM Studio models, and declarative compatible endpoints. Arrange a personal capability-and-pace map, optionally enrich hosted models with explicitly fetched Artificial Analysis scores through a benchmark-only OpenRouter key, and keep benchmark evidence visibly separate from runtime limits and availability.
- **Terminal, Git, and review** - keep a terminal drawer beside the conversation, inspect files and diffs in Environment, edit with dirty-file protection, compare branches, commit or push checked snapshots, and open the workspace in a discovered external editor.
+- **Rich responses and local diagnostics** - render sandboxed HTML, chart, math, and raster artifacts with recovery/export controls, and inspect or export bounded local diagnostics without an automatic upload path.
+- **Desktop integration and appearance** - native menus, encrypted system credential storage, **Parakeet**, the dictation pill, semantic themes, high contrast, reduced motion, and consistent light/dark rendering. Apple **Foundation Models**, Accessibility auto-paste, and the signed **Rust** Computer Use broker remain macOS-only.
- **Shared browser and annotations** - browse beside the chat in Environment, preview workspace HTML/PDF files, use isolated profiles and responsive viewports, and share selected text, elements, drawings, and image crops with Aiden. Aiden's browser tools operate on those same tabs. See [browser behavior and controls](docs/environment-browser.md).
-- **macOS integration and appearance** - native menus, **Keychain**, **Parakeet**, the dictation pill, Apple **Foundation Models**, the signed **Rust** Computer Use broker, semantic themes, high contrast, reduced motion, and consistent light/dark rendering.
- **Extensibility and background work** - use skills, **MCP**, **Exa** search, scheduled tasks, voice, and attachments through typed, allowlisted boundaries.
- **Aiden On The Go** - opt in to a pinned local-network connection or an explicit non-Funnel Tailscale Serve route, pair each iPhone or iPad separately, and revoke devices from [Remote Access settings](docs/aiden-on-the-go-remote-access.md).
- **Updates and release safety** - signed builds use the verified GitHub release feed. Once an update is downloaded, Aiden shows the version above Profile with **Later** and **Restart now**, then follows the normal save and shutdown guards before relaunching.
@@ -46,11 +61,11 @@ The roadmap is maintained in [the plan index](docs/plans/README.md). These bulle
## Privacy and trust
-Aiden stores chats, settings, workspace metadata, and downloaded speech models locally. Provider credentials and MCP OAuth sessions are encrypted with macOS secure storage. The renderer is sandboxed, has no direct Node.js access, and communicates with Electron through an allowlisted bridge.
+Aiden stores chats, settings, workspace metadata, and downloaded speech models locally. Provider credentials and MCP OAuth sessions use the operating system's encrypted credential storage. Linux refuses to store secrets if only Electron's reversible `basic_text` backend is available. The renderer is sandboxed, has no direct Node.js access, and communicates with Electron through an allowlisted bridge.
Network access happens only when the selected feature needs it: hosted models receive the conversation content sent to them, cloud transcription receives selected audio, Exa receives search queries, remote MCP servers receive tool requests, and model downloads contact their upstream host. A fully local session can use a local model, on-device voice, no remote MCP servers, and web search disabled.
-Computer Use is an opt-in beta with a global switch, a separate per-chat switch, macOS permission checks, exact target binding, and one-use approval for every mutation. See the [Computer Use security design](docs/computer-use-integration.md) for the complete boundary.
+On macOS, Computer Use is an opt-in beta with a global switch, a separate per-chat switch, permission checks, exact target binding, and one-use approval for every mutation. It is omitted from Linux builds. See the [Computer Use security design](docs/computer-use-integration.md) for the complete boundary.
## Architecture
@@ -66,7 +81,7 @@ Electron main process
├── workspace-scoped tools, Git, review, and terminal
├── encrypted credentials and local JSON stores
├── MCP, attachments, search, and voice
- └── signed native helpers for Apple models and Computer Use
+ └── hardened native helpers, plus macOS-only Apple model and Computer Use helpers
```
Core technologies include Electron 43, React 19, TypeScript, Vite, Tailwind CSS, TanStack Router and Query, Radix UI, the Pi agent runtime, Swift, and Rust.
@@ -75,11 +90,11 @@ Core technologies include Electron 43, React 19, TypeScript, Vite, Tailwind CSS,
### Requirements
-- macOS
+- macOS or a glibc 2.34+ x64/arm64 Linux desktop
- Node.js 22.19 or newer and npm
-- Rust and Cargo
-- A full Xcode 26 or newer for the Apple Foundation Models helper
-- An Apple Development or Developer ID Application identity for packaged builds
+- Rust and Cargo only for macOS Computer Use helper tests
+- A full Xcode 26 or newer only for macOS Apple Foundation Models builds
+- An Apple signing identity only for packaged macOS distribution builds
### Run locally
@@ -94,7 +109,7 @@ The native Aiden On The Go iPhone and iPad client lives in [`ios/`](ios/README.m
The iPhone and iPad app is distributed through **TestFlight only**. GitHub releases do not publish an IPA; [`ios/README.md`](ios/README.md) documents local development and device validation. Android validation remains separate from the macOS release. Pull requests run the Android verification gates without retaining an installable artifact; relevant merges to `main` publish the debug APK and its checksum.
-The development launcher prepares a cached, ad-hoc-signed **Aiden Agent Dev** runtime that can run beside the installed **Aiden Agent** app. Development uses separate Application Support, Chromium session, log, crash, and `~/.aiden-dev` roots; it does not copy production data, register global shortcuts, or check the production update feed by default. Set `AIDEN_DEV_GLOBAL_SHORTCUTS=1` only when a development run intentionally needs the global bindings.
+The development launcher prepares a platform-appropriate **Aiden Agent Dev** runtime that can run beside the installed **Aiden Agent** app. Development uses separate app-data, Chromium session, log, crash, and `~/.aiden-dev` roots; it does not copy production data, register global shortcuts, or check the production update feed by default. Set `AIDEN_DEV_GLOBAL_SHORTCUTS=1` only when a development run intentionally needs the global bindings.
Native builds discover the newest compatible full Xcode without changing the machine-wide `xcode-select` setting; `DEVELOPER_DIR` remains available as a per-command override.
@@ -115,13 +130,23 @@ npm run package
npm run package:verify
```
-Distribution builds use `npm run dist` and require Developer ID signing plus notarization. The release pipeline fails closed, verifies the app, DMG, and ZIP, checks the deployed Homebrew and website consumers, and publishes updater metadata only with the matching verified artifacts. Read [macOS releases and automatic updates](docs/releasing.md) before enabling publication.
+On Linux, use `npm run package:linux` and `npm run package:linux:verify` for an
+unpacked development package, or `npm run dist:linux` for AppImage, Debian, and
+RPM artifacts. Linux packages are built on their target architecture.
+
+Distribution builds use `npm run dist`; macOS artifacts require Developer ID signing plus notarization. The release pipeline fails closed, verifies the platform artifacts, checks the deployed Homebrew and website consumers, and publishes updater metadata only with the matching verified artifacts. Read [macOS releases and automatic updates](docs/releasing.md) before enabling publication.
-The checked-in models.dev snapshot is refreshed only through `npm run models:refresh` or the guarded distribution path. Artificial Analysis credentials and data are never bundled. Direct Artificial Analysis suggestions require an explicit user fetch. OpenRouter benchmark insights use a separate encrypted Model Pad key only after an explicit Connect & fetch or Fetch latest action, then read normalized public scores from a device-local offline cache. That key never configures an inference provider or imports OpenRouter's model catalog.
+The checked-in models.dev snapshot is refreshed only through `npm run models:refresh` or the guarded distribution path; Linux and macOS packaging invoke that explicit release step. Artificial Analysis credentials and data are never bundled. OpenRouter benchmark insights use a separate encrypted Model Pad key only after an explicit Connect & fetch or Fetch latest action, then read normalized public scores from a device-local offline cache. That key never configures an inference provider or imports OpenRouter's model catalog.
## Project status
-Aiden Agent is a beta macOS release. Signed DMG and ZIP builds, checksums, and automatic-update metadata are published through [GitHub Releases](https://github.com/sambitcreate/aiden-agent/releases). The release workflow is fail-closed: it verifies signing, notarization, package contents, updater metadata, and version monotonicity before publishing. See [the release guide](docs/releasing.md) for the complete process.
+Aiden Agent is a beta desktop release for macOS, with the reviewed Linux release
+pipeline prepared for the next release. Signed macOS DMG/ZIP builds and verified
+Linux AppImage/DEB/RPM builds for x64 and arm64 are published together with
+checksums once that release is accepted. A separate manual workflow builds
+non-release Linux installers for testing. macOS keeps automatic updates; Linux
+uses explicit package replacement. See [the macOS release guide](docs/releasing.md)
+and [Linux guide](docs/linux.md).
The canonical website download is the stable
[`Aiden-Agent-Beta-arm64.dmg`](https://github.com/sambitcreate/aiden-agent/releases/latest/download/Aiden-Agent-Beta-arm64.dmg)
diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/config/AidenVoiceInput.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/config/AidenVoiceInput.kt
index 231afd2d..0568a854 100644
--- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/config/AidenVoiceInput.kt
+++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/config/AidenVoiceInput.kt
@@ -7,7 +7,7 @@ import kotlinx.coroutines.flow.asStateFlow
enum class AidenVoiceInputMode(val wireValue: String, val title: String) {
ON_DEVICE("on-device", "On this device"),
- PAIRED_MAC("paired-mac", "Paired Mac");
+ PAIRED_MAC("paired-mac", "Paired desktop");
companion object {
fun fromWireValue(value: String?): AidenVoiceInputMode =
diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotCustomAccessFlowScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotCustomAccessFlowScreen.kt
index c294c481..96082fbe 100644
--- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotCustomAccessFlowScreen.kt
+++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotCustomAccessFlowScreen.kt
@@ -184,7 +184,7 @@ fun AidenBotCustomAccessFlowScreen(
val freshCat = cl.botCapabilityCatalog(detail.id)
selectedBotDetail = fresh
catalog = freshCat
- saveError = "Access policy was changed on your Mac. Review the latest policy and try again."
+ saveError = "Access policy was changed on your paired desktop. Review the latest policy and try again."
} catch (_: Exception) {
saveError = e.message ?: "Conflict updating access"
}
@@ -305,7 +305,7 @@ fun AidenBotCustomAccessFlowScreen(
// File Scopes Section
item {
- Text("Mac Files", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = palette.secondary)
+ Text("Desktop Files", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = palette.secondary)
Spacer(modifier = Modifier.height(6.dp))
Card(
modifier = Modifier.fillMaxWidth(),
@@ -370,7 +370,7 @@ fun AidenBotCustomAccessFlowScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text("Execute Shell Commands", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, color = palette.foreground)
- Text("Allows bot to run terminal commands on Mac", style = MaterialTheme.typography.bodySmall, color = palette.secondary)
+ Text("Allows bot to run terminal commands on the paired desktop", style = MaterialTheme.typography.bodySmall, color = palette.secondary)
}
Switch(
checked = curDraft.shellEnabled,
diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotEditorScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotEditorScreen.kt
index e9cfb74d..2164cc2e 100644
--- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotEditorScreen.kt
+++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotEditorScreen.kt
@@ -703,7 +703,7 @@ fun AidenBotEditorScreen(
Divider(color = palette.canvas)
// File scopes
- Text("Mac File Scopes", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = palette.foreground)
+ Text("Desktop File Scopes", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = palette.foreground)
currentCat.fileScopes.forEach { scopeItem ->
Row(
verticalAlignment = Alignment.CenterVertically,
diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotGeneratedAvatarLifecycle.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotGeneratedAvatarLifecycle.kt
index 84736a9d..c3defe98 100644
--- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotGeneratedAvatarLifecycle.kt
+++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotGeneratedAvatarLifecycle.kt
@@ -38,7 +38,7 @@ sealed class AidenBotGeneratedAvatarError(val messageText: String) : Exception(m
object SourceTooLarge : AidenBotGeneratedAvatarError("That image is too large. Choose another image.")
object UnsupportedImage : AidenBotGeneratedAvatarError("That image format can't be used for a Bot photo.")
object InvalidImage : AidenBotGeneratedAvatarError("Aiden couldn't prepare that image. Choose another image.")
- object Unavailable : AidenBotGeneratedAvatarError("Reconnect to your Mac before saving this Bot photo.")
+ object Unavailable : AidenBotGeneratedAvatarError("Reconnect to your paired desktop before saving this Bot photo.")
}
enum class AidenBotGeneratedAvatarPhase {
diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotImagePlaygroundView.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotImagePlaygroundView.kt
index 9e70b179..4f85d898 100644
--- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotImagePlaygroundView.kt
+++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotImagePlaygroundView.kt
@@ -232,7 +232,7 @@ fun AidenBotImagePlaygroundSheet(
}
Spacer(modifier = Modifier.height(8.dp))
Text(
- text = "Bot photos generated on macOS can be synchronized to Android. You can also customize your Bot with the built-in Semantic Avatar studio.",
+ text = "Bot photos generated on a supported Apple device can be synchronized to Android. You can also customize your Bot with the built-in Semantic Avatar studio.",
style = MaterialTheme.typography.bodySmall,
color = palette.secondary
)
diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotsHomeScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotsHomeScreen.kt
index 4102b800..dacfca8a 100644
--- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotsHomeScreen.kt
+++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotsHomeScreen.kt
@@ -187,7 +187,7 @@ fun aidenBotInboxActivityStatus(
if (canRespondToApproval) {
AidenBotInboxActivityStatus("Approval needed", "verified_user")
} else {
- AidenBotInboxActivityStatus("Waiting for approval on Mac", "computer")
+ AidenBotInboxActivityStatus("Waiting for desktop approval", "computer")
}
}
AidenBotConversationActivityState.RECONCILING -> AidenBotInboxActivityStatus("Updating", "sync")
@@ -435,7 +435,7 @@ fun AidenBotsHomeScreen(
AidenEmptyState(
icon = Icons.Default.WifiOff,
title = "Bots couldn’t load",
- body = errorMessage ?: "Reconnect to your Mac and try again.",
+ body = errorMessage ?: "Reconnect to your paired desktop and try again.",
modifier = Modifier.padding(top = 36.dp),
action = {
Button(
@@ -456,7 +456,7 @@ fun AidenBotsHomeScreen(
body = if (connectionState == AidenConnectionState.CONNECTED)
"Create a familiar helper with one persistent conversation and its own capabilities."
else
- "Reconnect to your Mac to load Bots.",
+ "Reconnect to your paired desktop to load Bots.",
modifier = Modifier.padding(top = 36.dp),
action = if (connectionState == AidenConnectionState.CONNECTED) {
{
diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/AidenChatDetailScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/AidenChatDetailScreen.kt
index 896a8d8d..79d2570a 100644
--- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/AidenChatDetailScreen.kt
+++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/AidenChatDetailScreen.kt
@@ -308,7 +308,7 @@ fun AidenChatDetailScreen(
) {
pendingApproval?.let { approval ->
val isAutomation = AidenApprovalPresentation.isAutomation(approval.toolName)
- val requiresMacConfirmation = AidenApprovalPresentation.requiresMacConfirmation(approval)
+ val requiresDesktopConfirmation = AidenApprovalPresentation.requiresDesktopConfirmation(approval)
Card(
modifier = Modifier
.fillMaxWidth()
@@ -353,10 +353,10 @@ fun AidenChatDetailScreen(
style = MaterialTheme.typography.bodySmall,
color = palette.secondary
)
- } else if (requiresMacConfirmation) {
+ } else if (requiresDesktopConfirmation) {
Spacer(modifier = Modifier.height(8.dp))
Text(
- text = "Review the full unattended access scope and confirm in Aiden on your Mac. You can deny it here.",
+ text = "Review the full unattended access scope and confirm in Aiden on your paired desktop. You can deny it here.",
style = MaterialTheme.typography.bodySmall,
color = palette.secondary
)
diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/AidenChatViewModel.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/AidenChatViewModel.kt
index cb0e626d..6fa62aa8 100644
--- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/AidenChatViewModel.kt
+++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/AidenChatViewModel.kt
@@ -689,10 +689,10 @@ class AidenChatViewModel(
if (!capabilities.canWriteSchedules) {
"Schedule write access is required to approve this task."
} else {
- "Confirm this automation in Aiden on your Mac after reviewing its full access scope."
+ "Confirm this automation in Aiden on your paired desktop after reviewing its full access scope."
}
} else {
- "This action must be confirmed in Aiden on your Mac."
+ "This action must be confirmed in the Aiden desktop app."
}
return
}
diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/ComposerVoiceInputController.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/ComposerVoiceInputController.kt
index b7de26fb..cb529c72 100644
--- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/ComposerVoiceInputController.kt
+++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/ComposerVoiceInputController.kt
@@ -198,7 +198,7 @@ class ComposerVoiceInputController(private val context: Context) {
private fun startMac(client: AidenRemoteClient?, session: Long) {
if (client == null) {
- fail("Connect to your paired Mac before using Mac transcription.", session, AidenDiagnosticCode.NETWORK)
+ fail("Connect to your paired desktop before using desktop transcription.", session, AidenDiagnosticCode.NETWORK)
return
}
state = ComposerVoiceInputState.PREPARING
@@ -208,18 +208,18 @@ class ComposerVoiceInputController(private val context: Context) {
val status = client.speechStatus()
ensureActive()
if (!isCurrent(session)) return@launch
- if (!status.engine.ready) throw IllegalStateException(status.engine.error ?: "The Mac speech engine is unavailable.")
+ if (!status.engine.ready) throw IllegalStateException(status.engine.error ?: "The desktop speech engine is unavailable.")
val selected = status.models.firstOrNull { it.id == status.selectedModelId && it.installed }
?: status.models.firstOrNull { it.installed && it.recommended }
?: status.models.firstOrNull { it.installed }
- ?: throw IllegalStateException("Download a Mac speech model in Settings before using this option.")
+ ?: throw IllegalStateException("Download a desktop speech model in Settings before using this option.")
if (status.selectedModelId != selected.id) client.selectSpeechModel(selected.id)
ensureActive()
if (!isCurrent(session)) return@launch
activeModelId = selected.id
beginMacRecording(session)
} catch (error: Exception) {
- if (isCurrent(session)) fail(error.message ?: "Mac transcription is unavailable.", session, AidenDiagnosticCode.NETWORK)
+ if (isCurrent(session)) fail(error.message ?: "Desktop transcription is unavailable.", session, AidenDiagnosticCode.NETWORK)
} finally {
if (isCurrent(session)) preparationJob = null
}
@@ -294,7 +294,7 @@ class ComposerVoiceInputController(private val context: Context) {
val client = activeClient
val modelId = activeModelId
if (client == null || modelId == null) {
- fail("Mac transcription stopped because the connection changed.", session, AidenDiagnosticCode.NETWORK)
+ fail("Desktop transcription stopped because the connection changed.", session, AidenDiagnosticCode.NETWORK)
return
}
state = ComposerVoiceInputState.TRANSCRIBING
@@ -307,7 +307,7 @@ class ComposerVoiceInputController(private val context: Context) {
updateTranscript(result.text, session)
clearSession(session)
} catch (error: Exception) {
- if (isCurrent(session)) fail(error.message ?: "The Mac could not transcribe this recording.", session, AidenDiagnosticCode.NETWORK)
+ if (isCurrent(session)) fail(error.message ?: "The paired desktop could not transcribe this recording.", session, AidenDiagnosticCode.NETWORK)
} finally {
if (isCurrent(session)) transcriptionJob = null
}
diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenBotChatToolsView.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenBotChatToolsView.kt
index 14f786b1..60c966bb 100644
--- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenBotChatToolsView.kt
+++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenBotChatToolsView.kt
@@ -201,7 +201,7 @@ class AidenBotChatToolsModel(
fun readOnlyMessage(connected: Boolean, canWriteBots: Boolean, hostAllowsMutations: Boolean): String? {
if (bot?.health == AidenBotHealth.ARCHIVED) return "Archived bots are read-only until restored."
if (bot?.health == AidenBotHealth.DEGRADED || bot?.health == AidenBotHealth.UNAVAILABLE) {
- return "This bot's access needs repair on your Mac before it can work."
+ return "This bot's access needs repair on your paired desktop before it can work."
}
if (!connected) return "Offline — reconnect to change this chat's access."
if (!canWriteBots) return "This phone can view Bot access but is not approved to change it."
diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenPairingScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenPairingScreen.kt
index bbf03897..2b86ae82 100644
--- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenPairingScreen.kt
+++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenPairingScreen.kt
@@ -82,7 +82,7 @@ fun AidenPairingScreen(
Scaffold(
topBar = {
TopAppBar(
- title = { Text("Paired Macs", fontWeight = FontWeight.Bold) },
+ title = { Text("Paired desktops", fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "Close", tint = palette.foreground)
@@ -186,7 +186,7 @@ fun AidenPairingScreen(
// Pair New Mac Section
Text(
- text = "Connect your Mac",
+ text = "Connect your desktop",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = palette.secondary
@@ -194,7 +194,7 @@ fun AidenPairingScreen(
Spacer(modifier = Modifier.height(8.dp))
Text(
- text = "On your Mac, open Settings → Aiden On The Go → Connect a device. Then scan its code here.",
+ text = "On your desktop, open Settings → Aiden On The Go → Connect a device. Then scan its code here.",
style = MaterialTheme.typography.bodyMedium,
color = palette.secondary
)
@@ -304,7 +304,7 @@ fun AidenPairingScreen(
) {
CircularProgressIndicator(color = palette.accent, modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(8.dp))
- Text("Pairing with Mac...", style = MaterialTheme.typography.bodyMedium, color = palette.foreground)
+ Text("Pairing with desktop...", style = MaterialTheme.typography.bodyMedium, color = palette.foreground)
}
}
}
@@ -330,7 +330,7 @@ fun AidenPairingScreen(
colors = sbtbiswas.AidenOnTheGo.ui.theme.aidenTextFieldColors(),
value = endpointUrl,
onValueChange = { endpointUrl = it },
- label = { Text("Mac address") },
+ label = { Text("Desktop address") },
singleLine = true,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth()
@@ -422,7 +422,7 @@ fun AidenPairingScreen(
onDismissRequest = { installationPendingRemoval = null },
title = { Text("Remove ${installation.name}?") },
text = {
- Text("This removes the pairing credential and all cached chats, Bots, usage, drafts, and workspace data for this Mac from this device.")
+ Text("This removes the pairing credential and all cached chats, Bots, usage, drafts, and workspace data for this desktop from this device.")
},
confirmButton = {
TextButton(
diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenQRCodeScanner.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenQRCodeScanner.kt
index 061ae1fc..e4c392ff 100644
--- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenQRCodeScanner.kt
+++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenQRCodeScanner.kt
@@ -89,7 +89,7 @@ fun AidenQRCodeScanner(
)
Spacer(modifier = Modifier.height(8.dp))
Text(
- text = "Point your camera at the QR code displayed in Aiden on your Mac to pair instantly.",
+ text = "Point your camera at the QR code displayed in Aiden on your desktop to pair instantly.",
style = MaterialTheme.typography.bodySmall,
color = palette.secondary,
textAlign = TextAlign.Center
diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/scheduled/AidenScheduledTasksScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/scheduled/AidenScheduledTasksScreen.kt
index 4f3472ae..8e814f95 100644
--- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/scheduled/AidenScheduledTasksScreen.kt
+++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/scheduled/AidenScheduledTasksScreen.kt
@@ -290,7 +290,7 @@ fun AidenScheduledTasksScreen(
refresh()
loadRuns(selectedTask.id)
} catch (_: CancellationException) {
- // Cancellation does not prove whether the Mac accepted the run.
+ // Cancellation does not prove whether the paired desktop accepted the run.
} catch (error: Exception) {
pendingRunKeys.failed(selectedTask.id, error)
if (isCurrentRequest(activeClient, AidenRemoteCapability.SCHEDULE_WRITE)) {
@@ -408,7 +408,7 @@ private fun AidenScheduledTaskList(
)
Spacer(Modifier.height(5.dp))
Text(
- "Aiden shows a permission review before saving unattended work. If a proposal can't be fully reviewed here, Aiden asks you to confirm it on your Mac.",
+ "Aiden shows a permission review before saving unattended work. If a proposal can't be fully reviewed here, Aiden asks you to confirm it on your paired desktop.",
style = MaterialTheme.typography.bodySmall,
color = palette.secondary
)
@@ -684,7 +684,7 @@ private fun AidenScheduledTaskDetail(
) { Text("Delete automation") }
if (!isConnected) {
- Text("Connect to your Mac to run or change this task.", style = MaterialTheme.typography.bodySmall, color = palette.secondary)
+ Text("Connect to your paired desktop to run or change this task.", style = MaterialTheme.typography.bodySmall, color = palette.secondary)
} else if (!canManage) {
Text("This paired device has read-only scheduled task access.", style = MaterialTheme.typography.bodySmall, color = palette.secondary)
}
diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/settings/AidenAppearanceSettingsScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/settings/AidenAppearanceSettingsScreen.kt
index 8f77611f..f9b18343 100644
--- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/settings/AidenAppearanceSettingsScreen.kt
+++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/settings/AidenAppearanceSettingsScreen.kt
@@ -56,14 +56,14 @@ fun AidenAppearanceSettingsScreen(
}
runCatching { client.speechStatus() }
.onSuccess { speechStatus = it; speechError = null }
- .onFailure { speechError = it.message ?: "Mac transcription is unavailable." }
+ .onFailure { speechError = it.message ?: "Desktop transcription is unavailable." }
}
fun runSpeechAction(action: suspend () -> AidenSpeechStatus) {
scope.launch {
runCatching { action() }
.onSuccess { speechStatus = it; speechError = null }
- .onFailure { speechError = it.message ?: "Mac transcription is unavailable." }
+ .onFailure { speechError = it.message ?: "Desktop transcription is unavailable." }
}
}
@@ -111,7 +111,7 @@ fun AidenAppearanceSettingsScreen(
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text("Installations", style = MaterialTheme.typography.titleMedium, color = palette.foreground)
- Text("Pair or switch your Aiden Agent Mac", style = MaterialTheme.typography.bodySmall, color = palette.secondary)
+ Text("Pair or switch your Aiden Agent desktop", style = MaterialTheme.typography.bodySmall, color = palette.secondary)
}
}
}
@@ -165,7 +165,7 @@ fun AidenAppearanceSettingsScreen(
)
Spacer(Modifier.height(8.dp))
Text(
- text = "Choose where speech is transcribed. Paired Mac sends microphone audio over Aiden's encrypted pinned connection and does not retain it.",
+ text = "Choose where speech is transcribed. Paired desktop sends microphone audio over Aiden's encrypted pinned connection and does not retain it.",
style = MaterialTheme.typography.bodySmall,
color = palette.secondary
)
@@ -181,7 +181,7 @@ fun AidenAppearanceSettingsScreen(
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
Text(mode.title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, color = palette.foreground)
Text(
- if (mode == AidenVoiceInputMode.ON_DEVICE) "Android SpeechRecognizer; speech stays on this device." else "Parakeet on your connected Aiden Agent Mac; final text appears after you stop.",
+ if (mode == AidenVoiceInputMode.ON_DEVICE) "Android SpeechRecognizer; speech stays on this device." else "Parakeet on your connected Aiden Agent desktop; final text appears after you stop.",
style = MaterialTheme.typography.bodySmall,
color = palette.secondary
)
@@ -217,7 +217,7 @@ fun AidenAppearanceSettingsScreen(
} else {
val status = speechStatus
if (remoteClient == null) {
- Text("Connect to a paired Mac to configure transcription.", style = MaterialTheme.typography.bodySmall, color = palette.warning)
+ Text("Connect to a paired desktop to configure transcription.", style = MaterialTheme.typography.bodySmall, color = palette.warning)
} else if (status == null && speechError == null) {
LinearProgressIndicator(Modifier.fillMaxWidth())
} else if (status != null) {
diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenUsageSheet.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenUsageSheet.kt
index 57791ad4..d7774951 100644
--- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenUsageSheet.kt
+++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenUsageSheet.kt
@@ -232,7 +232,7 @@ fun AidenUsageSheet(
Icon(Icons.Default.Shield, null, tint = palette.accent, modifier = Modifier.size(28.dp))
Spacer(Modifier.width(12.dp))
Text(
- "Privacy-safe aggregates are recorded by Aiden Agent on your Mac. Prompts, responses, chat IDs, workspace IDs, and file paths are not included.",
+ "Privacy-safe aggregates are recorded by Aiden Agent on your paired desktop. Prompts, responses, chat IDs, workspace IDs, and file paths are not included.",
style = MaterialTheme.typography.bodySmall,
color = palette.secondary
)
diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceEnvironmentScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceEnvironmentScreen.kt
index d2bc004a..2363f26e 100644
--- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceEnvironmentScreen.kt
+++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceEnvironmentScreen.kt
@@ -458,7 +458,7 @@ fun AidenWorkspaceEnvironmentScreen(
onDismissRequest = { showConflictDialog = false },
title = { Text("Conflict Detected", fontWeight = FontWeight.Bold) },
text = {
- Text("This file on your Mac was modified since you opened it. Would you like to reload the latest version from your Mac?")
+ Text("This file on your paired desktop was modified since you opened it. Would you like to reload the latest version from your desktop?")
},
confirmButton = {
Button(
@@ -478,7 +478,7 @@ fun AidenWorkspaceEnvironmentScreen(
},
colors = ButtonDefaults.buttonColors(containerColor = palette.accent)
) {
- Text("Reload from Mac", color = Color.White)
+ Text("Reload from desktop", color = Color.White)
}
},
dismissButton = {
diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceHomeScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceHomeScreen.kt
index 7ccbfb7d..c66e3ecd 100644
--- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceHomeScreen.kt
+++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceHomeScreen.kt
@@ -374,7 +374,7 @@ private fun AidenWorkspaceHome(
viewModel.load(force = true)
scope.launch {
snackbarHostState.showSnackbar(
- usageErrorMessage ?: "Loading Usage from your Mac…"
+ usageErrorMessage ?: "Loading Usage from your paired desktop…"
)
}
}
diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceShellScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceShellScreen.kt
index 7697e67a..a6ecfcb2 100644
--- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceShellScreen.kt
+++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceShellScreen.kt
@@ -440,7 +440,7 @@ fun AidenWorkspaceDirectoryScreen(
}
)
DropdownMenuItem(
- text = { Text("Add Mac Folder...") },
+ text = { Text("Add Desktop Folder...") },
leadingIcon = { Icon(Icons.Default.Folder, contentDescription = null) },
onClick = {
showCreateMenu = false
@@ -516,7 +516,7 @@ fun AidenWorkspaceDirectoryScreen(
icon = if (selectedTab == 0) Icons.Default.FolderOpen else Icons.Default.Archive,
title = if (selectedTab == 0) "No active workspaces" else "No archived workspaces",
body = if (selectedTab == 0)
- "Create a workspace or add an approved folder from your Mac."
+ "Create a workspace or add an approved folder from your desktop."
else
"Workspaces archived on this device will appear here."
)
@@ -751,7 +751,7 @@ fun AidenWorkspaceDirectoryScreen(
onDismissRequest = { showScratchConfirmDialog = false },
title = { Text("Create Managed Scratch?", fontWeight = FontWeight.Bold) },
text = {
- Text("Aiden will create an isolated scratch workspace in an ephemeral location on your Mac.")
+ Text("Aiden will create an isolated scratch workspace in an ephemeral location on your paired desktop.")
},
confirmButton = {
Button(
@@ -827,7 +827,7 @@ fun AidenWorkspaceDirectoryScreen(
onDismissRequest = { showArchiveDisclosureDialog = false },
title = { Text("Archive on this Device", fontWeight = FontWeight.Bold) },
text = {
- Text("Archiving a workspace hides it only on this device. Your Mac, files, and other devices remain completely unaffected.")
+ Text("Archiving a workspace hides it only on this device. Your paired desktop, files, and other devices remain completely unaffected.")
},
confirmButton = {
Button(
@@ -856,7 +856,7 @@ fun AidenWorkspaceDirectoryScreen(
onDismissRequest = { showRemoveDialog = false },
title = { Text("Remove Workspace?", fontWeight = FontWeight.Bold) },
text = {
- Text("Are you sure you want to remove \"${target.name}\" from Aiden? Local files on your Mac are preserved.")
+ Text("Are you sure you want to remove \"${target.name}\" from Aiden? Local files on your paired desktop are preserved.")
},
confirmButton = {
Button(
@@ -888,7 +888,7 @@ fun AidenWorkspaceDirectoryScreen(
onDismissRequest = { showDeleteWorktreeDialog = false },
title = { Text("Delete Managed Worktree?", fontWeight = FontWeight.Bold) },
text = {
- Text("This will permanently remove the managed worktree folder and git worktree on your Mac.")
+ Text("This will permanently remove the managed worktree folder and git worktree on your paired desktop.")
},
confirmButton = {
Button(
@@ -1014,7 +1014,7 @@ fun AidenFolderBrowserSheet(
modifier = Modifier.fillMaxWidth()
) {
Text(
- text = "Browse Mac Folders",
+ text = "Browse Desktop Folders",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = palette.foreground,
@@ -1359,8 +1359,8 @@ fun AidenWorkspaceSettingsSheet(
title = { Text(if (workspace.isManagedWorktree) "Delete Worktree?" else "Remove Workspace?", fontWeight = FontWeight.Bold) },
text = {
Text(
- if (workspace.isManagedWorktree) "This will permanently remove the managed worktree folder and git worktree on your Mac."
- else "Are you sure you want to remove \"${workspace.name}\" from Aiden? Local files on your Mac are preserved."
+ if (workspace.isManagedWorktree) "This will permanently remove the managed worktree folder and git worktree on your paired desktop."
+ else "Are you sure you want to remove \"${workspace.name}\" from Aiden? Local files on your paired desktop are preserved."
)
},
confirmButton = {
diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenChat.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenChat.kt
index fddf4a64..fdc592f5 100644
--- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenChat.kt
+++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenChat.kt
@@ -355,7 +355,7 @@ object AidenAgentActivityPresentation {
"web_search" to Pair("Searching the web", "Searched the web"),
"schedule_task" to Pair("Scheduling", "Scheduled"),
"edit_automation" to Pair("Editing automation", "Edited automation"),
- "computer_use" to Pair("Using Mac", "Used Mac"),
+ "computer_use" to Pair("Using Computer Use", "Used Computer Use"),
"browser" to Pair("Loading browser tools", "Loaded browser tools"),
"browser_status" to Pair("Checking browser", "Checked browser"),
"browser_open" to Pair("Opening browser", "Opened browser"),
@@ -472,7 +472,7 @@ object AidenAgentActivityPresentation {
if (changes > 0) clauses.add("${if (running) "editing" else "edited"} $changes file${if (changes == 1) "" else "s"}")
if (commands > 0) clauses.add("${if (running) "running" else "ran"} $commands command${if (commands == 1) "" else "s"}")
if (web > 0) clauses.add("$web web search${if (web == 1) "" else "es"}")
- if (mac > 0) clauses.add("$mac Mac action${if (mac == 1) "" else "s"}")
+ if (mac > 0) clauses.add("$mac Computer Use action${if (mac == 1) "" else "s"}")
if (compactions > 0) clauses.add(if (running) "compacting context" else "compacted context")
if (other > 0) clauses.add("$other tool call${if (other == 1) "" else "s"}")
if (clauses.isEmpty()) return if (running) "Working" else "Used ${tools.size} tool${if (tools.size == 1) "" else "s"}"
@@ -825,7 +825,7 @@ object AidenApprovalPresentation {
else -> "Approval Required"
}
- fun requiresMacConfirmation(approval: AidenPendingApproval): Boolean =
+ fun requiresDesktopConfirmation(approval: AidenPendingApproval): Boolean =
isAutomation(approval.toolName) && approval.canRespond &&
approval.hasRequiredWriteCapability && !approval.hostCanAllow
}
diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/persistence/AidenInstallationStore.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/persistence/AidenInstallationStore.kt
index 0f37c19c..ef516aed 100644
--- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/persistence/AidenInstallationStore.kt
+++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/persistence/AidenInstallationStore.kt
@@ -70,7 +70,7 @@ class AidenInstallationStore(
val installation = AidenInstallation(
instanceId = exchange.instanceId,
deviceId = exchange.deviceId,
- name = exchange.displayName ?: "Aiden Mac",
+ name = exchange.displayName ?: "Aiden desktop",
endpoint = exchange.endpoint,
serverSpkiSha256 = exchange.serverSpkiSha256,
pairingTrust = trust,
diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/protocol/AidenRemoteExceptions.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/protocol/AidenRemoteExceptions.kt
index 573c9694..546519a8 100644
--- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/protocol/AidenRemoteExceptions.kt
+++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/protocol/AidenRemoteExceptions.kt
@@ -20,7 +20,7 @@ sealed class AidenBotContractException(val reason: String, message: String = rea
class InvalidCombination(val combination: String) : AidenBotContractException(
combination,
when (combination) {
- "no available provider and model" -> "Set up a provider and model on your Mac. In Aiden Agent, open Settings → Providers, connect or refresh a provider, and make at least one chat model available. Then tap Try Again."
+ "no available provider and model" -> "Set up a provider and model on your paired desktop. In Aiden Agent, open Settings → Providers, connect or refresh a provider, and make at least one chat model available. Then tap Try Again."
"unavailable custom access" -> "One or more selected AI, Files, Connections, or Skills are no longer available. Review this Bot’s access choices and try again."
"chat access exceeds bot" -> "This chat is asking for more access than the Bot currently allows. Reduce the chat’s access or expand the Bot’s access, then try again."
"full access notice" -> "Review and accept the Full Access notice before giving this Bot full access."
@@ -30,7 +30,7 @@ sealed class AidenBotContractException(val reason: String, message: String = rea
}
sealed class AidenManualPairingException(message: String) : Exception(message) {
- object InvalidCode : AidenManualPairingException("Enter the 20-character setup code shown on your Mac.")
+ object InvalidCode : AidenManualPairingException("Enter the 20-character setup code shown on your desktop.")
object InvalidBootstrap : AidenManualPairingException("Aiden Agent returned an invalid manual pairing response.")
object DecryptionFailed : AidenManualPairingException("The setup code is incorrect or belongs to a different pairing window.")
object EndpointMismatch : AidenManualPairingException("The setup code belongs to a different Aiden Agent address.")
@@ -63,7 +63,7 @@ sealed class AidenSSEParserException(message: String) : Exception(message) {
sealed class AidenRemoteClientException(message: String, cause: Throwable? = null) : Exception(message, cause) {
object MissingCredential : AidenRemoteClientException("No credential available for this installation.")
object MissingTrustConfiguration : AidenRemoteClientException("This Aiden installation must be paired again to establish secure server trust.")
- object InstallationChanged : AidenRemoteClientException("The active Aiden Agent changed. Try again on the selected Mac.")
+ object InstallationChanged : AidenRemoteClientException("The active Aiden Agent changed. Try again on the selected desktop.")
object InvalidEndpoint : AidenRemoteClientException("The Aiden Agent address is invalid.")
class UnexpectedStatus(val statusCode: Int) : AidenRemoteClientException("Aiden Agent returned HTTP status $statusCode.")
data class Server(val statusCode: Int, val body: AidenRemoteErrorEnvelope.Body) : AidenRemoteClientException(body.message) {
diff --git a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenChatTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenChatTest.kt
index 80bb1d14..a07cebbf 100644
--- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenChatTest.kt
+++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenChatTest.kt
@@ -405,7 +405,7 @@ class AidenChatTest {
}
)
assertEquals(
- "1 web search, 1 Mac action, compacted context, 1 tool call",
+ "1 web search, 1 Computer Use action, compacted context, 1 tool call",
AidenAgentActivityPresentation.summary(multiTimeline)
)
@@ -583,8 +583,8 @@ class AidenChatTest {
assertTrue(AidenApprovalPresentation.isAutomation(approval.toolName))
assertEquals("Create this automation?", AidenApprovalPresentation.title(approval.toolName))
assertEquals("Create a daily report", AidenApprovalPresentation.oneLineSummary(approval.summary))
- assertTrue(AidenApprovalPresentation.requiresMacConfirmation(approval))
- assertFalse(AidenApprovalPresentation.requiresMacConfirmation(approval.copy(hostCanAllow = true, canAllow = true)))
+ assertTrue(AidenApprovalPresentation.requiresDesktopConfirmation(approval))
+ assertFalse(AidenApprovalPresentation.requiresDesktopConfirmation(approval.copy(hostCanAllow = true, canAllow = true)))
assertEquals("Approval Required", AidenApprovalPresentation.title("run_command"))
}
@@ -624,7 +624,7 @@ class AidenChatTest {
assertTrue(readOnlySchedule!!.canRespond)
assertFalse(readOnlySchedule.hasRequiredWriteCapability)
assertFalse(readOnlySchedule.canAllow)
- assertFalse(AidenApprovalPresentation.requiresMacConfirmation(readOnlySchedule))
+ assertFalse(AidenApprovalPresentation.requiresDesktopConfirmation(readOnlySchedule))
val cannotRespond = AidenPendingApprovalResolution.resolve(
valid,
diff --git a/docs/computer-use-integration.md b/docs/computer-use-integration.md
index d9209203..e10d8bed 100644
--- a/docs/computer-use-integration.md
+++ b/docs/computer-use-integration.md
@@ -238,3 +238,58 @@ Computer Use smoke is accepted until its identity-bound receipt exists.
documentation, platform support, installer scripts, and release workflow.
- Aiden: Pi agent/tool types, `llm-client.ts`, generic MCP manager, approval UI,
config/chat persistence, Electron lifecycle, preload allowlists, and packaging.
+
+## Linux managed payload preparation
+
+Linux Computer Use remains disabled while its authenticated launch boundary is
+implemented. `scripts/linux-payload-inventory.mjs` provides a reusable inventory
+check for a finalized, trusted, quiescent payload tree. It records every regular
+file and directory, including file hashes and modes, and rejects links and
+special files. The expected inventory is external to the payload: nothing in the
+payload is excluded from comparison.
+
+```sh
+node scripts/linux-payload-inventory.mjs compute /absolute/staged/payload /absolute/trusted/inventory.json
+node scripts/linux-payload-inventory.mjs verify /absolute/staged/payload /absolute/trusted/inventory.json
+```
+
+Compute an expected inventory only after authenticating the release and completing
+trusted extraction into isolated staging. A future privileged installer must protect
+both the staged tree and expected inventory from hostile writers. Do not regenerate
+an expected inventory from an installation whose integrity is in doubt: that would
+accept its modified files. A matching attacker-supplied inventory proves nothing
+about release identity. See [Linux package provenance](releasing.md#linux-package-provenance).
+
+The inventory utility itself is not a privileged installer, immutable storage
+mechanism, atomic filesystem security boundary, or live-process authenticator.
+It does not inventory host libraries outside the supplied tree or constrain JIT.
+Electron Builder adds files after afterPack, and package installation can alter
+sandbox permissions, so the final intended generation must be inventoried rather
+than assuming an earlier packaging-hook snapshot is complete.
+
+`native/linux-managed-payload` supplies the next local-only preparation step. A
+trusted operator can stage an already approved inventory into a private
+root-managed store on an SELinux-enforcing host:
+
+```sh
+sudo aiden-managed-payload stage \
+ --store /var/lib/aiden-managed-payload/store \
+ --source /absolute/trusted/extracted-payload \
+ --inventory /absolute/protected/inventory.json \
+ --approval /absolute/protected/local-staging-approval.json
+```
+
+The approval must use schema version 1 and kind `local-staging-only`, and bind
+the exact inventory-file SHA-256. The stager validates protected ancestry and
+metadata, copies into fresh root-owned inodes through descriptor-relative paths,
+sets the store's exact SELinux creation context, verifies every copied entry,
+restores the process creation context, and publishes the inventory-digest-named
+generation without replacement. Its receipt deliberately records
+`releaseAuthenticated`, `runtimeAdmission`, and `kernelImmutable` as false.
+
+This command neither authenticates release attestations nor activates, launches,
+or confines the generation. Do not treat a locally staged generation as
+production Computer Use admission. Production still needs authenticated release
+acceptance, root-managed active-generation selection, exact-generation launch,
+complete process and loader confinement, live process-incarnation supervision,
+and an accepted GNOME capture/input driver.
diff --git a/docs/linux.md b/docs/linux.md
new file mode 100644
index 00000000..caa25750
--- /dev/null
+++ b/docs/linux.md
@@ -0,0 +1,171 @@
+# Linux desktop support
+
+Aiden Agent's release pipeline produces native x64 and arm64 Linux builds as
+AppImage, Debian, and RPM packages. The current public `v0.40.0` release predates
+that pipeline and contains only the arm64 Mac build; Linux installation becomes
+available with the next accepted release. The `.deb` and `.rpm` formats are
+recommended because the distro package manager installs Electron's runtime
+libraries and owns replacement or removal. AppImage is the portable fallback.
+
+## Install
+
+The cross-platform installer detects macOS or Linux, the native architecture,
+and common Debian or RPM distribution families:
+
+```sh
+curl -fsSL https://raw.githubusercontent.com/sambitcreate/aiden-agent/main/install.sh | sh
+```
+
+Linux installation requires a current trusted GitHub CLI so the selected package
+can be checked against Aiden's main-branch release-workflow attestation. Pass an
+independently reviewed release commit for the strictest path:
+
+```sh
+curl -fsSL https://raw.githubusercontent.com/sambitcreate/aiden-agent/main/install.sh | \
+ sh -s -- --expected-commit REPLACE_WITH_APPROVED_40_CHARACTER_COMMIT
+```
+
+Without that option, the installer pins verification to the exact commit in the
+GitHub release record and says so. This is convenient release authentication,
+not approval for the separate Computer Use runtime boundary. Use `--user` for a
+writable AppImage under `~/.local/share/aiden-agent`, or `--download-only DIR`
+to retain the verified package without installing it. `--plan --version X.Y.Z`
+prints the platform decision without network access.
+
+You can also download the package for your architecture manually from
+[GitHub Releases](https://github.com/sambitcreate/aiden-agent/releases):
+
+Debian, Ubuntu, Linux Mint, Pop!_OS, and related distributions:
+
+```sh
+sudo apt install ./Aiden-Agent-*-linux.deb
+```
+
+Fedora, RHEL, Rocky Linux, and other RPM-based distributions:
+
+```sh
+sudo dnf install ./Aiden-Agent-*-linux.rpm
+```
+
+Portable AppImage:
+
+```sh
+chmod +x Aiden-Agent-*-linux.AppImage
+./Aiden-Agent-*-linux.AppImage
+```
+
+The AppImage uses a pinned static launcher, so it does not depend on the legacy
+FUSE 2 userspace library. A container, locked-down host, or other environment
+without a usable FUSE mount can still use AppImage's extraction fallback:
+
+```sh
+./Aiden-Agent-*-linux.AppImage --appimage-extract-and-run
+```
+
+## Desktop requirements
+
+- A glibc 2.34 or newer x64 or arm64 desktop distribution supported by
+ Electron. This includes RHEL/Rocky Linux 9, Debian 12, Ubuntu 22.04, and newer
+ releases in those families.
+- A working graphical session under X11 or Wayland.
+- A Secret Service or KWallet credential backend when saving provider, MCP,
+ ChatGPT, or model-data credentials. GNOME Keyring, KDE Wallet, and compatible
+ desktop keyrings provide this on common desktop installations.
+- `tar` for on-device speech-model installation and `openssl` only when the
+ optional nearby Aiden On The Go listener creates its local TLS identity.
+- Tailscale only when the optional private-tailnet remote route is selected.
+
+Aiden deliberately refuses to save secrets when Electron reports the Linux
+`basic_text` backend. Unlock or configure the desktop keyring and restart Aiden;
+the app will not silently downgrade credentials to reversible local storage.
+Keyless local connections such as LM Studio and Ollama do not use secret
+storage and remain available when no keyring session is running.
+
+## Tailscale remote access
+
+Tailscale installs its CLI at `/usr/bin/tailscale` on mainstream Linux
+packages. After signing in, grant your desktop user one-time permission to
+manage Serve routes:
+
+```sh
+sudo tailscale set --operator=$USER
+```
+
+Aiden changes only its scoped `/api/aiden/v1` HTTPS Serve path and preserves
+unrelated Serve configuration. Without the operator grant, status remains
+readable but Aiden reports the permission requirement instead of claiming an
+uncertain connection.
+
+## Platform behavior
+
+The workspace agent, providers, local models, MCP, skills, Web Search,
+schedules, terminal, Git, file editor, generative UI artifacts, diagnostics,
+Gemini voice transcription, remote access, notifications, profile, themes, and
+native subagents use the same contracts as macOS. Linux-specific integrations include:
+
+- native distro window chrome and conventional File/Edit/View/Window/Help menus;
+- Ctrl-based app and global shortcuts, including the Wayland Global Shortcuts
+ portal on desktops that implement it;
+- Vulkan disabled on Wayland sessions to avoid Chromium's Ozone incompatibility
+ warning (pass `--ozone-platform=x11` to keep the default feature set);
+- editor discovery through `PATH`, Snap command locations, JetBrains Toolbox
+ scripts, and common Flatpak application IDs;
+- opening folders with the default desktop file manager;
+- profile snapshot export through a Save dialog;
+- bundled Node mDNS publication for nearby Aiden On The Go discovery, without
+ requiring Apple's `dns-sd` utility.
+
+Bots use the same roster, definitions, access controls, conversations, schedules,
+and Telegram bindings as macOS. Their native authority helper requires an
+unlocked Secret Service collection (for example GNOME Keyring, or a KDE Wallet
+that exposes the Secret Service API). KWallet support for Electron credentials
+alone does not establish this requirement. Bot authority and rollback anchors
+never fall back to files or plaintext. If the helper or keyring is unavailable,
+Bot operations fail closed while ordinary workspace chat remains available;
+unlock or configure the keyring and restart Aiden to restore Bot access.
+
+Computer Use and Apple Foundation Models are not included in the Linux
+build. Their settings, navigation, onboarding promises, helper bundles, and
+chat controls are omitted. Global dictation remains available when the desktop can register its
+shortcut, but the transcript is copied to the clipboard instead of using the
+macOS Accessibility auto-paste transaction. To use hold-to-dictate, choose Hold
+in Settings → Voice and assign a shortcut in your desktop’s Global Shortcuts
+portal. Hold setup requires the package's desktop entry to be installed under a
+system or user applications directory; development runs and non-integrated
+AppImages retain toggle dictation. Setup is explicit for each app session; startup never opens a permission
+dialog. The desktop may assign a different trigger, which Aiden displays. If
+the session ends, or shortcuts are edited, Aiden returns to toggle behavior.
+Desktops without this portal keep toggle dictation. Wayland compositors own final
+placement of the dictation pill, so exact bottom-center positioning may vary.
+
+Provider inventories may refresh only from the provider services the user has
+configured. Descriptive model metadata uses the bundled release snapshot or a validated
+device-local display cache. The explicit **Update model catalogs** action in
+Settings may refresh that cache; startup and ordinary reads stay offline.
+
+## Updates and troubleshooting
+
+Writable production AppImages mounted by their launcher use Settings → About
+for update checks, downloads, and restart. Downloads are verified against the
+SHA-512 digest in the architecture-specific GitHub release feed. Updates retain
+the current AppImage filename. Unlike macOS releases, this does not provide Apple
+code-signing verification.
+
+Native subagent file replacement preserves metadata or fails closed. On an
+SELinux host, Aiden preserves a non-default `security.selinux` label when the
+active policy permits the user-owned helper to copy it. If the kernel denies
+copying that label or Linux file capabilities, Aiden leaves the original file
+unchanged and reports the mutation as unavailable. Restore a normal workspace
+label or apply denied privileged metadata outside the subagent transaction
+before retrying.
+
+Install the newer `.deb` or `.rpm` through the package manager that owns the
+installation. Read-only AppImages and extraction-mode launches also use manual
+replacement. Settings → About links to GitHub Releases for these installations.
+
+If a global shortcut is unavailable, check the desktop's shortcut portal or
+conflicts with another application and assign another chord under Settings →
+Keyboard shortcuts. If an AppImage does not start, prefer the native distro
+package or use the extraction command above. When reporting a Linux issue,
+include the distribution, architecture, desktop environment, X11/Wayland
+session type, package format, and the exact error shown by Aiden.
diff --git a/docs/plans/README.md b/docs/plans/README.md
index d487e5a7..cec4b599 100644
--- a/docs/plans/README.md
+++ b/docs/plans/README.md
@@ -4,6 +4,8 @@ This directory is the source of truth for Aiden's implementation plans. The engi
## Active and partial
+- [Linux macOS parity reconciliation](linux-macos-parity-plan.md) — Active; implementation through pull-request closure remediation passes two-review gates and the full local suite. Enforcing Fedora GNOME VM validated the SELinux file-descriptor probes, Electron role transitions, protected-socket transfer, payload inventory, root-managed generation staging, and fail-closed security-label preservation. Release provenance and cross-platform installer delivery are implemented. Authenticated production admission, active-generation launch, complete confinement and live desktop-driver acceptance remain. Real portal testing exposed modifier-release loss; GNOME safely retains toggle dictation. Computer Use remains disabled on Linux.
+
| Plan | Status | Current state |
| -------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Scheduled-Task Provider and Pi Rollout Recovery](scheduled-provider-and-pi-rollout-recovery-plan.md) | Implemented | Attended chat starts seed the scheduler's provider fallback, tasks pin providers explicitly (editor picker + prefilled drafts + honest `schedule_task` gating), Pi-rollout-ineligible chats generate journalless over in-memory sessions with the fail-closed contract preserved, and remote request journal events carry method/route evidence — green in the recovery worktree; release-owner stage advance (B1) and machine remediation remain. |
@@ -65,5 +67,6 @@ This directory is the source of truth for Aiden's implementation plans. The engi
| [Sidebar Chat Activity](completed/sidebar-chat-activity-plan.md) | Complete | Complete, revisioned per-chat activity appears as an accessible static ring with no polling or perpetual animation. |
| [Pi Compaction Compatibility](completed/compaction-reliability-plan.md) | Complete | Aiden's adapter now matches the audited Pi baseline 1:1 and persists only closed, privacy-safe provider-failure metadata for durable UI. |
| [Pi Thinking Disclosure](completed/pi-thinking-disclosure-plan.md) | Complete | Provider-neutral readable Pi thinking, a one-second inspectable preview, and a durable local presentation toggle now match the audited Pi contract. |
+| [Linux Desktop Support](completed/linux-desktop-support-plan.md) | Complete | Linux x64/arm64 AppImage, DEB, and RPM packages, explicit platform capability tradeoffs, native helpers, and hosted Ubuntu/Fedora package and E2E acceptance all pass. |
Move a plan to `completed/` only when its original delivery scope is complete. Keep the original plan as historical documentation; follow-on work belongs in a new active plan.
diff --git a/docs/plans/completed/linux-desktop-support-plan.md b/docs/plans/completed/linux-desktop-support-plan.md
new file mode 100644
index 00000000..60ac38d1
--- /dev/null
+++ b/docs/plans/completed/linux-desktop-support-plan.md
@@ -0,0 +1,191 @@
+# Linux Desktop Support
+
+Status: Complete — implementation, native acceptance, and hosted x64/arm64/Fedora acceptance complete 2026-08-30
+
+## Goal
+
+Ship Aiden Agent as a first-class Linux desktop application that installs and
+runs on the common Debian/Ubuntu, Fedora/RHEL, and openSUSE families, with a
+portable AppImage option. Preserve the existing macOS experience while making
+platform-specific behavior explicit, secure, tested, and maintainable.
+
+## Supported baseline
+
+- Architectures: x86_64 and arm64.
+- Packages: AppImage, `.deb`, and `.rpm`.
+- Runtime baseline: glibc 2.34 or newer; package verification rejects native
+ executables or modules that raise that floor.
+- Display servers: X11 and Wayland. Window placement that Wayland deliberately
+ forbids is treated as a capability limitation rather than emulated.
+- Desktop integration: native Linux window frame/menu, desktop notifications,
+ default file manager, common installed editors, and Secret Service/KWallet
+ credential encryption.
+- Computer Use, Apple Foundation Models, and Bots remain macOS-only. Linux omits
+ their helpers, settings/navigation actions, onboarding promises, and runtime
+ tool exposure while retaining the shared implementations for capable hosts.
+
+## Research-backed tradeoffs
+
+1. Electron supports Linux x64 and arm64, while electron-builder directly
+ supports AppImage, Debian, and RPM targets. Native dependencies and Aiden's
+ own helper executables must be built and verified on the target OS rather
+ than copied from macOS.
+2. Linux `safeStorage` can fall back to Electron's `basic_text` backend. Aiden
+ will fail closed for provider and OAuth secrets when no desktop keyring is
+ available, with an actionable error, instead of silently storing secrets
+ with the hard-coded fallback key.
+3. Wayland does not allow applications to position or programmatically focus
+ windows in all compositors. Global dictation therefore guarantees capture
+ and clipboard delivery on Linux, while exact floating-pill placement and
+ automatic paste remain macOS conveniences.
+4. Linux uses a conventional native frame. macOS keeps hidden-inset traffic
+ lights, vibrancy, and the Dock-icon preference; Linux does not expose those
+ controls.
+5. Native profile sharing becomes a Save dialog on Linux. Opening a workspace
+ uses the system file manager, and supported editors are discovered from
+ executable paths/Flatpak installations rather than macOS bundles/Spotlight.
+6. Linux packages initially use explicit download/install updates. The current
+ signed macOS updater stays unchanged; silently treating `.deb`/`.rpm`
+ replacement as equivalent would bypass distribution ownership and package
+ manager expectations.
+
+Primary references:
+
+- [Electron supported platforms](https://www.electronjs.org/docs/latest/tutorial/installation)
+- [Electron safeStorage](https://www.electronjs.org/docs/latest/api/safe-storage)
+- [Electron Linux notifications](https://www.electronjs.org/docs/latest/tutorial/notifications)
+- [Electron custom title bars](https://www.electronjs.org/docs/latest/tutorial/custom-title-bar)
+- [electron-builder Linux targets](https://www.electron.build/docs/linux/)
+- [electron-builder cross-platform builds](https://www.electron.build/docs/features/multi-platform-build/)
+
+## Delivery phases
+
+### Phase 1 — audit, research, and support contract
+
+- Inventory macOS assumptions in startup, windows, menus, permissions,
+ packaging, helper binaries, onboarding, settings, and release automation.
+- Establish the supported distro/package matrix and deliberate limitations.
+- Add this plan to the canonical plan inventory.
+
+Review gate: confirm that every default-on service either has a Linux path or
+is explicitly capability-gated before implementation begins.
+
+### Phase 2 — Linux build, package, and runtime foundation
+
+- Make development startup platform-neutral.
+- Add Linux AppImage, Debian, and RPM packaging with x64/arm64 metadata,
+ Linux runtime dependencies, icons, native helpers, unpacked native modules,
+ and fuse/package verification.
+- Port the worktree remover, subagent run store, file mutator, and shell runner
+ to Linux without weakening their path, identity, or atomicity contracts.
+- Add platform-safe window construction, menu construction, terminal shell
+ selection, and secure-storage selection.
+
+Review gate: build and test all Linux native helpers, create an unpacked Linux
+package, inspect its resources/fuses/permissions, and run focused runtime tests.
+
+### Phase 3 — platform capabilities and service adaptations
+
+- Advertise typed host capabilities to the renderer.
+- Omit Computer Use and Apple Foundation Models on Linux.
+- Adapt microphone permission, dictation delivery, profile sharing, external
+ editors/file manager, Tailscale discovery, and LAN service publication.
+- Remove or replace macOS-only controls and promises in Settings/onboarding
+ while retaining clear explanations for deliberate Linux limitations.
+
+Review gate: exercise every changed IPC boundary and verify that unsupported
+features cannot be enabled or invoked through stale renderer state.
+
+### Phase 4 — CI, distribution, documentation, and acceptance
+
+- Add Linux unit/integration, native-helper, package-contract, and Electron E2E
+ coverage under Xvfb.
+- Add x64 and arm64 Linux artifact workflows.
+- Document install, keyring, Wayland, package ownership, and update behavior.
+- Run type-check, lint, focused suites, full tests, Linux package verification,
+ and smoke launch acceptance.
+
+Review gate: freeze the diff, perform a final platform/security regression
+review, and archive this plan only when the complete acceptance matrix passes.
+
+## Implementation and review record
+
+- Phase 1 complete: audited startup, windows, menus, permissions, helpers,
+ packaging, onboarding, settings, Remote Access, companion copy, and release
+ automation. Every default-on service now has a Linux implementation or an
+ explicit main-owned capability gate.
+- Phase 2 complete: Linux development startup, x64/arm64 AppImage/DEB/RPM
+ configuration, package/fuse verification, native C helper portability,
+ Linux PTY layouts, conventional window/menu behavior, and fail-closed
+ keyring selection are implemented. Clean target-architecture builds produced
+ and verified all three package formats and every native helper/module.
+- Phase 3 complete: Computer Use and Apple Foundation Models are inaccessible
+ on Linux; Settings, onboarding, profile export, dictation, shortcuts,
+ editors/file manager, Tailscale, nearby mobile discovery, and Aiden On The
+ Go copy follow the advertised platform capabilities. Review additionally
+ fixed Linux safe-save recovery ownership and Remote Access publisher races.
+- Latest-main parity reconciliation retains Web Search, diagnostics, Gemini
+ voice transcription, Model Pad, companion projections, raster and sandboxed
+ generative UI artifacts, and response/accessibility improvements on Linux.
+ Chat-native Scheduled Tasks, including revision-bound remote runs and shared
+ desktop/mobile presentation, are also platform-neutral on Linux.
+ The attended-chat Ask User Question composer and the native todo, BTW, and
+ Advisor Pi extensions are also platform-neutral and covered by both Ubuntu
+ and Fedora Linux CI gates.
+ The shared capability projection now also hides Bots and all Bot entry points
+ until Linux receives equivalent native security bindings. Settings and
+ Command-K share the same availability filter, dictation never invokes macOS
+ Accessibility on Linux, and model metadata remains a release-bundled offline
+ snapshot with no live models.dev UI action.
+- Phase 4 implementation complete: CI builds x64 and arm64 artifacts, installs
+ and verifies DEB on Ubuntu 24.04 and RPM on Fedora 44, and runs Electron E2E
+ under Xvfb. Release publication requires both Linux architectures alongside
+ the verified macOS artifacts. Linux installation and limitation guidance is
+ documented in `docs/linux.md`.
+- Native acceptance passes on both x64 and arm64 Ubuntu 24.04: package
+ verification, DEB installation, RPM metadata, AppImage execution, native
+ linkage, desktop association, and fresh empty-XDG X11 startup. Debian 12
+ additionally proves the glibc 2.36/legacy-ALSA path; Fedora 44 and openSUSE
+ Tumbleweed prove RPM dependency resolution; headless Weston proves Wayland
+ startup. The complete deterministic Electron E2E suite also runs under Xvfb.
+- The final adversarial pass fixed several defects that metadata-only checks
+ missed: Linux's seven-field native generation token, a glibc 2.38 symbol
+ leak, Debian's false ALSA virtual provider, architecture-specific release
+ filenames, a dynamically linked arm64 AppImage launcher, non-ELF `.node`
+ verifier inputs, AppleDouble test sidecars, stripped X11/Wayland launch
+ variables, keyless onboarding incorrectly requiring a desktop keyring, and
+ RPM integrity checks rejecting electron-builder's exact sandbox-mode fallback.
+ It also corrected Linux last-window shutdown so enabled Remote Access retains
+ background ownership without changing the ordinary last-window quit policy.
+- Local regression acceptance passes `type-check`, E2E type-check, lint, Linux
+ contracts, helper/parser adversarial suites, package/publisher tests, and the
+ production TypeScript-to-native run-store boundary.
+- Live OrbStack acceptance now also exercises the installed Linux
+ `/usr/bin/tailscale` client from the real Aiden controller. A separate
+ tailnet peer reached the exact HTTPS `/api/aiden/v1/health` contract, and
+ teardown removed only Aiden's scoped Serve route. This pass added strict
+ `Running`/online status checks and actionable detection of Linux's required
+ one-time Tailscale operator grant.
+
+Hosted run `33338723528` completed the release-infrastructure gate: Linux x64,
+Linux arm64, the Fedora RPM artifact consumer, deterministic Electron E2E,
+Android, and the full verification job all passed. The final CI hardening uses
+SIGKILL for bounded Electron smoke teardown and hands Fedora a checksum-verified
+RPM built against the Ubuntu baseline, preserving the strict glibc 2.34 floor.
+Together with the native acceptance matrix above, this completes the plan's
+original delivery scope.
+
+## Acceptance criteria
+
+- A fresh supported Linux desktop can install an appropriate Aiden package and
+ complete onboarding without encountering a macOS-only control or helper.
+- Chat, providers, local runtimes, MCP, terminal, workspaces/worktrees,
+ subagents, schedules, notifications, local voice, Telegram, and Aiden Remote
+ retain their existing contracts on Linux or disclose a documented platform
+ limitation before the user acts.
+- Secret material is never written through Electron's Linux `basic_text`
+ backend.
+- AppImage, Debian, and RPM contents include the correct-architecture native
+ modules/helpers and exclude Computer Use and Apple-only helper artifacts.
+- macOS packaging, signing, notarization, UI, and feature availability remain
+ regression-tested and unchanged except for shared platform abstractions.
diff --git a/docs/plans/rpiv-advisor-integration-plan.md b/docs/plans/completed/rpiv-advisor-integration-plan.md
similarity index 99%
rename from docs/plans/rpiv-advisor-integration-plan.md
rename to docs/plans/completed/rpiv-advisor-integration-plan.md
index 006818f9..0b7343c9 100644
--- a/docs/plans/rpiv-advisor-integration-plan.md
+++ b/docs/plans/completed/rpiv-advisor-integration-plan.md
@@ -1,6 +1,6 @@
# rpiv-advisor integration
-Status: Implemented (2026-08-30)
+Status: Complete (2026-08-30)
## Objective
diff --git a/docs/plans/linux-cua-driver-gap-audit.md b/docs/plans/linux-cua-driver-gap-audit.md
new file mode 100644
index 00000000..56ffd306
--- /dev/null
+++ b/docs/plans/linux-cua-driver-gap-audit.md
@@ -0,0 +1,62 @@
+# Pinned Cua driver: Fedora GNOME gap audit
+
+Status: source audit, 2026-09-12. No Linux Computer Use admission or full-parity acceptance. This audit did not install the extension, exercise desktop driver operations, or change policy. The separately noted isolated ARM64 version check grants no desktop access. Upstream test claims below are not Aiden's Fedora 44 / GNOME 50.4 acceptance results.
+
+## Pin and release artifacts
+
+Aiden currently pins only the macOS universal artifact in `resources/computer-use/cua-driver-artifact.json`: Cua 0.8.3, tag `cua-driver-rs-v0.8.3`, source `0612c26b2c7b8556f6de7f6b4f3927ecac914e4f`. The [release API](https://api.github.com/repos/trycua/cua/releases/tags/cua-driver-rs-v0.8.3) advertises Linux bare-binary archives:
+
+| Asset | Archive SHA-256 reported by GitHub | Size |
+| --- | --- | --- |
+| `cua-driver-rs-0.8.3-linux-arm64-binary.tar.gz` | `910456505b927966867f668e37195b130364dcc50f566d4301cd9c3760da9cd3` | 10,856,213 bytes |
+| `cua-driver-rs-0.8.3-linux-x86_64-binary.tar.gz` | `42bd2cfb2df60b9d635eb52aaf389ff816e6a7ff45c843e815688a8d96feda2f` | 10,828,874 bytes |
+
+Both exact archives were subsequently downloaded through the release, matched the advertised SHA-256 values above, and contained one regular `cua-driver` executable. Static ELF inspection confirmed AArch64 and x86-64 respectively. Extracted executable SHA-256 values:
+
+- ARM64: `6fb1b0b43b5123390f77b61e00e1acab8ec8e32ff3133a8e5463738cd73ccb29`
+- x86-64: `4b7f229ea82ed93da7e2414e53224aed4b1a76685503d4558e07007e693747eb`
+
+Static `DT_NEEDED` inspection found X11, Xi, xkbcommon, GCC support and glibc libraries. This is not a complete runtime dependency inventory: dynamically opened libraries, the extension and runtime resources still require inspection. The ARM64 executable subsequently returned `cua-driver 0.8.3` for `--version` in a bounded Bubblewrap invocation with a separate network namespace, no session/display sockets, a clean environment, and telemetry/update flags disabled. This establishes basic startup only; the x86-64 executable has not been run and neither driver has desktop acceptance. The downloads and receipt are under `/tmp/aiden-linux-cua-artifact-audit/`. These hashes record observed upstream bytes; they are not production admission pins or proof of source-to-binary provenance. Linux packaging, immutable payload installation and exact live binary admission remain work. The macOS signing contract does not cover these assets.
+
+## Source-backed capability limits
+
+The pinned [Wayland selection gate](https://github.com/trycua/cua/blob/0612c26b2c7b8556f6de7f6b4f3927ecac914e4f/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs#L62-L101) is off by default. It requires `WAYLAND_DISPLAY` and an explicitly enabled `CUA_DRIVER_RS_ENABLE_WAYLAND` value. Installing an extension alone does not exercise the native Wayland route. A future trusted launcher must set the reviewed constant deliberately while retaining strict environment filtering; ordinary caller environment must not select the authority boundary.
+
+References below use the immutable source commit. The [platform table and background contract](https://github.com/trycua/cua/blob/0612c26b2c7b8556f6de7f6b4f3927ecac914e4f/docs/content/docs/reference/cua-driver/platform-support.mdx#L39-L79) and [validation ledger](https://github.com/trycua/cua/blob/0612c26b2c7b8556f6de7f6b4f3927ecac914e4f/libs/cua-driver/docs/linux-desktop-validation.md#L5-L14) support this matrix:
+
+| Surface | Pinned upstream position | Fedora GNOME acceptance still needed |
+| --- | --- | --- |
+| Accessibility and semantic background actions | AT-SPI actions and native GTK behavior covered; semantic actions can avoid raising a window. | Real GTK and Electron/Tauri actions, focus and foreground noninterference; unavailable semantics must refuse. GNOME shared-renderer coverage remains open upstream. |
+| Geometry, window targeting, foreground input | GNOME route depends on WinRects geometry and verified activation before portal/libei input. | Compatible trusted extension, target/focus checks, grant cancellation and revocation, real input outcomes. |
+| Screenshots | Display capture tries Shell helper, native Wayland protocols, then Screenshot portal; window capture crops a display image using known geometry. | Actual GNOME capture route, consent, scale/multiple displays, occlusion and minimized-window results. A display crop does not establish independent capture of an occluded window. |
+| Raw background input | Ordinary standard Wayland clients cannot address arbitrary occluded surfaces through active-seat input. Semantic hit-test fallbacks cover some pixel-addressed actions. | Preserve structured refusals; do not silently foreground or inject into the occluding application. |
+| Video and broader renderers | GNOME portal video and shared renderer matrix remain open in the ledger. | No accepted recording or complete app/toolkit parity claim. |
+
+The actual [capture dispatcher](https://github.com/trycua/cua/blob/0612c26b2c7b8556f6de7f6b4f3927ecac914e4f/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs#L924-L1001) is more precise than nearby comments: it attempts several routes and crops the composited display for window captures. The existence of a protocol fallback in source does not prove GNOME 50 advertises that protocol or that the release binary succeeds on it.
+
+The arbitrary raw-background limitation is architectural for an ordinary client on stock Wayland, not a claim that a compositor modification could never implement it. Upstream's experimental nested `cua-compositor` supplies a different environment; it does not establish parity on the user's existing GNOME desktop. macOS itself also has structured refusals, so acceptance should compare concrete action cells rather than promise universal macOS behavior.
+
+## WinRects compatibility and additional authority boundary
+
+The [extension metadata](https://github.com/trycua/cua/blob/0612c26b2c7b8556f6de7f6b4f3927ecac914e4f/libs/cua-driver/wayland-helper/winrects@cua/metadata.json#L1) declares Shell versions **45–48**, not 50. This is a declared compatibility gap; no actual GNOME 50 load failure was tested in this audit. Adding a version string or disabling extension validation would not establish behavioral compatibility.
+
+The [helper README, lines 8–39](https://github.com/trycua/cua/blob/0612c26b2c7b8556f6de7f6b4f3927ecac914e4f/libs/cua-driver/wayland-helper/README.md#L8-L39) describes global window geometry, activation, capture, and cursor operations inside GNOME Shell. Capture uses Shell privilege without a portal grant. AX can remain useful without the helper, but authoritative geometry and verified foreground delivery are missing from that route.
+
+The [extension implementation, lines 13–41 and 110–144](https://github.com/trycua/cua/blob/0612c26b2c7b8556f6de7f6b4f3927ecac914e4f/libs/cua-driver/wayland-helper/winrects@cua/extension.js#L13-L144) exports `org.cua.WinRects` on the session bus. Its capture and activation methods contain no caller authentication. Under a permissive session-bus policy, another reachable caller could invoke these methods directly. This is a source-derived exposure, not an exploitation result on the VM, where this audit did not install the extension.
+
+Consequently, admitting only the exact main/broker/driver processes cannot by itself protect the added Shell endpoint. A solution must enforce who may invoke it, protect its installed code and the Shell hosting it from the modeled hostile same-UID process, authenticate the service side, and revoke access with the authorized lifecycle. A caller-supplied app ID, bus name, PID lookup alone, or writable extension copy is not a replacement for the requested live exact-build boundary. The viability and scope of kernel/bus enforcement need a separate proof; this audit does not claim a few JavaScript checks solve it.
+
+## Next implementable scope
+
+1. Finish the isolated mandatory-boundary probes independently of driver behavior. Retain disabled admission until main/child role separation, immutable executable and interpreted payloads, runtime libraries/JIT, descriptor inheritance/transfer, and lifecycle revocation are demonstrated together.
+2. Build a reviewed Cua source revision with a GNOME 50-compatible extension in the isolated VM, retaining upstream automation implementation. First test ABI compatibility without treating it as authorization. Design and negatively test the authenticated Shell boundary before granting it access from production Aiden. A changed build requires a new reviewed pin and extracted binary/payload hashes; it is no longer the untouched 0.8.3 artifact.
+3. Compare a portal-based upstream route before committing to the extension design. [RemoteDesktop](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.RemoteDesktop.html) supports user-granted input and an EIS descriptor; [ScreenCast](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.ScreenCast.html) supplies selected capture streams via PipeWire. Those capabilities still require protected descriptor ownership and revocation. They do not by themselves provide the pinned driver's exact-target activation/geometry contract or arbitrary raw background delivery. A portal alternative may require upstream changes and narrower capability reporting.
+4. Run an app-owned GTK/Electron/Tauri matrix on the actual GNOME session: AX and pixel actions, foreground and background, occlusion, cancellation, revocation, multi-display scaling, target disappearance, and hostile caller negatives. Accept each proven cell and preserve explicit refusal elsewhere.
+
+Aiden's [existing integration decision](../computer-use-integration.md#decision) delegates automation to Cua and keeps its broker limited to authentication, transport, and lifecycle. Compatible upstream changes or a reviewed Cua fork fit that separation more closely than implementing a second capture/input engine inside Aiden. Either alternative needs an explicit reviewed Linux architecture and artifact policy; neither is authorized for release by this audit.
+
+## Related Electron observation
+
+A separate live Fedora probe recorded Electron 43.1.1 browser, GPU, renderer, network utility, and Node utility processes using the same Electron executable and stock `unconfined_t`. The sandboxed renderer reported `Seccomp: 2` and `NoNewPrivs: 1`; the run did not use `--no-sandbox`. Receipt: `/tmp/aiden-fedora-parity-vm/electron-role-results.json`. This is an observational subset, not a full process inventory: `getAppMetrics` did not enumerate a zygote.
+
+A source-domain transition from a constrained launcher into main, followed by a different transition when main executes the same binary, is a candidate for the next isolated experiment. Its correctness depends on actual exec and fork paths, including zygotes and direct utility processes, plus payload integrity and JIT constraints. The observation does not establish role isolation, and command-line role strings must not become the admission credential.
diff --git a/docs/plans/linux-macos-parity-plan.md b/docs/plans/linux-macos-parity-plan.md
new file mode 100644
index 00000000..69f6f936
--- /dev/null
+++ b/docs/plans/linux-macos-parity-plan.md
@@ -0,0 +1,420 @@
+# Linux macOS parity reconciliation
+
+Status: Active — phases 1–3c implemented and reviewed; phase 4 hosted validation running; Fedora Computer Use prerequisites in progress, 2026-09-12.
+
+Baseline: Linux `6a397578` (0.36.1), macOS main `a4c85c6d` (0.40.0).
+
+## Phases and gates
+
+1. Integrate stacked Linux fixes `e0f48366`, repair the service status contract, and validate remote/Wayland behavior.
+2. Reconcile current main shared runtime, persistence, chat, browser, terminal, settings, onboarding and mobile contracts while preserving Linux platform integrations.
+3. Audit and implement remaining feasible Linux native parity, including Bots security backend, dictation and Computer Use; retain explicitly platform-specific Apple capabilities and respect compositor/package-manager ownership.
+4. Validate macOS regressions and native Linux x64/arm64 packaging, desktop behavior, mobile contracts and final feature matrix.
+
+After every phase, two independent GPT-6 Astra reviewers at medium effort review the implementation. Resolve actionable findings and rerun relevant checks before proceeding. Record evidence and limitations; no claim of full parity before applicable native acceptance passes.
+
+## Evidence
+
+- Linux support branch pulled; stacked fixes fast-forwarded locally.
+- Phase 1 fixes a hosted TypeScript failure: service status omitted `permission_denied` although renderer status and runtime handling included it.
+
+- Phase 1: type-check and focused lint passed; initial remote/Wayland suite 113 passed, 1 skipped; post-review regression suite 51 passed, 1 skipped. Two GPT-6 Astra medium reviewers cleared all findings after fixes for Chromium feature preservation and Tailscale error precedence.
+
+## Phase 2 evidence
+
+- Reconciled main 0.40.0 into Linux; 31 merge conflicts resolved by ownership.
+- Shared chat drafts/queue/sidebar, durable memory/Pi lifecycle, browser, Ghostty, Settings, onboarding and remote/mobile contracts retained.
+- Two GPT-6 Astra medium reviewers cleared runtime/mobile and renderer/build scopes; fixed unsupported Linux hold-to-dictate UI and explicit main-to-renderer capability projection.
+- Full desktop command: 5,916 passed, 3 skipped, zero failures. TypeScript including E2E, lint, branding, production build passed.
+- macOS targeted Electron: 12 passed (browser, real PTY, chat controls, guided pairing).
+- Android: 141 unit tests passed, lint and instrumentation compilation passed. iOS generic hardware app/test compilation passed; physical execution remains unverified.
+- Native Linux ARM64 container: type-check, platform contracts, safety helper tests and production build passed.
+- Linux ARM64 Electron initially 10 passed / 2 failed: annotation preview and recording startup time out when rendering hidden surfaces. These native runtime gaps remain explicit phase 3 work; shared-source reconciliation does not claim native acceptance.
+- Foreground model catalog parity restored according to root user instructions; fixed endpoint/cache privacy suite plus UI/onboarding checks: 24 passed.
+
+## Remaining native parity matrix
+
+| Capability | Current Linux implementation | Next acceptance |
+| --- | --- | --- |
+| Bots and Bot Telegram routes | Linux Secret Service authority implemented; two source reviews cleared | Native keyring, ARM64 packaging and Linux Settings acceptance passed; hosted multi-distro gates remain |
+| Dictation capture/transcription | Available; toggle shortcut and clipboard delivery | Desktop-owned release events and safe paste when available, X11/Wayland acceptance |
+| Computer Use | Gated off; broker and process trust require macOS | Linux capture/input/accessibility backend and equivalent lifecycle/security boundary |
+| Updates | Eligible mounted AppImages support verified downloads and atomic replacement; DEB/RPM remain package-manager owned | Future published-version download/restart acceptance |
+| Apple-only services | Apple Foundation Models and Dock integration unavailable by platform | Preserve local model alternatives and native Linux desktop behavior |
+
+Full parity is not claimed by the shared-feature merge. Native implementation and target desktop acceptance remain required.
+
+- Phase 2 merge gate complete: both required reviewers cleared integration findings. Linux x64 type-check/contracts/native helpers/build passed under local emulation. ARM64 AppImage/DEB/RPM built and hardened package verifier passed; DEB install reported 0.40.0 and survived a bounded Xvfb GUI smoke. Native recording and detached capture remain phase 3 scope.
+
+- Final phase 2 reviews cleared imported on-device title preference correction and Linux hidden-view capture fix. Expanded ARM64 browser annotation/inactive screenshot test passed; original recording test passed on x64. ARM64 Chromium recording crashes remain a phase 3 runtime investigation; failed experiments were reverted.
+
+## Phase 3a: Linux Bots authority
+
+- Added native Secret Service helper and platform authority factories, preserving macOS Keychain namespaces and bootstrap semantics. No plaintext/file authority fallback or interactive keyring prompt.
+- Reused existing Bots settings/navigation and onboarding artwork on Linux; startup failures remain isolated from workspace chat.
+- Two Astra medium source reviews cleared after repairing test coverage registration. Bots coverage suite passed 445/445 after accounting for Node’s coverage instrumentation in the helper environment fixture; type-check and Linux contracts passed.
+- Private GNOME Keyring tests cover four authority namespaces, reads/writes across helper processes and daemon replacement, locked collection failure, session-only storage rejection, real duplicates, and missing default collection. CI runs this isolated Linux acceptance command.
+- Native ARM64 AppImage/DEB/RPM built and hardened package verification passed. Linux Settings Electron acceptance passed, including all Settings destinations and Bots navigation; corrected imported Mac-only Voice label. Both Astra medium reviewers cleared the final changes. Phase 3a complete.
+
+### ARM64 recording diagnosis
+
+A minimal visible-canvas Electron reproduction crashes at the ARM SVE instruction `cntd` on this OrbStack host (SME present, SVE absent). This matches upstream libyuv [fab11704](https://chromium.googlesource.com/libyuv/libyuv/+/fab11704cda62ff2d6b5e308b741e759ae816035). Chromium ignores libyuv environment-disable variables. No Aiden recorder change is justified by current evidence; acceptance needs an Electron build containing the upstream fix. This is specific to the tested CPU feature combination, not evidence that all ARM64 recording fails.
+
+## Phase 3b: AppImage update delivery (complete)
+
+- Add runtime eligibility for writable mounted production AppImages, preserving package-manager updates for DEB/RPM and manual replacement for extracted/read-only images.
+- Reuse About update controls through a main-provided capability; preserve Darwin behavior and avoid Linux signing claims.
+- Generate architecture-specific minimal AppImage feeds from exact release bytes; verify hashes again before publishing any release assets.
+- Implement atomic replacement with failure preservation and test disposable files before enabling installation. Both Astra medium reviewers cleared final changes after fixing swallowed installer failures during restart handoff.
+
+- Phase 3b validation: 19 updater tests, 27 release/branding script tests, 30 About/capability tests passed; full lint and TypeScript passed. ARM64 distributions built and verified. A real FUSE-mounted disposable AppImage passed runtime eligibility, atomic replacement, and replacement executable launch/version acceptance. Feed generation/verification passed against real package bytes. Future-version GitHub download and full production restart remain a release acceptance check.
+
+## Phase 3c: Linux hold dictation (complete implementation)
+
+- Explicit Voice Settings choice creates a desktop-owned GlobalShortcuts portal session; no permission prompts on startup. The displayed trigger comes from the compositor. Session loss, binding changes, disabled policy, and recorder suspension restore toggle ownership.
+- Native helper fences portal owner/request/session/shortcut signals. Main fences helper generations, early release and recording operations. Persistence commits use the latest Settings revision.
+- Both Astra medium reviews cleared after fixes for duplicate toggle registration and stale Settings persistence.
+- Nine native private-D-Bus cases, 87 voice tests, onboarding, Linux Settings Electron, TypeScript, full lint, ARM64 package build/verifier passed. Full desktop command passed 5,946 tests, 3 skipped, zero failures.
+- Real GNOME/KDE shortcut assignment and physical press/release acceptance remain external to the mock and Xvfb tests. Linux transcript delivery remains clipboard-only.
+
+## Phase 4: Hosted acceptance repair (complete; hosted CI passed)
+
+Hosted CI at cedcc841 passed shared verification, macOS Electron, Android, and Linux ARM64. Linux x64 passed packaging/keyring but failed three Electron cases: legacy empty-chat migration, unsupported Computer Use setup expectation, and Model Pad minimum-height fit with Linux window chrome. Repair and rerun before final acceptance; Fedora RPM job depends on x64 success.
+
+## Computer Use admission work remaining
+
+The pinned upstream Cua 0.8.3 release has Linux x64/arm64 artifacts, but the Aiden broker deliberately requires macOS live code-signing/audit-token authentication. A same-UID socket, PID, executable pathname, hash check, or bearer secret cannot substitute for that existing contract.
+
+A Linux implementation needs a root-managed verified release payload and an enforced execution domain covering Aiden main, broker, and driver. Exact-build identity, constrained loaders/children, protection against process-memory and descriptor access, and admission before driver execution must survive the complete launch chain. A dedicated broker UID by itself is insufficient. Portable AppImage support would also require separate trusted provisioning.
+
+Next implementation target is a distro-specific policy prototype (Fedora/SELinux or Ubuntu/AppArmor), with reviewed negative tests for tampered executable/ASAR/libraries, unauthorized clients, transferred descriptors, reused PIDs, and peer-loss revocation. Graphical acceptance must additionally verify capture, AT-SPI, and permitted foreground/background actions per compositor. The upstream KDE/background-action matrix has gaps; upstream fixture claims are not Aiden acceptance.
+
+Current Linux Computer Use gate stays disabled until this boundary is implemented and validated. Real compositor hold-shortcut acceptance, focused-element-safe dictation paste, and an Electron release carrying libyuv fab11704 also remain before a full parity claim.
+
+- Phase4 fixes: shared subagent generation validation now accepts exactly Linux7-field and macOS9-field identities; migration safety remains unchanged. Linux draft suite5/5 passed. Compact ModelPad layout passes all24native Linux geometry states with160px minimum retained. Guidedsetup now asserts unsupportedComputerUse absence onLinux (3tests passed). Mac focused Electron9/9 passed; lint/typecheck/build and36focused store tests passed. Both Astra medium reviewers cleared final changes.
+
+
+## Phase 5: Fedora GNOME Computer Use admission prerequisites
+
+The user selected Fedora GNOME with SELinux as the first implementation target.
+OrbStack's tested kernel (`7.0.14-orbstack-00380-ga7e0a2dc9535`) reports only
+`capability,landlock,yama,bpf` in `/sys/kernel/security/lsm`. Installing Fedora
+userspace there cannot validate SELinux enforcement. A separately booted Fedora
+GNOME host with enforcing SELinux is required for native acceptance.
+
+Run `npm run computer-use:linux-host-preflight` on the target desktop.
+The host preflight command is a read-only prerequisite diagnostic. Even a
+successful result does not authenticate a release, prove installed policy, or
+enable Computer Use. The existing Linux capability gate remains disabled.
+
+### Required launch-boundary prototype
+
+1. Root-managed provisioning verifies release provenance and the full immutable
+ payload: Electron, ASAR, snapshots, native addons, broker, driver and admitted
+ libraries. [fs-verity](https://www.kernel.org/doc/html/latest/filesystems/fsverity.html)
+ can protect file contents but does not itself enforce executable admission.
+2. A native launcher accepts fixed arguments, sanitizes environment and inherited
+ descriptors, and enters an exact-release main domain. Distinct broker, driver
+ and Electron child domains must prevent renderer/utility/zygote processes
+ acquiring main authority. Source-domain/executable-label transitions require
+ actual Electron fork/exec validation; an argv role claim is insufficient.
+3. Audit the effective policy against hostile unconfined processes. Fedora's
+ [targeted policy](https://github.com/fedora-selinux/selinux-policy/blob/rawhide/policy/modules/kernel/domain.te)
+ grants broad access from unconfined domains, so an additive module alone does
+ not establish isolation. Verify installed toolchain support before relying on
+ [CIL deny rules](https://github.com/SELinuxProject/selinux/blob/main/secilc/docs/cil_access_vector_rules.md);
+ neverallow is a compile-time assertion, not permission subtraction.
+4. Enforce entrypoint and executable mapping restrictions before execution,
+ protect runtime code sources and constrain the main process's required JIT.
+ Linux lacks Electron's macOS/Windows
+ [embedded ASAR integrity implementation](https://www.electronjs.org/docs/latest/tutorial/fuses).
+ Authenticating the interpreter alone does not authenticate writable scripts.
+5. Bind private channels to live process incarnations and contain all descendants
+ through a trusted supervisor. Kernel credentials alone do not prevent endpoint
+ delegation: negative tests must cover inherited/transferred descriptors,
+ `/proc/PID/fd`, ptrace, `pidfd_getfd`, PID reuse and peer-loss revocation.
+
+Acceptance requires tampered executable/ASAR/snapshot/library/driver rejection,
+unauthorized same-UID clients, Electron role confusion, and complete revocation
+under failure. Record the exact kernel, loaded policy, toolchain and release.
+After these pass, validate GNOME capture, AT-SPI, portal permission and supported
+foreground/background actions. These are requirements, not implemented controls
+or a claim that Fedora Computer Use currently works.
+
+- Phase 5 prerequisite diagnostic: 29 tests passed; both GPT-6 Astra medium reviewers cleared. Local OrbStack container correctly reports missing SELinux/session prerequisites. This completes the diagnostic subphase only; enforced launch-boundary implementation is still pending.
+- Hosted phase 4 run 34675055415: shared verification, macOS Electron and Linux ARM64 passed. Linux x64 now reaches 52 passing tests but Providers overflows by 17px at 390px; empty-chat migration passed on retry. Android emulator package installation failed with a broken pipe. Fedora RPM remains gated on x64.
+
+## Phase 6: Narrow Providers and migration fixture repair
+
+- Reproduced the hosted 17px overflow locally; constrained the existing Providers action group to its available width. The unchanged full Settings destination/width matrix passes on Linux. Resize checks now wait for the renderer to observe native content width.
+- Migration E2E no longer writes the index while Electron can rewrite it. All seed mutations run after verified shutdown and before relaunch, with prelaunch index/journal assertions. Production migration is unchanged. Linux draft suite passed 5/5.
+- Both GPT-6 Astra medium source reviews cleared. macOS focused regressions passed 8/8; E2E TypeScript and focused lint passed. The next hosted run remains a validation gate.
+
+## Phase 8: Fedora CI prerequisite repair
+
+- Hosted b637bfdc passed shared verification, macOS Electron, Linux x64, Linux ARM64 and Android. Fedora RPM failed before portal execution because dbus-run-session was absent.
+- Fedora44 package query identifies dbus-daemon as the provider. Added it to the existing CI prerequisites and extended the CI policy regression. Both Astra medium reviews cleared; six policy tests and the native portal suite on a real Fedora44 container passed.
+- Independently booted a new isolated Fedora44 ARM64 UTM VM with kernel 6.19.10-300.fc44.aarch64 and SELinux Enforcing. This removes the OrbStack kernel limitation for future testing; GNOME setup and launch-boundary acceptance are still pending.
+
+## Phase 9: Enforcing Fedora desktop acceptance host
+
+- Provisioned an independently booted Fedora 44 ARM64 VM: kernel 6.19.10-300.fc44.aarch64, SELinux Enforcing, SELinux userspace 3.11, GNOME 50.4 on Wayland, and GNOME portal 50.0. Two independent Astra medium reviewers verified the live host and prerequisite report.
+- The VM has 32 GiB sparse storage, key-only SSH bound to host loopback, and no clipboard, directory, or USB sharing. Ordinary outbound NAT remains enabled; this is not isolation from host network services.
+- Exact-head CI at f620ef14 (run 34697719544) passed shared verification, macOS Electron, Linux x64, Linux ARM64, Android, and Fedora RPM.
+
+## Phase 10: Native desktop and SELinux prerequisite experiments
+
+- Added fixed desktop application registration before GlobalShortcuts requests. GNOME rejected the previous unregistered connection. Registration is display metadata, never process authentication; older portals may omit the interface, while genuine registration failures remain errors.
+- Real GNOME accepted the registered helper, delivered F8 activation and deactivation, and revoked the helper after a settings rebind. Ctrl+D delivered activation but lost deactivation when Control was released first. GNOME Mutter 50.4 looks up the release using the current modifier mask, so the modified binding is missed after modifier release. Hold dictation cannot be declared generally accepted from the F8 result. See [Mutter key processing](https://github.com/GNOME/mutter/blob/50.4/src/core/keybindings.c) and [GNOME portal forwarding](https://github.com/GNOME/xdg-desktop-portal-gnome/blob/50.0/src/globalshortcuts.c).
+- Added an explicitly invoked disposable-VM SELinux probe. With the same non-root UID, baseline file/socket access succeeded, target-scoped CIL denies removed effective allows and denied operations, the root-managed fixture service still launched, and removing the overlay restored baseline access. Ptrace/pidfd results require successful baselines and matching AVCs; proc inspection remains separately qualified because stock policy suppresses some audit records.
+- The runner bounds unauthorized launch attempts and always attempts every cleanup step. Host-independent regression tests cover verifier rejection and cleanup faults. The sixth real enforcing-VM run passed and removed its fixture service, modules, user and files. This proves synthetic prerequisites only: authenticated releases, Electron role separation, endpoint delegation and complete revocation remain unimplemented. Computer Use remains disabled on Linux.
+
+- Mitigation: GNOME sessions now reject hold setup before portal activation and retain toggle dictation; the gate does not guess from localized descriptions or requested keys. The final helper returned unavailable on the real GNOME host. The native private D-Bus suite passes 13 cases, and Linux contracts pass 151 tests with one platform skip. Both independent Astra medium reviewers cleared the probe and portal changes.
+
+## Phase 11: Descriptor delegation and upstream compatibility
+
+Active: extend the synthetic policy experiment to transferred and inherited file
+descriptors, requiring a working baseline, verified receiver domain, exact-run
+audit evidence, and effective policy checks. A failed child launch must not be
+counted as successful descriptor isolation.
+
+A separate minimal Electron 43.1.1 ARM64 run on the enforcing GNOME Wayland host
+successfully launched a sandboxed renderer and Node utility process. Browser,
+GPU, renderer, network utility and Node utility metrics all used the same
+Electron executable and `unconfined_t` context. The renderer reported seccomp
+mode 2 and no-new-privileges. This is an observational prerequisite, not an
+identity boundary or a complete process inventory: zygotes are not included in
+`app.getAppMetrics()`. A filename or argv role check would not distinguish these
+processes. A future source-domain transition experiment must cover both direct
+utility execs and zygote descendants before any main-process authority is granted.
+
+The [pinned driver gap audit](linux-cua-driver-gap-audit.md) records the additional GNOME extension compatibility and authority boundary, native Wayland opt-in, and action matrix needed before Linux driver admission.
+
+Phase 11 complete within its prerequisite scope: both descriptor baselines read
+the full synthetic token. The enforcing overlay caused SCM_RIGHTS to omit the
+file descriptor and denied an inherited file read after fork plus an explicit
+outgoing domain change. Exact receiver/private-file AVCs and loaded policy
+subtraction are required. The exec path failed before receiver main and is
+explicitly not counted. Restoration and cleanup passed on run 11. Both Astra
+medium reviewers independently cleared the final source and evidence; 22 focused
+probe tests and 156 Linux contract tests passed (one platform skip).
+
+Next: isolate Electron main and child roles in a separate disposable fixture.
+This must preserve a working sandboxed renderer and utilities while distinguishing
+source-domain transitions, without treating argv, filenames or the stock
+sandbox as authenticated process identity. Production Computer Use stays disabled.
+
+## Phase 12: Electron process-role transition experiment
+
+Active, separate disposable fixture. The candidate launches a root-owned copy
+from a fixed system unit into a main domain, then transitions its Electron execs
+into a child domain. Acceptance must also cover a command launched by main:
+Aiden's scheduled scripts use `child_process.spawn` and its Linux terminal uses
+node-pty's `forkpty` followed by `execvp`. A policy covering only Electron's own
+executable could leave a shell in the main domain.
+
+[SELinux fork/exec semantics](https://github.com/SELinuxProject/selinux-notebook/blob/main/src/computing_security_contexts.md)
+state that fork inherits the parent's context; exec transitions are distinct.
+Therefore a post-launch domain snapshot cannot prove every child was isolated
+from its first instruction. Trusted pre-exec native paths, anonymous channel
+ownership, interpreted payloads, loader inputs and JIT need separate review.
+The role fixture may retain broad permissions to measure feasibility, but it
+must not advertise those permissions as the production security policy.
+
+Hosted CI at phase-11 commit `23917870` passed every lane (run `34699940335`),
+including Fedora RPM, both Linux architectures, macOS Electron, shared
+verification and Android.
+
+Phase 12 candidate experiment passed on the enforcing host. Main plus ten
+observed descendants were collected, including zygotes, renderer, GPU, network,
+Node utility and GTK image-loader helpers. Renderer computation, loopback
+network, utility computation, shell and command checks passed; renderer seccomp
+and NoNewPrivs remained enabled. Both unauthorized entry attempts returned
+exactly 126. The main-to-child NNP transition permission is required to avoid
+silently retaining main's context.
+
+The main and Node utility actually moved to a GNOME application scope while
+other descendants remained in the original system service. The fixture uses
+bounded, domain-scoped pidfd cleanup, opening handles before identity reads;
+this is experimental cleanup, not production containment. Final cleanup restored
+the module inventory and kept SELinux enforcing. Both independent Astra medium
+reviews cleared the candidate; 25 focused tests and 181 Linux contract tests
+passed, with one platform skip. Final lint-only imports/comments also pass lint
+and the focused suite. Immutable payload, pre-exec fork identity, JIT and protected
+channel authority remain unproved; Linux Computer Use remains disabled.
+
+## Phase 13: Protected IPC object permissions
+
+Active: preserve a working generic socket roundtrip while rejecting transfer or
+use of a separately labeled protected socket. Both descriptors must have the
+same trusted creator domain, and receiver `fd/use` must remain allowed, so a
+coarse creator-domain denial cannot explain the protected result. Require exact
+synthetic token/ACK baselines, a working generic channel under enforcement,
+matching protected-socket AVCs, effective policy checks and restoration.
+
+A separate live socketpair/fork observation on the same Fedora host confirmed
+that SO_PEERCRED and SO_PEERPIDFD identify the socketpair creator even while its
+child holds the other endpoint. The receipt records creator PID 19085 and holder
+PID 19113. These APIs must not be interpreted as authenticating the current
+holder after inheritance or delegation. Native launch ownership and enforced
+endpoint access remain separate requirements; no production broker admission
+has been implemented from this observation.
+
+Phase 13 passed its scoped experiment. With creator fd/use still allowed, the
+generic socket completed token read and ACK write under the overlay. The
+protected endpoint was omitted with MSG_CTRUNC; exact receiver socket-object
+AVCs and effective read/write removal are required. Restoration and cleanup
+passed. Both Astra medium reviewers independently cleared source and live
+receipts. Thirteen focused tests, 194 Linux contract tests (one platform skip),
+and scoped lint passed; both new suites are registered in package.json.
+Inherited endpoints, pipes, Electron integration and current-holder authentication
+remain separate work; no Computer Use admission was enabled.
+
+## Phase 14: Combined Electron roles and protected socket transfer
+
+Active: perform SCM_RIGHTS receipt inside actual Electron main and Node utility
+processes through a small N-API v8 fixture addon. One native sender creates all
+four pairs. Main must complete generic and protected token/ACK roundtrips; the
+utility must complete generic IPC while the protected endpoint is omitted with
+an exact enforcing socket-object AVC. Retain the existing sandboxed renderer,
+network, shell, command and complete observed-role checks. This combines the
+previous socket and role experiments without enabling production Computer Use.
+
+The ARM64 pinned Cua binary separately passed an isolated `--version` startup
+check without network or desktop sockets; see the driver gap audit. This is not
+a desktop acceptance result.
+
+Production package provenance is a separate missing prerequisite. The repository
+is currently public (verified through GitHub), so repository-bound GitHub build
+attestations are an available candidate without introducing a new private release
+key. Existing Linux release jobs do not yet attest their packages. A future
+installer must verify the exact repository, release workflow and approved source
+ref, not just a matching digest or arbitrary workflow attestation.
+
+Phase 14 passed with both independent Astra medium reviews clear. All four
+actual Electron IPC cells and prior sandboxed renderer/role checks passed in
+final Fedora run 4. Cleanup returned zero and restored the module inventory
+under enforcing SELinux. The 41 focused tests, 210 Linux contract tests (one
+platform skip), scoped lint and diff checks passed. Evidence is retained at
+`/tmp/aiden-fedora-parity-vm/phase14-electron-ipc-evidence`. This establishes
+selective SCM_RIGHTS receipt in the tested Electron processes; inherited
+endpoints, pipes, current-holder authentication, payload/JIT integrity and
+production admission remain open. Exact phase-13 head `88592efa` passed every
+hosted CI lane in run `34701326411`.
+
+## Phase 15: Linux release package provenance
+
+Active: add repository/workflow-bound build attestations for verified Linux
+release packages, with a pinned official action and focused workflow contracts.
+Verification guidance must bind the expected source commit and main ref. This
+prepares future releases; it neither publishes a release now nor authenticates
+an installed process or enables Computer Use.
+
+Phase 15 implementation passed both Astra medium reviews. The Linux job is
+main-only, uses a pinned official action, grants only read access plus OIDC and
+attestation writes, and attests every staged package/feed class after verification.
+Seventy-two branding/release tests, YAML parsing, scoped lint and diff checks
+passed. No release was triggered. Hosted signing and positive/negative package
+verification remain acceptance gates for a future approved main release.
+
+## Phase 16: Packaged Linux payload inventory
+
+Complete: compute and verify a deterministic external inventory of a finalized,
+trusted, quiescent Linux payload tree. Include every file and directory, modes,
+sizes and content hashes; reject links and special files. Keep the inventory
+strictly outside the tree, with no excluded payload entries. The future managed
+installer must derive it from authenticated release bytes and protect its storage.
+This standalone component is not an authentication or race-proof installation
+boundary, and production Computer Use remains disabled.
+
+The initial afterPack proposal was rejected after inspecting electron-builder:
+it adds target-specific files later, while installation may change sandbox mode.
+Finalized extracted payloads must be measured instead of hiding those changes
+with exclusions.
+
+Phase 16 passed both independent Astra medium reviews, 26 focused tests and
+236 Linux contract tests (one platform skip), plus scoped lint. The reusable
+module matched all 316 entries (279 files) from an extracted ARM64 RPM on Fedora
+and rejected eight changes covering Electron, ASAR, snapshot, library, addon,
+mode, extra file and missing file. Restoration matched; SELinux remained enforcing.
+The receipt and module digest are retained in
+`/tmp/aiden-fedora-parity-vm/phase16-receipt.json`. The package is a local fixture
+from an earlier build, not an authenticated release or current app acceptance.
+
+## Phase 17: Native managed-generation staging
+
+Complete within its local-staging scope: copy a supplied finalized payload into
+fresh root-managed inodes using fd-relative traversal, validate the copied bytes
+against the complete inventory, and publish a new generation atomically without
+replacement. No active pointer or execution is part of this phase. Release
+authentication remains a separate mandatory prerequisite for future production
+admission.
+
+The native Rust stager accepts only a protected `local-staging-only` approval,
+requires host root and SELinux enforcing, pins trusted paths with `openat2`, and
+rejects links, hardlinks, special files, unsafe ownership or modes, ACLs,
+capabilities and unsupported extended attributes. It copies bytes into fresh
+root-owned inodes under the store's exact SELinux creation context, rechecks the
+complete inventory and metadata, writes an honest receipt, restores the process
+creation context, and publishes by no-replace rename plus parent fsync. Failures
+remove only the private temporary generation through retained descriptors;
+rollback is armed immediately after the initial `mkdirat`.
+
+Both independent Astra medium reviews cleared the final patch after finding and
+verifying the initial-directory rollback fix. Seven Rust tests, the release
+build, and the ignored root integration passed on Fedora 44 with SELinux
+enforcing. The root suite covers mutable-source races, path replacement, device
+nodes without payload reads, links, ACLs, capabilities, ownership and mode
+violations, overlapping paths, publication collisions, injected failures and
+SELinux filename transitions. No temporary staging directory remained. The
+stager also reproduced the 316-entry Phase 16 RPM payload in a preserved
+generation and an independent inventory verification matched every byte.
+
+This phase does not authenticate a release, select or launch an active
+generation, make payload bytes kernel-immutable, constrain JIT or host-library
+loading, authenticate live process incarnations, or implement the GNOME capture
+and input driver. Its receipt records those limits as false. Production Linux
+Computer Use remains disabled.
+
+## Phase 18: Cross-platform installer delivery
+
+Complete after two independent GPT-6 Astra medium reviews: a standalone manual
+workflow produces verified Linux x64 and arm64 AppImage, DEB and RPM artifacts
+without publishing a release. A POSIX installer selects the exact package for
+macOS or Linux, verifies the release checksum, requires GitHub build-provenance
+verification before Linux installation, and verifies Apple identity plus
+Gatekeeper acceptance before macOS installation. DEB/RPM preserve
+package-manager ownership and a user-owned writable AppImage is the portable
+fallback.
+
+The installer uses private, exclusive staging on both platforms. macOS app
+replacement preserves the prior app until the promoted copy passes signature,
+Gatekeeper, bundle, version, signing-team and architecture checks; rollback
+state is armed before filesystem moves, and a failed restore retains the
+transaction directory for recovery. Focused checksum, provenance, hostile-path,
+rollback-failure and signal-interruption regressions pass. The repository's
+branding/release and Linux contract suites also pass.
+
+The current public release contains only the arm64 Mac artifacts. Intel macOS
+selection must fail until the release pipeline publishes and validates a signed
+x64 DMG; it must never substitute the arm64 DMG. The standalone Linux workflow
+does not publish releases, and its Actions artifacts are not accepted by the
+release installer. Linux Computer Use production admission remains separate.
+
+## Phase 19: Pull-request closure remediation
+
+Complete after two independent GPT-6 Astra medium reviews. Linux subreaper
+cleanup is bounded, all platform policy tests are registered, and fail-closed
+SELinux security-metadata behavior has an explicit support limit. Portal
+dictation replacement is transactional, and the portal accepts missing
+display text, hold setup is advertised only when desktop metadata is
+discoverable, and Fedora diagnostic journal windows are timezone-independent.
+
+Focused shortcut, portal, native runner and Electron-role tests pass. Linux
+contracts pass 152 tests with two expected platform skips, lint, type checking,
+branding/release tests and the complete repository test suite pass. A root-run
+regression on the Fedora 44 SELinux Enforcing VM confirms that a matching-owner
+helper preserves a non-default label. A separate denied file-capability case
+returns an I/O failure while preserving the original bytes and capability.
+Hosted exact-head validation and automated-review thread closure remain the
+final pull-request delivery gate.
diff --git a/docs/releasing.md b/docs/releasing.md
index f196bcf6..a940223f 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -78,6 +78,61 @@ authority. Ordinary model reads and application startup remain offline.
Local `npm run dist` builds do not embed a feed or perform automatic update checks. The release
workflow opts in with `AIDEN_ENABLE_AUTO_UPDATES=1`.
+## Linux package provenance
+
+The Linux release matrix attests its verified AppImage, DEB, RPM and update-feed
+bytes using the pinned official [`actions/attest`](https://github.com/actions/attest)
+action. Attestation runs only for a new declared release from `refs/heads/main`,
+after package and GUI checks, and before staging. The Linux job is skipped for
+manual dispatches from other refs; its dependent publication job cannot publish
+a release through that path. The action generates SLSA build
+provenance using GitHub's OIDC identity and uploads it to the repository's
+attestation API. This adds no separately managed signing key.
+
+The publisher also includes the reviewed repository-root `install.sh` in the
+release and in `SHA256SUMS`. The script selects exact versioned artifacts; it
+does not install Actions artifacts from the standalone manual Linux-installer
+workflow. That manual workflow is for downloading and testing unpromoted builds
+and has no release-publication permission.
+
+For a future release produced by this workflow, independently select the approved
+40-character source commit from the reviewed release record. Verify each downloaded
+file with a trusted, current GitHub CLI. Use an absolute artifact path and substitute
+the approved commit for the placeholder; do not take the expected commit from the
+unverified bundle or package:
+
+```sh
+approved_commit=REPLACE_WITH_APPROVED_40_CHARACTER_COMMIT
+artifact=/absolute/path/to/downloaded-package.rpm
+gh attestation verify "$artifact" \
+ --hostname github.com \
+ --repo sambitcreate/aiden-agent \
+ --signer-repo sambitcreate/aiden-agent \
+ --signer-workflow sambitcreate/aiden-agent/.github/workflows/release.yml \
+ --cert-identity https://github.com/sambitcreate/aiden-agent/.github/workflows/release.yml@refs/heads/main \
+ --cert-oidc-issuer https://token.actions.githubusercontent.com \
+ --source-ref refs/heads/main \
+ --source-digest "$approved_commit" \
+ --signer-digest "$approved_commit" \
+ --deny-self-hosted-runners \
+ --predicate-type https://slsa.dev/provenance/v1 \
+ --format json
+```
+
+Require successful verification before trusting the downloaded bytes. A checksum
+alone is not publisher authentication. Existing releases without these attestations
+will not pass this policy. Verification may access GitHub and Sigstore trust data;
+this is an explicit operator action, not app startup or background traffic. See
+[GitHub CLI verification policy](https://cli.github.com/manual/gh_attestation_verify).
+
+This authenticates a build's artifact digest and workflow identity. It does not
+establish a root-managed immutable installation, authenticate a live process, prevent
+rollback to an otherwise approved old build, or enable Linux Computer Use. Positive
+release acceptance still requires a future approved main release: verify the exact
+packages and feeds, then confirm that modified bytes, a wrong source commit, and
+an attestation from a different workflow or ref are rejected. Local workflow tests
+cannot substitute for that hosted signing acceptance.
+
## Physical Mac acceptance on a personal Mac Studio
A spare Mac is not required. For now, keep pull-request CI, release builds, signing,
@@ -125,8 +180,9 @@ its history scan, before changing visibility.
4. Keep `RELEASES_ENABLED` unset while configuring the environment. Set the non-secret
repository variable to `true` only when the first public beta is approved. The workflow uses
- its scoped `GITHUB_TOKEN` with `contents: write`; no separate release-repository token exists.
-5. Trigger `Release macOS` manually for the first release or push a reviewed commit to `main`.
+ its scoped `GITHUB_TOKEN`; publication has `contents: write`, while the Linux build job
+ overrides that with read access plus attestation permissions. No separate release-repository token exists.
+5. Trigger `Release desktop` manually from `main` for the first release or push a reviewed commit to `main`.
6. Install the published DMG, then publish one higher version and verify the installed app
downloads it, reports it ready, and installs it through the in-app Update and Restart action.
For the 0.27 recovery release, repeat this from an installed 0.27.0 build because older
diff --git a/install.sh b/install.sh
new file mode 100755
index 00000000..b5b0fefc
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,401 @@
+#!/bin/sh
+
+set -eu
+
+repository="sambitcreate/aiden-agent"
+release_base="https://github.com/${repository}/releases"
+requested_version="${AIDEN_VERSION:-}"
+requested_format="auto"
+expected_commit="${AIDEN_EXPECTED_COMMIT:-}"
+user_install=false
+download_only=""
+print_plan=false
+temporary=""
+mounted=false
+mountpoint=""
+elevate=""
+mac_stage_parent=""
+mac_backup=""
+mac_destination=""
+mac_transaction=false
+mac_had_existing=false
+mac_promoted=false
+linux_stage_parent=""
+
+usage() {
+ cat <<'EOF'
+Aiden Agent installer
+
+Usage: sh install.sh [options]
+
+Options:
+ --version VERSION Install a specific released version.
+ --format FORMAT auto, dmg, deb, rpm, or appimage.
+ --expected-commit SHA Require this reviewed release commit on Linux.
+ --user Use ~/Applications on macOS or AppImage on Linux.
+ --download-only DIRECTORY Verify and copy the installer without installing it.
+ --plan Print the selected artifact without downloading it.
+ -h, --help Show this help.
+
+Examples:
+ curl -fsSL https://raw.githubusercontent.com/sambitcreate/aiden-agent/main/install.sh | sh
+ curl -fsSL https://raw.githubusercontent.com/sambitcreate/aiden-agent/main/install.sh | sh -s -- --version 0.41.0
+EOF
+}
+
+fail() {
+ printf 'Aiden installer: %s\n' "$*" >&2
+ exit 1
+}
+
+has() {
+ command -v "$1" >/dev/null 2>&1
+}
+
+cleanup() {
+ preserve_mac_stage=false
+ if [ "$mac_transaction" = true ] && [ -n "$mac_destination" ]; then
+ if [ "$mac_promoted" = true ] && { [ -e "$mac_destination" ] || [ -L "$mac_destination" ]; }; then
+ if ! privilege /bin/rm -rf "$mac_destination"; then
+ preserve_mac_stage=true
+ fi
+ fi
+ if [ "$mac_had_existing" = true ] && { [ -e "$mac_backup" ] || [ -L "$mac_backup" ]; }; then
+ if [ -e "$mac_destination" ] || [ -L "$mac_destination" ]; then
+ preserve_mac_stage=true
+ elif ! privilege /bin/mv "$mac_backup" "$mac_destination"; then
+ preserve_mac_stage=true
+ fi
+ fi
+ fi
+ if [ -n "$mac_stage_parent" ] && [ -d "$mac_stage_parent" ]; then
+ if [ "$preserve_mac_stage" = true ]; then
+ printf 'Aiden installer: rollback failed; preserved recovery files at %s\n' "$mac_stage_parent" >&2
+ else
+ privilege /bin/rm -rf "$mac_stage_parent" || true
+ fi
+ fi
+ if [ -n "$linux_stage_parent" ] && [ -d "$linux_stage_parent" ]; then
+ rm -rf -- "$linux_stage_parent"
+ fi
+ if [ "$mounted" = true ] && [ -n "$mountpoint" ]; then
+ /usr/bin/hdiutil detach "$mountpoint" >/dev/null 2>&1 ||
+ /usr/bin/hdiutil detach -force "$mountpoint" >/dev/null 2>&1 || true
+ fi
+ if [ -n "$temporary" ] && [ -d "$temporary" ]; then
+ rm -rf -- "$temporary"
+ fi
+}
+
+privilege() {
+ if [ "$elevate" = "sudo" ]; then
+ sudo "$@"
+ else
+ "$@"
+ fi
+}
+
+backup_existing_macos_app() {
+ # Arm recovery before the external move so a signal cannot hide the backup.
+ mac_had_existing=true
+ privilege /bin/mv "$mac_destination" "$mac_backup"
+}
+
+promote_macos_app() {
+ # Arm rollback before the external move so a signal cannot leave a partial promotion.
+ mac_promoted=true
+ privilege /bin/mv "$staged_app" "$mac_destination"
+}
+
+trap cleanup EXIT
+trap 'exit 1' HUP INT TERM
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --version)
+ [ "$#" -ge 2 ] || fail "--version requires a value."
+ requested_version=$2
+ shift 2
+ ;;
+ --format)
+ [ "$#" -ge 2 ] || fail "--format requires a value."
+ requested_format=$2
+ shift 2
+ ;;
+ --expected-commit)
+ [ "$#" -ge 2 ] || fail "--expected-commit requires a value."
+ expected_commit=$2
+ shift 2
+ ;;
+ --user)
+ user_install=true
+ shift
+ ;;
+ --download-only)
+ [ "$#" -ge 2 ] || fail "--download-only requires a directory."
+ download_only=$2
+ shift 2
+ ;;
+ --plan)
+ print_plan=true
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ fail "unknown option: $1"
+ ;;
+ esac
+done
+
+case "$(uname -s)" in
+ Darwin) os="macos" ;;
+ Linux) os="linux" ;;
+ *) fail "unsupported operating system: $(uname -s)" ;;
+esac
+
+case "$(uname -m)" in
+ arm64|aarch64) arch="arm64" ;;
+ x86_64|amd64) arch="x64" ;;
+ *) fail "unsupported architecture: $(uname -m)" ;;
+esac
+
+if [ "$os" = "macos" ] && [ "$arch" = "x64" ]; then
+ if [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || printf '0')" = "1" ]; then
+ arch="arm64"
+ fi
+fi
+
+if [ -z "$requested_version" ]; then
+ has curl || fail "curl is required."
+ latest_url=$(curl --proto '=https' --tlsv1.2 --fail --silent --show-error \
+ --location --retry 3 --output /dev/null --write-out '%{url_effective}' \
+ "${release_base}/latest") || fail "could not resolve the latest release."
+ requested_version=${latest_url##*/v}
+fi
+
+if ! printf '%s\n' "$requested_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+)*$'; then
+ fail "invalid release version: $requested_version"
+fi
+
+case "$requested_format" in
+ auto|dmg|deb|rpm|appimage) ;;
+ *) fail "unsupported format: $requested_format" ;;
+esac
+
+format=$requested_format
+if [ "$os" = "macos" ]; then
+ [ "$format" = "auto" ] && format="dmg"
+ [ "$format" = "dmg" ] || fail "macOS requires the dmg format."
+ asset="Aiden-Agent-Beta-${requested_version}-${arch}.dmg"
+else
+ if [ "$format" = "auto" ]; then
+ os_release=""
+ [ -r /etc/os-release ] && os_release=$(cat /etc/os-release)
+ if printf '%s\n' "$os_release" | grep -Eiq '(^|[=[:space:]\"])(debian|ubuntu|mint)([[:space:]\"]|$)' && has apt-get; then
+ format="deb"
+ elif printf '%s\n' "$os_release" | grep -Eiq '(^|[=[:space:]\"])(fedora|rhel|centos|rocky|almalinux)([[:space:]\"]|$)' && has dnf; then
+ format="rpm"
+ else
+ format="appimage"
+ fi
+ fi
+ case "$format:$arch" in
+ deb:x64) asset="Aiden-Agent-${requested_version}-amd64-linux.deb" ;;
+ deb:arm64) asset="Aiden-Agent-${requested_version}-arm64-linux.deb" ;;
+ rpm:x64) asset="Aiden-Agent-${requested_version}-x86_64-linux.rpm" ;;
+ rpm:arm64) asset="Aiden-Agent-${requested_version}-aarch64-linux.rpm" ;;
+ appimage:x64) asset="Aiden-Agent-${requested_version}-x86_64-linux.AppImage" ;;
+ appimage:arm64) asset="Aiden-Agent-${requested_version}-arm64-linux.AppImage" ;;
+ *) fail "Linux supports deb, rpm, or appimage." ;;
+ esac
+ if [ "$user_install" = true ] && [ "$format" != "appimage" ]; then
+ format="appimage"
+ case "$arch" in
+ x64) asset="Aiden-Agent-${requested_version}-x86_64-linux.AppImage" ;;
+ arm64) asset="Aiden-Agent-${requested_version}-arm64-linux.AppImage" ;;
+ esac
+ fi
+fi
+
+if [ "$print_plan" = true ]; then
+ printf 'os=%s\narch=%s\nformat=%s\nversion=%s\nasset=%s\n' \
+ "$os" "$arch" "$format" "$requested_version" "$asset"
+ exit 0
+fi
+
+has curl || fail "curl is required."
+temporary=$(mktemp -d "${TMPDIR:-/tmp}/aiden-install.XXXXXX") ||
+ fail "could not create a private temporary directory."
+chmod 700 "$temporary"
+asset_path="$temporary/$asset"
+checksums="$temporary/SHA256SUMS"
+asset_url="${release_base}/download/v${requested_version}/${asset}"
+checksum_url="${release_base}/download/v${requested_version}/SHA256SUMS"
+
+printf 'Downloading %s\n' "$asset"
+curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location --retry 3 \
+ --output "$asset_path" "$asset_url" ||
+ fail "the selected installer is not published for $os/$arch ($asset)."
+curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location --retry 3 \
+ --output "$checksums" "$checksum_url" || fail "could not download SHA256SUMS."
+
+checksum_lines=$(awk -v name="$asset" '$2 == name { count += 1; digest = $1 } END { if (count == 1) print digest }' "$checksums")
+if ! printf '%s\n' "$checksum_lines" | grep -Eq '^[0-9a-f]{64}$'; then
+ fail "SHA256SUMS does not contain one exact checksum for $asset."
+fi
+if has sha256sum; then
+ actual_checksum=$(sha256sum "$asset_path" | awk '{print $1}')
+elif has shasum; then
+ actual_checksum=$(shasum -a 256 "$asset_path" | awk '{print $1}')
+else
+ fail "sha256sum or shasum is required."
+fi
+[ "$actual_checksum" = "$checksum_lines" ] || fail "installer checksum verification failed."
+
+if [ "$os" = "linux" ]; then
+ has gh || fail "GitHub CLI is required to authenticate Linux release provenance. Install gh and retry."
+ if [ -z "$expected_commit" ]; then
+ expected_commit=$(gh api "repos/${repository}/releases/tags/v${requested_version}" --jq .target_commitish) ||
+ fail "could not resolve the release source commit."
+ printf 'Using release-record commit %s; pass --expected-commit for an independently reviewed pin.\n' "$expected_commit"
+ fi
+ if ! printf '%s\n' "$expected_commit" | grep -Eq '^[0-9a-f]{40}$'; then
+ fail "the expected release commit must be 40 lowercase hexadecimal characters."
+ fi
+ gh attestation verify "$asset_path" \
+ --hostname github.com \
+ --repo "$repository" \
+ --signer-repo "$repository" \
+ --signer-workflow "${repository}/.github/workflows/release.yml" \
+ --cert-identity "https://github.com/${repository}/.github/workflows/release.yml@refs/heads/main" \
+ --cert-oidc-issuer https://token.actions.githubusercontent.com \
+ --source-ref refs/heads/main \
+ --source-digest "$expected_commit" \
+ --signer-digest "$expected_commit" \
+ --deny-self-hosted-runners \
+ --predicate-type https://slsa.dev/provenance/v1 \
+ --format json >/dev/null || fail "Linux release provenance verification failed."
+fi
+
+verify_macos_app() {
+ candidate=$1
+ [ -d "$candidate" ] && [ ! -L "$candidate" ] || fail "DMG does not contain a regular Aiden Agent.app."
+ /usr/bin/codesign --verify --strict --verbose=2 "$candidate" >/dev/null 2>&1 ||
+ fail "Aiden Agent signature verification failed."
+ /usr/sbin/spctl --assess --type execute --verbose=2 "$candidate" >/dev/null 2>&1 ||
+ fail "Gatekeeper rejected Aiden Agent."
+ candidate_id=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$candidate/Contents/Info.plist")
+ candidate_version=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$candidate/Contents/Info.plist")
+ candidate_team=$(/usr/bin/codesign -dv --verbose=4 "$candidate" 2>&1 | sed -n 's/^TeamIdentifier=//p')
+ [ "$candidate_id" = "com.sambitcreate.aiden-agent" ] || fail "unexpected macOS bundle identifier."
+ [ "$candidate_version" = "$requested_version" ] || fail "unexpected macOS bundle version."
+ [ "$candidate_team" = "5WP229CBB8" ] || fail "unexpected macOS signing team."
+ candidate_arches=$(/usr/bin/lipo -archs "$candidate/Contents/MacOS/Aiden Agent")
+ expected_arch=$( [ "$arch" = x64 ] && printf x86_64 || printf arm64 )
+ case " $candidate_arches " in
+ *" $expected_arch "*) ;;
+ *) fail "the DMG executable does not support this Mac architecture." ;;
+ esac
+}
+
+if [ "$os" = "macos" ]; then
+ has codesign || fail "codesign is required."
+ has spctl || fail "spctl is required."
+ has diskutil || fail "diskutil is required."
+ has hdiutil || fail "hdiutil is required."
+ mountpoint="$temporary/mount"
+ mkdir "$mountpoint"
+ /usr/sbin/diskutil image attach --readOnly --nobrowse --mountPoint "$mountpoint" "$asset_path" >/dev/null
+ mounted=true
+ source_app="$mountpoint/Aiden Agent.app"
+ verify_macos_app "$source_app"
+fi
+
+if [ -n "$download_only" ]; then
+ mkdir -p "$download_only"
+ cp "$asset_path" "$download_only/$asset"
+ printf 'Verified installer copied to %s\n' "$download_only/$asset"
+ exit 0
+fi
+
+if [ "$os" = "linux" ]; then
+ case "$format" in
+ deb)
+ has apt-get || fail "apt-get is required to install the selected DEB."
+ sudo apt-get install -y "$asset_path"
+ ;;
+ rpm)
+ has dnf || fail "dnf is required to install the selected RPM."
+ sudo dnf install -y "$asset_path"
+ ;;
+ appimage)
+ app_dir="$HOME/.local/share/aiden-agent"
+ bin_dir="$HOME/.local/bin"
+ mkdir -p "$app_dir" "$bin_dir"
+ app_destination="$app_dir/Aiden-Agent.AppImage"
+ launcher="$bin_dir/aiden-agent"
+ if [ -L "$app_destination" ] || [ -d "$app_destination" ]; then
+ fail "existing AppImage destination is not a regular file."
+ fi
+ if [ -d "$launcher" ] && [ ! -L "$launcher" ]; then
+ fail "existing aiden-agent launcher is a directory."
+ fi
+ linux_stage_parent=$(mktemp -d "$app_dir/.install.XXXXXX") ||
+ fail "could not create private AppImage staging."
+ chmod 700 "$linux_stage_parent"
+ staged="$linux_stage_parent/Aiden-Agent.AppImage"
+ cp "$asset_path" "$staged"
+ chmod 755 "$staged"
+ mv -fT "$staged" "$app_destination"
+ rmdir "$linux_stage_parent"
+ linux_stage_parent=""
+ ln -sfnT "$app_destination" "$launcher"
+ printf 'Installed Aiden Agent at %s\n' "$app_destination"
+ printf 'Run %s/aiden-agent or add that directory to PATH.\n' "$bin_dir"
+ ;;
+ esac
+ exit 0
+fi
+
+if has pgrep && pgrep -x 'Aiden Agent' >/dev/null 2>&1; then
+ fail "quit Aiden Agent before installing an update."
+fi
+
+if [ "$user_install" = true ]; then
+ install_parent="$HOME/Applications"
+ mkdir -p "$install_parent"
+else
+ install_parent="/Applications"
+ elevate="sudo"
+fi
+mac_destination="$install_parent/Aiden Agent.app"
+if [ -e "$mac_destination" ] || [ -L "$mac_destination" ]; then
+ [ ! -L "$mac_destination" ] && [ -d "$mac_destination" ] || fail "existing destination is not an application directory."
+ existing_id=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$mac_destination/Contents/Info.plist" 2>/dev/null || true)
+ [ "$existing_id" = "com.sambitcreate.aiden-agent" ] || fail "existing destination is not Aiden Agent."
+fi
+
+mac_stage_parent=$(privilege /usr/bin/mktemp -d "$install_parent/.aiden-install.XXXXXX")
+mac_transaction=true
+privilege /bin/chmod 0711 "$mac_stage_parent"
+staged_app="$mac_stage_parent/new.app"
+mac_backup="$mac_stage_parent/previous.app"
+privilege /usr/bin/ditto "$source_app" "$staged_app"
+verify_macos_app "$staged_app"
+if [ -e "$mac_destination" ]; then
+ backup_existing_macos_app
+fi
+if ! promote_macos_app; then
+ fail "could not promote the staged application."
+fi
+verify_macos_app "$mac_destination"
+mac_transaction=false
+if [ -e "$mac_backup" ]; then
+ privilege /bin/rm -rf "$mac_backup"
+fi
+privilege /bin/rmdir "$mac_stage_parent"
+mac_stage_parent=""
+printf 'Installed Aiden Agent %s at %s\n' "$requested_version" "$mac_destination"
diff --git a/ios/APP_STORE_METADATA.md b/ios/APP_STORE_METADATA.md
index 0606c99b..50ef60a2 100644
--- a/ios/APP_STORE_METADATA.md
+++ b/ios/APP_STORE_METADATA.md
@@ -30,15 +30,15 @@ Developer Tools is the closest current Apple category: Apple describes it as app
Description draft:
-> Aiden On The Go is the native iPhone and iPad companion for Aiden Agent on your Mac. Pair directly with a Mac you control over your local network or Tailscale, then continue Aiden chats and manage workspaces from your mobile device.
+> Aiden On The Go is the native iPhone and iPad companion for Aiden Agent on your macOS or Linux desktop. Pair directly with a desktop you control over your local network or Tailscale, then continue Aiden chats and manage workspaces from your mobile device.
>
-> Review conversations, stream responses, handle approval requests, inspect workspace files, work with supported Git flows, and manage scheduled tasks. App Intents provide quick navigation, Live Activities show bounded run status, and optional voice dictation can use the device's native recognizer or a local speech model on your paired Mac. Read-aloud stays on device.
+> Review conversations, stream responses, handle approval requests, inspect workspace files, work with supported Git flows, and manage scheduled tasks. App Intents provide quick navigation, Live Activities show bounded run status, and optional voice dictation can use the device's native recognizer or a local speech model on your paired desktop. Read-aloud stays on device.
>
-> Your Mac remains the execution authority. Remote Access is off by default, each mobile device uses a revocable credential, and provider credentials remain on the Mac.
+> Your desktop remains the execution authority. Remote Access is off by default, each mobile device uses a revocable credential, and provider credentials remain on the desktop.
Review-notes draft:
-> Aiden On The Go requires the companion Aiden Agent desktop app. In Aiden Agent, open Settings → Remote Access, enable the listener, and open a short-lived pairing session. On the iPhone or iPad, scan the pairing QR code or use the approved manual connection flow. The reviewer must be supplied with a reachable review Mac and any required setup instructions; no production credential is embedded in the app.
+> Aiden On The Go requires the companion Aiden Agent desktop app. In Aiden Agent, open Settings → Remote Access, enable the listener, and open a short-lived pairing session. On the iPhone or iPad, scan the pairing QR code or use the approved manual connection flow. The reviewer must be supplied with a reachable review desktop and any required setup instructions; no production credential is embedded in the app.
## Age-rating questionnaire draft
@@ -75,11 +75,11 @@ Current Apple references:
Evidence for that answer in the current distribution candidate:
- The app contains no analytics, advertising, crash-reporting, account, or Aiden-hosted relay SDK.
-- Pairing credentials and custom headers stay in Keychain. Cached chats/settings stay on the user's device; authoritative chats/files remain on the Mac the user pairs.
+- Pairing credentials and custom headers stay in Keychain. Cached chats/settings stay on the user's device; authoritative chats/files remain on the desktop the user pairs.
- QR camera frames are processed for pairing and are not uploaded to Aiden's developer.
-- Dictation uses either Apple’s native recognizer or, only when the person selects **Paired Mac**, a bounded recording sent directly over the authenticated pinned-TLS connection to the Mac-local Parakeet model. Neither endpoint retains that recording. Read-aloud uses Apple system frameworks locally. Aiden's developer operates no speech collection service.
-- Photos/files selected by the user are sent directly to their paired Mac and may then be sent to model providers the user configured on that Mac. Aiden's developer cannot access them. Provider processing remains governed by each selected provider and should be described in the public policy.
-- Optional Bot image generation uses Apple's system Image Playground on supported devices and may use Private Cloud Compute. Aiden disables person/Photos personalization and supplies only the visible Bot name and purpose as starting concepts. Aiden's developer runs no image-generation or proxy service and cannot access those concepts, rejected candidates, or results. When the person chooses **Use this image**, the app sends only that normalized image directly to the paired Mac, which stores the canonical Bot photo.
+- Dictation uses either Apple’s native recognizer or, only when the person selects **Paired desktop**, a bounded recording sent directly over the authenticated pinned-TLS connection to the desktop-local Parakeet model. Neither endpoint retains that recording. Read-aloud uses Apple system frameworks locally. Aiden's developer operates no speech collection service.
+- Photos/files selected by the user are sent directly to their paired desktop and may then be sent to model providers the user configured there. Aiden's developer cannot access them. Provider processing remains governed by each selected provider and should be described in the public policy.
+- Optional Bot image generation uses Apple's system Image Playground on supported devices and may use Private Cloud Compute. Aiden disables person/Photos personalization and supplies only the visible Bot name and purpose as starting concepts. Aiden's developer runs no image-generation or proxy service and cannot access those concepts, rejected candidates, or results. When the person chooses **Use this image**, the app sends only that normalized image directly to the paired desktop, which stores the canonical Bot photo.
- Local Network or Tailscale traffic goes directly to the paired installation. Aiden does not run a central account, synchronization service, analytics endpoint, or proxy.
- Live Activity state is device-local and response excerpts are off by default.
- External transcript media can contact the media host without forwarding Aiden credentials; the public policy should disclose that a remote host can observe an ordinary network request when its media is displayed.
@@ -111,7 +111,7 @@ Current Apple reference: `https://developer.apple.com/help/app-store-connect/ref
- Owner/legal-review and publish `app-store/MOBILE_PRIVACY_SUPPORT_COPY.md` at the resolved privacy URL.
- Make the prepared working support contact visible at the resolved support URL.
- Required physical-iPhone and physical-iPad screenshots captured from the final distribution candidate at accepted dimensions.
-- App Review phone number, notes, and a reachable companion-Mac review environment. The name/email are resolved above.
+- App Review phone number, notes, and a reachable companion-desktop review environment. The name/email are resolved above.
- Availability, price, territories, and release mode.
Do not replace unresolved values with placeholders in App Store Connect.
@@ -120,10 +120,10 @@ Do not replace unresolved values with placeholders in App Store Connect.
Provide these notes only with a reachable, reviewer-safe paired Aiden Agent environment and the final approved contact details:
-1. Pair the iPhone or iPad with the supplied Aiden Agent Mac, then tap the Aiden logo and choose **Bots**.
-2. Accept the one-time Full Access notice or choose **Customize first**. Full Access uses only capabilities already enabled on the paired Mac; Custom can reduce Files, commands, Connections, and Skills.
+1. Pair the iPhone or iPad with the supplied Aiden Agent desktop, then tap the Aiden logo and choose **Bots**.
+2. Accept the one-time Full Access notice or choose **Customize first**. Full Access uses only capabilities already enabled on the paired desktop; Custom can reduce Files, commands, Connections, and Skills.
3. Create a Bot with the built-in semantic avatar, save it, and start a chat. Apple Intelligence is not required for this complete path.
-4. On eligible Apple Intelligence hardware with iOS/iPadOS 18.4 or later, **Create with Apple Intelligence** opens Apple's system Image Playground. Apple controls generation and may use Private Cloud Compute. Aiden disables person/Photos personalization and sends the paired Mac only the image explicitly accepted and saved.
+4. On eligible Apple Intelligence hardware with iOS/iPadOS 18.4 or later, **Create with Apple Intelligence** opens Apple's system Image Playground. Apple controls generation and may use Private Cloud Compute. Aiden disables person/Photos personalization and sends the paired desktop only the image explicitly accepted and saved.
5. On unsupported hardware, including iPhone 13 Pro, the editor honestly keeps the semantic avatar available and has no dead Image Playground action.
Do not claim successful Image Playground generation in review notes until it has passed on supported physical hardware. Do not include pairing credentials, private prompts, paths, or provider secrets in metadata or notes.
diff --git a/ios/AidenOnTheGo/Auth/KeychainStore.swift b/ios/AidenOnTheGo/Auth/KeychainStore.swift
index dd645be7..bad497e3 100644
--- a/ios/AidenOnTheGo/Auth/KeychainStore.swift
+++ b/ios/AidenOnTheGo/Auth/KeychainStore.swift
@@ -7,7 +7,7 @@ protocol KeychainStoring {
func delete(_ key: KeychainStore.Key) throws
// A device credential is scoped to Aiden's stable installation identifier.
- // Switching or removing one paired Mac must never read or clear another
+ // Switching or removing one paired desktop must never read or clear another
// installation's credential.
func save(_ value: String, forKey key: KeychainStore.Key, scope: String) throws
func load(_ key: KeychainStore.Key, scope: String) throws -> String?
diff --git a/ios/AidenOnTheGo/Config/AidenVoiceInput.swift b/ios/AidenOnTheGo/Config/AidenVoiceInput.swift
index ee77d3cd..c1662873 100644
--- a/ios/AidenOnTheGo/Config/AidenVoiceInput.swift
+++ b/ios/AidenOnTheGo/Config/AidenVoiceInput.swift
@@ -7,7 +7,7 @@ enum AidenVoiceInputMode: String, CaseIterable, Identifiable, Codable, Sendable
static let defaultsKey = "aiden.voiceInput.mode"
var id: String { rawValue }
- var title: String { self == .onDevice ? String(localized: "On this device") : String(localized: "Paired Mac") }
+ var title: String { self == .onDevice ? String(localized: "On this device") : String(localized: "Paired desktop") }
static var selected: AidenVoiceInputMode {
AidenVoiceInputMode(rawValue: UserDefaults.standard.string(forKey: defaultsKey) ?? "") ?? .onDevice
diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotCustomAccessFlowView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotCustomAccessFlowView.swift
index b6463a23..20b43993 100644
--- a/ios/AidenOnTheGo/Features/Bots/AidenBotCustomAccessFlowView.swift
+++ b/ios/AidenOnTheGo/Features/Bots/AidenBotCustomAccessFlowView.swift
@@ -361,7 +361,7 @@ struct AidenBotCustomAccessFlowView: View {
} header: {
Text("Bot")
} footer: {
- Text("Custom Access can only reduce what Aiden and your Mac already allow. Change the AI Provider or Model in Edit Bot.")
+ Text("Custom Access can only reduce what Aiden and the paired desktop already allow. Change the AI Provider or Model in Edit Bot.")
}
if isLoadingBot, draft == nil {
@@ -420,7 +420,7 @@ struct AidenBotCustomAccessFlowView: View {
.disabled(!canWrite)
optionSection(
title: "Other Capabilities",
- description: "Optional Aiden capabilities available on this Mac.",
+ description: "Optional Aiden capabilities available on the paired desktop.",
options: catalog.otherCapabilities,
keyPath: \.otherCapabilityIDs
)
@@ -463,7 +463,7 @@ struct AidenBotCustomAccessFlowView: View {
Toggle("Run commands", isOn: shellBinding(catalog))
.disabled(!catalog.shellAvailable && !(draft?.shellEnabled ?? false))
- .accessibilityHint("Allows the bot to use Aiden’s existing shell tool on your Mac.")
+ .accessibilityHint("Allows the bot to use Aiden’s existing shell tool on the paired desktop.")
} header: {
Text("Files and Commands")
} footer: {
@@ -484,7 +484,7 @@ struct AidenBotCustomAccessFlowView: View {
)
return Section {
if visibleOptions.isEmpty {
- Text("None configured on this Mac")
+ Text("None configured on the paired desktop")
.foregroundStyle(palette.secondary)
} else {
ForEach(visibleOptions) { option in
@@ -702,7 +702,7 @@ struct AidenBotCustomAccessFlowView: View {
capturedContext == request.context,
selectedBotID == request.botID else { return }
guard let loadedDraft = AidenBotCustomAccessDraft(access: detail.access, catalog: catalog) else {
- botError = "No available AI provider and model can be selected on your Mac."
+ botError = "No available AI provider and model can be selected on your paired desktop."
return
}
self.catalog = catalog
@@ -799,7 +799,7 @@ struct AidenBotCustomAccessFlowView: View {
access: authoritative.access,
catalog: refreshedCatalog
) else {
- saveError = "Access may have changed on your Mac. Close and reopen this screen to refresh."
+ saveError = "Access may have changed on your paired desktop. Close and reopen this screen to refresh."
return
}
selectedBot = authoritative
diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotEditorView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotEditorView.swift
index dcd94c0c..ee0eeef3 100644
--- a/ios/AidenOnTheGo/Features/Bots/AidenBotEditorView.swift
+++ b/ios/AidenOnTheGo/Features/Bots/AidenBotEditorView.swift
@@ -569,7 +569,7 @@ struct AidenBotEditorView: View {
.disabled(draft?.usesFullAccess == true)
optionSection(
title: "Other Capabilities",
- description: "Additional capabilities available on this Mac.",
+ description: "Additional capabilities available on the paired desktop.",
options: catalog.otherCapabilities,
keyPath: \.otherCapabilityIDs
)
@@ -692,14 +692,14 @@ struct AidenBotEditorView: View {
.accessibilityHint("Custom Access can reduce the capabilities this Bot may use.")
if draft?.usesFullAccess == true {
- Label("Uses everything Aiden and your Mac currently allow.", systemImage: "checkmark.shield")
+ Label("Uses everything Aiden and the paired desktop currently allow.", systemImage: "checkmark.shield")
.foregroundStyle(palette.secondary)
}
} header: {
Text("Access")
} footer: {
if !AidenBotEditorDraft.fullAccessAccepted(in: catalog) {
- Text("Full Access is unavailable because Customize First was selected for this Mac.")
+ Text("Full Access is unavailable because Customize First was selected for this desktop.")
} else {
Text("Connections and Skills are the most important controls when using Custom Access.")
}
@@ -737,7 +737,7 @@ struct AidenBotEditorView: View {
if visionProviders(in: catalog).isEmpty {
Label(
- "No image-capable model is connected. Add one in Aiden Agent on your Mac, then refresh this Bot.",
+ "No image-capable model is connected. Add one in Aiden Agent on your paired desktop, then refresh this Bot.",
systemImage: "exclamationmark.triangle"
)
.foregroundStyle(palette.secondary)
@@ -766,7 +766,7 @@ struct AidenBotEditorView: View {
} header: {
Text("AI Provider and Model")
} footer: {
- Text("This Bot uses this Provider and Model in every chat. Credentials stay on your Mac.")
+ Text("This Bot uses this Provider and Model in every chat. Credentials stay on the paired desktop.")
}
}
@@ -788,7 +788,7 @@ struct AidenBotEditorView: View {
} header: {
Text("Files and Commands")
} footer: {
- Text("Choose which files the Bot may work with and whether it may run commands on the paired Mac.")
+ Text("Choose which files the Bot may work with and whether it may run commands on the paired desktop.")
}
}
@@ -800,7 +800,7 @@ struct AidenBotEditorView: View {
) -> some View {
Section {
if options.isEmpty {
- Text("None configured on this Mac")
+ Text("None configured on the paired desktop")
.foregroundStyle(palette.secondary)
} else {
ForEach(options) { option in
@@ -830,7 +830,7 @@ struct AidenBotEditorView: View {
)
if draft?.usesFullAccess == true {
Label(
- "Full Access: files, commands, Connections, and Skills allowed by the paired Mac",
+ "Full Access: files, commands, Connections, and Skills allowed by the paired desktop",
systemImage: "checkmark.shield"
)
} else if let access = draft?.customAccess {
@@ -1058,7 +1058,7 @@ struct AidenBotEditorView: View {
private var readOnlyMessage: String {
if baselineBot?.health == .archived { return "Archived Bots are read-only until restored." }
- if coordinator.connectionState != .connected { return "Reconnect to your Mac to save this Bot." }
+ if coordinator.connectionState != .connected { return "Reconnect to your paired desktop to save this Bot." }
return "This phone can view Bots but is not approved to change them."
}
@@ -1362,7 +1362,7 @@ struct AidenBotEditorView: View {
onSaved(authoritative)
dismiss()
} else {
- saveError = "Aiden checked the Bot on your Mac. Review any remaining changes, then save again."
+ saveError = "Aiden checked the Bot on your paired desktop. Review any remaining changes, then save again."
}
} catch is CancellationError {
return
@@ -1373,7 +1373,7 @@ struct AidenBotEditorView: View {
) { return }
guard isCurrent(attempt) else { return }
capturedContext = nil
- saveError = "Aiden couldn’t verify which changes reached your Mac. Close and reopen this Bot before editing again."
+ saveError = "Aiden couldn’t verify which changes reached your paired desktop. Close and reopen this Bot before editing again."
}
}
}
diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotGeneratedAvatarLifecycle.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotGeneratedAvatarLifecycle.swift
index e098048f..d2672307 100644
--- a/ios/AidenOnTheGo/Features/Bots/AidenBotGeneratedAvatarLifecycle.swift
+++ b/ios/AidenOnTheGo/Features/Bots/AidenBotGeneratedAvatarLifecycle.swift
@@ -21,7 +21,7 @@ enum AidenBotGeneratedAvatarError: Error, LocalizedError, Equatable {
case .invalidImage:
"Aiden couldn’t prepare that image. Choose another image."
case .unavailable:
- "Reconnect to your Mac before saving this Bot photo."
+ "Reconnect to your paired desktop before saving this Bot photo."
}
}
}
@@ -464,7 +464,7 @@ final class AidenBotGeneratedAvatarModel {
self.candidateBytes = nil
candidateImage = nil
phase = .idle
- errorMessage = "The Bot photo changed on your Mac. Review it before choosing a new image."
+ errorMessage = "The Bot photo changed on your paired desktop. Review it before choosing a new image."
return
}
attempt = .init(
@@ -569,7 +569,7 @@ final class AidenBotGeneratedAvatarModel {
}
guard fresh.avatar.asset?.assetRevision == observedAssetRevision else {
phase = .idle
- errorMessage = "The Bot photo changed on your Mac. Review it before removing it."
+ errorMessage = "The Bot photo changed on your paired desktop. Review it before removing it."
return
}
attempt = .init(
@@ -730,7 +730,7 @@ final class AidenBotGeneratedAvatarModel {
candidateBytes = nil
candidateImage = nil
phase = .idle
- errorMessage = "The Bot photo changed on your Mac. Review the current photo before replacing it."
+ errorMessage = "The Bot photo changed on your paired desktop. Review the current photo before replacing it."
}
} catch is CancellationError {
if isCurrent(attempt.context, generation: generation), uploadAttempt == attempt {
@@ -745,7 +745,7 @@ final class AidenBotGeneratedAvatarModel {
}
guard isCurrent(attempt.context, generation: generation), uploadAttempt == attempt else { return }
phase = .ready
- errorMessage = "Aiden couldn’t verify which photo reached your Mac. Reconnect, then retry this same upload."
+ errorMessage = "Aiden couldn’t verify which photo reached your paired desktop. Reconnect, then retry this same upload."
}
}
@@ -799,7 +799,7 @@ final class AidenBotGeneratedAvatarModel {
} else {
deleteAttempt = nil
phase = .idle
- errorMessage = "The Bot photo changed on your Mac. Review it before trying again."
+ errorMessage = "The Bot photo changed on your paired desktop. Review it before trying again."
}
} catch is CancellationError {
if isCurrent(attempt.context, generation: generation), deleteAttempt == attempt {
@@ -1020,7 +1020,7 @@ struct AidenBotGeneratedAvatarLifecycleView: View {
}
Button("Cancel", role: .cancel) { }
} message: {
- Text("This removes the generated Bot photo from your paired Mac.")
+ Text("This removes the generated Bot photo from your paired desktop.")
}
}
@@ -1043,8 +1043,8 @@ struct AidenBotGeneratedAvatarLifecycleView: View {
}
private var statusCopy: String {
- if model.hasCandidate { return "Only this accepted image will be sent to your paired Mac." }
- if model.hasGeneratedAvatar { return "Saved on your paired Mac." }
+ if model.hasCandidate { return "Only this accepted image will be sent to your paired desktop." }
+ if model.hasGeneratedAvatar { return "Saved on your paired desktop." }
return "Your semantic avatar is always available."
}
diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotProfileView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotProfileView.swift
index d94dbda0..2d601bc7 100644
--- a/ios/AidenOnTheGo/Features/Bots/AidenBotProfileView.swift
+++ b/ios/AidenOnTheGo/Features/Bots/AidenBotProfileView.swift
@@ -427,7 +427,7 @@ struct AidenBotProfileView: View {
.accessibilityHint(
detail.health == .ready
? "Opens this Bot’s persistent conversation."
- : "Repair this Bot’s access on the paired Mac before starting its conversation."
+ : "Repair this Bot’s access on the paired desktop before starting its conversation."
)
}
diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift
index c95e8031..fef6118b 100644
--- a/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift
+++ b/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift
@@ -278,7 +278,7 @@ func aidenBotInboxActivityStatus(
case .waitingForApproval:
canRespondToApproval
? .init(label: "Approval needed", symbol: "checkmark.shield")
- : .init(label: "Waiting for approval on Mac", symbol: "desktopcomputer")
+ : .init(label: "Waiting for desktop approval", symbol: "desktopcomputer")
case .reconciling: .init(label: "Updating", symbol: "arrow.triangle.2.circlepath")
}
}
@@ -564,7 +564,7 @@ struct AidenBotsHomeView: View {
Text(
coordinator.connectionState == .connected
? "Create a Bot to give a familiar helper one persistent conversation and its own capabilities."
- : "Reconnect to your Mac to load Bots."
+ : "Reconnect to your paired desktop to load Bots."
)
} actions: {
if coordinator.connectionState == .connected {
diff --git a/ios/AidenOnTheGo/Features/Bots/Prototype/BotFirstPrototype.swift b/ios/AidenOnTheGo/Features/Bots/Prototype/BotFirstPrototype.swift
index a841252e..b07c6a79 100644
--- a/ios/AidenOnTheGo/Features/Bots/Prototype/BotFirstPrototype.swift
+++ b/ios/AidenOnTheGo/Features/Bots/Prototype/BotFirstPrototype.swift
@@ -120,7 +120,7 @@ private enum AidenBotPrototypeChatAccess: String, CaseIterable, Identifiable, Ha
}
private enum AidenBotPrototypeFileAccess: String, CaseIterable, Identifiable, Hashable {
- case fullMac = "Full Mac"
+ case fullMac = "Full desktop"
case botFolderOnly = "Bot folder only"
case chosenLocations = "Chosen locations"
case off = "Off"
@@ -711,9 +711,9 @@ private struct AidenBotPrototypeFullAccessNoticeView: View {
.background(palette.accent.opacity(0.12), in: Circle())
VStack(alignment: .leading, spacing: 10) {
- Text("Bots can use your Mac")
+ Text("Bots can use your paired desktop")
.font(.largeTitle.bold())
- Text("By default, bots can work with files, run commands, and use connections, skills, and AI configured on the paired Mac. Capabilities you enable later in Aiden are also available to Full Access bots. You can choose Custom Access now or reduce access in Bot Settings anytime.")
+ Text("By default, bots can work with files, run commands, and use connections, skills, and AI configured on the paired desktop. Capabilities you enable later in Aiden are also available to Full Access bots. You can choose Custom Access now or reduce access in Bot Settings anytime.")
.font(.body)
.foregroundStyle(palette.secondary)
}
@@ -790,7 +790,7 @@ private struct AidenBotPrototypeWorkspacesView: View {
} header: {
Text("Workspaces")
} footer: {
- Text("This fixture root stays mounted separately from Bots and never connects to a Mac.")
+ Text("This fixture root stays mounted separately from Bots and never connects to a desktop.")
}
}
.scrollContentBackground(.hidden)
@@ -1091,7 +1091,7 @@ private struct AidenBotPrototypeInboxView: View {
AidenBotPrototypeBanner(
symbol: "exclamationmark.triangle",
title: "Some selected access is unavailable.",
- detail: "Review it on your Mac.",
+ detail: "Review it on your paired desktop.",
tone: palette.warning
)
default:
@@ -1140,7 +1140,7 @@ private struct AidenBotPrototypeInboxView: View {
AidenBotPrototypeEmptyView(
symbol: "arrow.clockwise.circle",
title: "Bots didn’t load",
- detail: "The paired Mac did not return a complete Bot list.",
+ detail: "The paired desktop did not return a complete Bot list.",
actionTitle: "Retry",
action: { fixtureState = .ready }
)
@@ -1506,7 +1506,7 @@ private struct AidenBotPrototypeProfileView: View {
.multilineTextAlignment(.center)
.frame(maxWidth: 380)
Label(
- isArchived ? "Archived bots are read-only until restored." : "Ready on your Mac",
+ isArchived ? "Archived bots are read-only until restored." : "Ready on your paired desktop",
systemImage: isArchived ? "archivebox.fill" : "checkmark.circle.fill"
)
.font(.caption.weight(.semibold))
@@ -1801,7 +1801,7 @@ private struct AidenBotPrototypeEditorView: View {
} header: {
Text("How this bot helps")
} footer: {
- Text("Write this in everyday language. Aiden adds the private operating details on your Mac.")
+ Text("Write this in everyday language. Aiden adds the private operating details on your paired desktop.")
}
Section {
@@ -1847,7 +1847,7 @@ private struct AidenBotPrototypeEditorView: View {
LabeledContent("Look", value: lookStyle.rawValue)
LabeledContent("Access", value: accessPolicy.mode.title)
Text(accessPolicy.mode == .full
- ? "Can use your Mac, shell, enabled connections, and skills."
+ ? "Can use your paired desktop, shell, enabled connections, and skills."
: "Uses only the access you select. This chat can reduce it further.")
.font(.caption)
.foregroundStyle(palette.secondary)
@@ -1941,9 +1941,9 @@ private struct AidenBotPrototypeAccessView: View {
}
private static let locationCatalog = [
- CatalogItem(id: "documents", title: "Documents", detail: "Chosen on your Mac"),
- CatalogItem(id: "desktop", title: "Desktop", detail: "Chosen on your Mac"),
- CatalogItem(id: "downloads", title: "Downloads", detail: "Chosen on your Mac"),
+ CatalogItem(id: "documents", title: "Documents", detail: "Chosen on your paired desktop"),
+ CatalogItem(id: "desktop", title: "Desktop", detail: "Chosen on your paired desktop"),
+ CatalogItem(id: "downloads", title: "Downloads", detail: "Chosen on your paired desktop"),
]
private static let connectionCatalog = [
CatalogItem(id: "calendar", title: "Calendar", detail: "Events and availability"),
@@ -2033,7 +2033,7 @@ private struct AidenBotPrototypeAccessView: View {
}
if showsCustomCapabilities {
- Section("Mac files") {
+ Section("Desktop files") {
Picker("Files", selection: $files) {
ForEach(AidenBotPrototypeFileAccess.allCases) { option in
Text(option.rawValue)
@@ -2077,7 +2077,7 @@ private struct AidenBotPrototypeAccessView: View {
} header: {
Text("Connections")
} footer: {
- Text("Choose external apps and services already configured in Aiden. Some connections are powered by MCP; account details stay on your Mac.")
+ Text("Choose external apps and services already configured in Aiden. Some connections are powered by MCP; account details stay on your paired desktop.")
}
Section {
@@ -2109,7 +2109,7 @@ private struct AidenBotPrototypeAccessView: View {
if scope == .bot {
Label("Full Access", systemImage: "checkmark.shield.fill")
.foregroundStyle(palette.accent)
- Text("Can use your Mac, shell, enabled connections, and skills.")
+ Text("Can use your paired desktop, shell, enabled connections, and skills.")
.font(.subheadline)
.foregroundStyle(palette.secondary)
} else {
diff --git a/ios/AidenOnTheGo/Features/Remote/AidenBotChatToolsView.swift b/ios/AidenOnTheGo/Features/Remote/AidenBotChatToolsView.swift
index 7750606a..b48558ab 100644
--- a/ios/AidenOnTheGo/Features/Remote/AidenBotChatToolsView.swift
+++ b/ios/AidenOnTheGo/Features/Remote/AidenBotChatToolsView.swift
@@ -246,7 +246,7 @@ final class AidenBotChatToolsModel {
func readOnlyMessage(coordinator: AidenRemoteCoordinator, hostAllowsMutations: Bool) -> String? {
if bot?.health == .archived { return "Archived bots are read-only until restored." }
if bot?.health == .degraded || bot?.health == .unavailable {
- return "This bot's access needs repair on your Mac before it can work."
+ return "This bot's access needs repair on your paired desktop before it can work."
}
if coordinator.connectionState != .connected { return "Offline — reconnect to change this chat's access." }
if coordinator.installationStore.activeInstallation?.canWriteBots != true {
@@ -593,7 +593,7 @@ struct AidenBotChatAccessSheetView: View {
if model.draft?.mode == .custom {
optionSection(
title: "Connections",
- description: "Connected apps and services already configured on your Mac.",
+ description: "Connected apps and services already configured on your paired desktop.",
options: catalog.connections,
keyPath: \.connectionIDs,
ceiling: bot.access.custom.map { Set($0.connectionIds) }
@@ -611,7 +611,7 @@ struct AidenBotChatAccessSheetView: View {
.disabled(!canChangeDraft)
optionSection(
title: "Other abilities",
- description: "Additional capabilities enabled for this bot on your Mac.",
+ description: "Additional capabilities enabled for this bot on your paired desktop.",
options: catalog.otherCapabilities,
keyPath: \.otherCapabilityIDs,
ceiling: bot.access.custom.map { Set($0.otherCapabilityIds) }
@@ -664,7 +664,7 @@ struct AidenBotChatAccessSheetView: View {
ContentUnavailableView(
"Access Unavailable",
systemImage: "lock.trianglebadge.exclamationmark",
- description: Text(model.errorMessage ?? "Reconnect to your Mac to load this chat's access.")
+ description: Text(model.errorMessage ?? "Reconnect to your paired desktop to load this chat's access.")
)
}
}
@@ -713,7 +713,7 @@ struct AidenBotChatAccessSheetView: View {
) -> some View {
Section {
if options.isEmpty {
- Text("None configured on this Mac").foregroundStyle(palette.secondary)
+ Text("None configured on the paired desktop").foregroundStyle(palette.secondary)
} else {
ForEach(options) { option in
Toggle(isOn: optionBinding(
@@ -937,7 +937,7 @@ final class AidenBotConversationFilesModel {
guard coordinator.isCurrent(grant.context) else { return }
if case AidenRemoteClientError.server(_, let body) = error,
body.code.rawValue == "revision_conflict" {
- errorMessage = "This file changed on the Mac. Reload it before saving again."
+ errorMessage = "This file changed on the paired desktop. Reload it before saving again."
} else if error is AidenRemoteClientError {
errorMessage = "Files access changed. Return to the chat and open Files again."
} else {
@@ -1104,8 +1104,8 @@ private struct AidenBotConversationFileEditorView: View {
} else if let message = model.errorMessage {
VStack(spacing: 8) {
Text(message).font(.footnote).foregroundStyle(.secondary)
- if message.contains("changed on the Mac") {
- Button("Reload from Mac") {
+ if message.contains("changed on the paired desktop") {
+ Button("Reload from desktop") {
Task { await model.reloadDocument(coordinator: coordinator) }
}
}
diff --git a/ios/AidenOnTheGo/Features/Remote/AidenChatFeature.swift b/ios/AidenOnTheGo/Features/Remote/AidenChatFeature.swift
index 91aeb774..e9dc9987 100644
--- a/ios/AidenOnTheGo/Features/Remote/AidenChatFeature.swift
+++ b/ios/AidenOnTheGo/Features/Remote/AidenChatFeature.swift
@@ -1051,7 +1051,7 @@ final class AidenChatViewModel {
throw NSError(
domain: "AidenVoiceInput",
code: 1,
- userInfo: [NSLocalizedDescriptionKey: status.engine.error ?? String(localized: "The Mac speech engine is unavailable.")]
+ userInfo: [NSLocalizedDescriptionKey: status.engine.error ?? String(localized: "The desktop speech engine is unavailable.")]
)
}
guard let model = status.models.first(where: { $0.id == status.selectedModelId && $0.installed })
@@ -1060,7 +1060,7 @@ final class AidenChatViewModel {
throw NSError(
domain: "AidenVoiceInput",
code: 2,
- userInfo: [NSLocalizedDescriptionKey: String(localized: "Download a Mac speech model in App Settings before using this option.")]
+ userInfo: [NSLocalizedDescriptionKey: String(localized: "Download a desktop speech model in App Settings before using this option.")]
)
}
if status.selectedModelId != model.id { _ = try await client.selectSpeechModel(model.id) }
@@ -1531,7 +1531,7 @@ final class AidenChatViewModel {
case .scheduleWriteRequired:
String(localized: "Schedule write access was removed from this paired device. The task was not approved.")
case .hostApprovalRequired:
- String(localized: "This request can only be approved on your Mac.")
+ String(localized: "This request can only be approved on your paired desktop.")
}
streamState = .reconciling
coordinator.haptics.play(.warning, scope: hapticScope)
@@ -2060,7 +2060,7 @@ final class AidenChatViewModel {
return
} catch {
// Keep the durable stream cursor and continue retrying while
- // this Mac connection remains current. Long Tailscale or
+ // this desktop connection remains current. Long Tailscale or
// local-network outages must not erase terminal evidence.
}
attempt += 1
@@ -3089,7 +3089,7 @@ struct AidenMessageOutcomePresentation: Equatable {
case "rate_limit":
detail = "The model provider is receiving too many requests. Try again shortly."
case "authentication":
- detail = "The model provider rejected its credentials. Check Provider Settings on your Mac."
+ detail = "The model provider rejected its credentials. Check Provider Settings on your desktop."
case "quota":
detail = "The model provider account has no available quota."
case "invalid_request":
@@ -4162,7 +4162,7 @@ private struct AidenApprovalCard: View {
.font(.caption)
.foregroundStyle(palette.secondary)
} else if kind == .scheduledTask && !canAllow {
- Label("This task must be approved on your Mac.", systemImage: "desktopcomputer")
+ Label("This task must be approved on your paired desktop.", systemImage: "desktopcomputer")
.font(.caption)
.foregroundStyle(palette.secondary)
}
diff --git a/ios/AidenOnTheGo/Features/Remote/AidenPairingView.swift b/ios/AidenOnTheGo/Features/Remote/AidenPairingView.swift
index 80feba4f..6a447ee6 100644
--- a/ios/AidenOnTheGo/Features/Remote/AidenPairingView.swift
+++ b/ios/AidenOnTheGo/Features/Remote/AidenPairingView.swift
@@ -138,7 +138,7 @@ enum AidenPairingMethod: String, CaseIterable, Identifiable, Hashable {
var title: String {
switch self {
case .scanQRCode: return String(localized: "Scan QR Code")
- case .nearbyMac: return String(localized: "Nearby Mac + Setup Code")
+ case .nearbyMac: return String(localized: "Nearby Desktop + Setup Code")
case .privateAddress: return String(localized: "Private Address + Setup Code")
case .pastePayload: return String(localized: "Paste Pairing Payload")
}
@@ -149,9 +149,9 @@ enum AidenPairingMethod: String, CaseIterable, Identifiable, Hashable {
case .scanQRCode:
return String(localized: "Scan the one-time QR shown by Aiden Agent.")
case .nearbyMac:
- return String(localized: "Find your Mac on local Wi-Fi, then enter its setup code.")
+ return String(localized: "Find your desktop on local Wi-Fi, then enter its setup code.")
case .privateAddress:
- return String(localized: "Enter the private Tailscale address and setup code shown on your Mac.")
+ return String(localized: "Enter the private Tailscale address and setup code shown on your desktop.")
case .pastePayload:
return String(localized: "Use the complete one-time payload when the camera is unavailable.")
}
@@ -202,7 +202,7 @@ enum AidenMobileOnboardingPhase: String, CaseIterable, Identifiable, Hashable {
var detail: String {
switch self {
case .build:
- return String(localized: "Use Workspaces for project-focused work with files, commands, review, and Git. When Bots are available on your paired Mac, use them as reusable helpers and tap the Aiden logo to switch.")
+ return String(localized: "Use Workspaces for project-focused work with files, commands, review, and Git. When Bots are available on your paired desktop, use them as reusable helpers and tap the Aiden logo to switch.")
case .extend:
return String(localized: "Choose models and thinking levels, attach images, use web search, and extend Aiden with skills and MCP connectors.")
case .control:
@@ -573,19 +573,19 @@ struct AidenPairingView: View {
ScrollView {
VStack(alignment: .leading, spacing: 28) {
VStack(alignment: .leading, spacing: 8) {
- Text("Prepare your Mac").font(.largeTitle.bold())
- Text("Your Mac does the work. Your AI account keys stay on your Mac.")
+ Text("Prepare your desktop").font(.largeTitle.bold())
+ Text("Your desktop does the work. Your AI account keys stay on your desktop.")
.foregroundStyle(palette.secondary)
}
- pairingStep(number: 1, title: "Open Aiden Agent", detail: "On your Mac, go to Settings → Aiden On The Go.")
+ pairingStep(number: 1, title: "Open Aiden Agent", detail: "On your desktop, go to Settings → Aiden On The Go.")
pairingStep(number: 2, title: "Connect your phone", detail: "Choose where you’ll use Aiden, then select Connect a device. Review what Aiden will enable.")
pairingStep(number: 3, title: "Scan to finish", detail: "Keep the QR or setup code visible. Both expire after five minutes and can be used once.")
VStack(alignment: .leading, spacing: 10) {
Label("Only devices you connect can access Aiden", systemImage: "key.fill")
- Label("Encrypted connection to your Mac", systemImage: "lock.shield.fill")
- Label("Remove access from your Mac at any time", systemImage: "checkmark.shield")
+ Label("Encrypted connection to your desktop", systemImage: "lock.shield.fill")
+ Label("Remove access from your desktop at any time", systemImage: "checkmark.shield")
}
.font(.subheadline)
.foregroundStyle(palette.secondary)
@@ -624,7 +624,7 @@ struct AidenPairingView: View {
private var pairingPage: some View {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 12) {
- Text("Scan the code in Settings → Aiden On The Go on your Mac.")
+ Text("Scan the code in Settings → Aiden On The Go on your desktop.")
.font(.subheadline)
.foregroundStyle(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -670,11 +670,11 @@ struct AidenPairingView: View {
Label("Open Aiden Agent’s Add Device window and keep the one-time QR visible.", systemImage: "desktopcomputer")
Label("The QR already contains the selected Local Network or Tailscale address.", systemImage: "network")
} header: {
- Text("On your Mac")
+ Text("On your desktop")
}
Section("Private pairing") {
- Text("The QR expires after five minutes and can be used once. Aiden pins the Mac’s HTTPS identity during pairing.")
+ Text("The QR expires after five minutes and can be used once. Aiden pins the desktop’s HTTPS identity during pairing.")
.foregroundStyle(palette.secondary)
}
}
@@ -702,14 +702,14 @@ struct AidenPairingView: View {
}
}
} header: {
- Text("Nearby Macs")
+ Text("Nearby desktops")
} footer: {
- Text("Your iPhone or iPad and Mac must be on the same local network. Select the Mac shown in Aiden Agent’s Add Device window.")
+ Text("Your iPhone or iPad and desktop must be on the same local network. Select the desktop shown in Aiden Agent’s Add Device window.")
}
Section {
manualEndpointField(
- placeholder: "https://mac-name.local:49220/api/aiden/v1",
+ placeholder: "https://desktop-name.local:49220/api/aiden/v1",
accessibilityLabel: "Nearby Aiden Agent address"
)
manualSetupCodeField
@@ -717,7 +717,7 @@ struct AidenPairingView: View {
} header: {
Text("Setup code")
} footer: {
- Text("If discovery is unavailable, enter the exact nearby Mac address shown in Aiden Agent. The setup code is encrypted and can be used once.")
+ Text("If discovery is unavailable, enter the exact nearby desktop address shown in Aiden Agent. The setup code is encrypted and can be used once.")
}
}
.scrollContentBackground(.hidden)
@@ -728,7 +728,7 @@ struct AidenPairingView: View {
Form {
Section {
manualEndpointField(
- placeholder: "https://mac-name.tailnet.ts.net/api/aiden/v1",
+ placeholder: "https://desktop-name.tailnet.ts.net/api/aiden/v1",
accessibilityLabel: "Private Tailscale address"
)
manualSetupCodeField
@@ -741,7 +741,7 @@ struct AidenPairingView: View {
Section("Before pairing") {
Label("Sign in to the same Tailscale network on both devices", systemImage: "network")
- Label("Keep Aiden Agent open on your Mac", systemImage: "desktopcomputer")
+ Label("Keep Aiden Agent open on your desktop", systemImage: "desktopcomputer")
}
}
.scrollContentBackground(.hidden)
@@ -770,7 +770,7 @@ struct AidenPairingView: View {
} header: {
Text("One-time pairing payload")
} footer: {
- Text("Use only the complete payload copied from your own Mac. It contains a one-time secret and expires after five minutes.")
+ Text("Use only the complete payload copied from your own desktop. It contains a one-time secret and expires after five minutes.")
}
}
.scrollContentBackground(.hidden)
@@ -808,8 +808,8 @@ struct AidenPairingView: View {
.accessibilityValue(selectedAgentID == agent.id ? "Selected" : "Not selected")
.accessibilityAddTraits(selectedAgentID == agent.id ? .isSelected : [])
.accessibilityHint(agent.endpoint == nil
- ? "This Mac is still resolving its network address."
- : "Use this Mac for setup-code pairing.")
+ ? "This desktop is still resolving its network address."
+ : "Use this desktop for setup-code pairing.")
}
private func manualEndpointField(
diff --git a/ios/AidenOnTheGo/Features/Remote/AidenProductShellView.swift b/ios/AidenOnTheGo/Features/Remote/AidenProductShellView.swift
index 67fd4c59..5d313453 100644
--- a/ios/AidenOnTheGo/Features/Remote/AidenProductShellView.swift
+++ b/ios/AidenOnTheGo/Features/Remote/AidenProductShellView.swift
@@ -56,9 +56,9 @@ enum AidenBotsAvailability: Equatable, Sendable {
case .mobileDisabled:
"Bots aren’t available in this version of Aiden On The Go."
case .unsupported:
- "Bots need a newer version of Aiden Agent on your Mac."
+ "Bots need a newer version of Aiden Agent on your paired desktop."
case .notGranted:
- "Approve Bot access on your Mac, or pair this phone again."
+ "Approve Bot access on your paired desktop, or pair this phone again."
}
}
@@ -140,7 +140,7 @@ func aidenBotSwitcherCoachmarkDetail(canWrite: Bool) -> String {
if canWrite {
return "Before a Bot can act, Aiden shows a one-time Full Access notice. Choose Continue with Full Access or Customize first."
}
- return "This Mac shared Bots as read-only. You can open their conversations here, then change Bot access on your Mac if you want to let them act."
+ return "This desktop shared Bots as read-only. You can open their conversations here, then change Bot access on your paired desktop if you want to let them act."
}
func aidenBotChatAllowsMutations(
@@ -825,7 +825,7 @@ private struct AidenBotShellView: View {
guard coordinator.connectionState == .connected else {
if cached == nil {
path = []
- coordinator.presentedError = "Reconnect to your Mac to open this Bot chat."
+ coordinator.presentedError = "Reconnect to your paired desktop to open this Bot chat."
}
return
}
@@ -1165,10 +1165,10 @@ private struct AidenFullAccessNoticeView: View {
.foregroundStyle(.tint)
.accessibilityHidden(true)
- Text("Bots can use your Mac")
+ Text("Bots can use your paired desktop")
.font(.largeTitle.bold())
- Text("By default, bots can work with files, run commands, and use connections, skills, and AI configured on the paired Mac. Capabilities you enable later in Aiden are also available to Full Access bots. You can choose Custom Access now or reduce access in Bot Settings anytime.")
+ Text("By default, bots can work with files, run commands, and use connections, skills, and AI configured on the paired desktop. Capabilities you enable later in Aiden are also available to Full Access bots. You can choose Custom Access now or reduce access in Bot Settings anytime.")
.font(.body)
if includesMigrationCopy {
@@ -1195,7 +1195,7 @@ private struct AidenFullAccessNoticeView: View {
.disabled(isSaving)
if isSaving {
- ProgressView("Saving on your Mac…")
+ ProgressView("Saving on your paired desktop…")
.frame(maxWidth: .infinity)
}
}
@@ -1456,7 +1456,7 @@ struct AidenProductShellView: View {
case .coaching:
EmptyView()
case .checking:
- ProgressView("Checking Bot access on your Mac…")
+ ProgressView("Checking Bot access on your paired desktop…")
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
case .failed(let message):
diff --git a/ios/AidenOnTheGo/Features/Remote/AidenScheduledTasksView.swift b/ios/AidenOnTheGo/Features/Remote/AidenScheduledTasksView.swift
index 6b94043c..d242f86f 100644
--- a/ios/AidenOnTheGo/Features/Remote/AidenScheduledTasksView.swift
+++ b/ios/AidenOnTheGo/Features/Remote/AidenScheduledTasksView.swift
@@ -435,7 +435,7 @@ final class AidenScheduledTasksModel {
return
}
pendingRunKeys[task.id] = nil
- outcomeMessage = String(localized: "Run accepted (\(accepted.runId.prefix(12))…). It continues on your Mac if this phone disconnects.")
+ outcomeMessage = String(localized: "Run accepted (\(accepted.runId.prefix(12))…). It continues on your paired desktop if this phone disconnects.")
coordinator.haptics.play(
.actionStarted,
scope: hapticScope,
@@ -673,7 +673,7 @@ struct AidenScheduledTasksView: View {
ContentUnavailableView(
"Schedule Access Required",
systemImage: "lock.shield",
- description: Text("Enable schedule read access for this paired device on your Mac to view task definitions and cached run history.")
+ description: Text("Enable schedule read access for this paired device on your desktop to view task definitions and cached run history.")
)
.listRowBackground(Color.clear)
} else {
@@ -702,7 +702,7 @@ struct AidenScheduledTasksView: View {
ContentUnavailableView(
"No Scheduled Tasks",
systemImage: "clock.badge.plus",
- description: Text("Ask Aiden in any chat to create unattended work that runs on your Mac.")
+ description: Text("Ask Aiden in any chat to create unattended work that runs on your desktop.")
)
.listRowBackground(Color.clear)
} else if visibleTasks.isEmpty && !model.isLoading {
@@ -1110,7 +1110,7 @@ private struct AidenScheduledTaskEditor: View {
.font(.footnote)
.foregroundStyle(.secondary)
}
- Text("Only enabled server names are shown. Connection details and credentials remain on your Mac.")
+ Text("Only enabled server names are shown. Connection details and credentials remain on your desktop.")
.font(.footnote).foregroundStyle(.secondary)
}
}
@@ -1128,8 +1128,8 @@ private struct AidenScheduledTaskEditor: View {
Picker("Permission", selection: $draft.permission) {
ForEach(AidenScheduledTaskPermission.allCases, id: \.self) { Text($0.title).tag($0) }
}
- Toggle("Mac notification", isOn: $draft.notify)
- Text("Enabled tasks can run on your Mac while this phone is disconnected. Full permission can edit files and run commands without asking.")
+ Toggle("Desktop notification", isOn: $draft.notify)
+ Text("Enabled tasks can run on your desktop while this phone is disconnected. Full permission can edit files and run commands without asking.")
.font(.footnote).foregroundStyle(.secondary)
}
if let validation = reviewValidationMessage { Section { Text(validation).foregroundStyle(.red) } }
@@ -1207,7 +1207,7 @@ private struct AidenScheduledTaskEditor: View {
}
LabeledContent("Notifications", value: draft.notify ? "On" : "Off")
}
- Section { Text("Confirm only if this unattended work should run on your Mac while the phone is disconnected.") }
+ Section { Text("Confirm only if this unattended work should run on your desktop while the phone is disconnected.") }
}
.navigationTitle("Review Task")
.toolbar {
diff --git a/ios/AidenOnTheGo/Features/Remote/AidenWorkspaceEnvironmentView.swift b/ios/AidenOnTheGo/Features/Remote/AidenWorkspaceEnvironmentView.swift
index 27796919..984ac817 100644
--- a/ios/AidenOnTheGo/Features/Remote/AidenWorkspaceEnvironmentView.swift
+++ b/ios/AidenOnTheGo/Features/Remote/AidenWorkspaceEnvironmentView.swift
@@ -64,7 +64,7 @@ actor AidenWorkspaceEnvironmentCache {
try? FileManager.default.removeItem(at: instanceDirectory)
// The legacy flat format encoded instance + workspace identity in its
// filename but not its payload. Delete only names attributable from a
- // known workspace snapshot; never erase another Mac's unknown cache.
+ // known workspace snapshot; never erase another desktop's unknown cache.
for workspaceId in knownWorkspaceIds {
try? FileManager.default.removeItem(
at: legacyFile(instanceId: instanceId, workspaceId: workspaceId)
@@ -240,7 +240,7 @@ final class AidenWorkspaceFilesModel {
guard coordinator.isCurrent(context) else { return false }
if case AidenRemoteClientError.server(_, let body) = error,
body.code.rawValue == "revision_conflict" {
- errorMessage = "This file changed on the Mac. Reload it before saving again."
+ errorMessage = "This file changed on the paired desktop. Reload it before saving again."
coordinator.haptics.play(.warning, scope: hapticScope)
} else {
errorMessage = error.localizedDescription
@@ -357,8 +357,8 @@ private struct AidenWorkspaceFileEditorView: View {
if let message = model.errorMessage {
VStack(spacing: 8) {
Text(message).font(.footnote).foregroundStyle(.secondary)
- if message.contains("changed on the Mac") {
- Button("Reload from Mac") {
+ if message.contains("changed on the paired desktop") {
+ Button("Reload from desktop") {
Task { await model.reloadDocument(coordinator: coordinator) }
}
}
diff --git a/ios/AidenOnTheGo/Features/Remote/AidenWorkspaceShellView.swift b/ios/AidenOnTheGo/Features/Remote/AidenWorkspaceShellView.swift
index 695b3912..d0f011a2 100644
--- a/ios/AidenOnTheGo/Features/Remote/AidenWorkspaceShellView.swift
+++ b/ios/AidenOnTheGo/Features/Remote/AidenWorkspaceShellView.swift
@@ -2013,7 +2013,7 @@ private struct AidenWorkspacesDirectoryView: View {
searchText.isEmpty ? "No Workspaces" : "No Matching Workspaces",
systemImage: searchText.isEmpty ? "folder" : "magnifyingglass",
description: Text(searchText.isEmpty
- ? "Create a workspace or add a Mac folder to get started."
+ ? "Create a workspace or add a desktop folder to get started."
: "Try a different search term.")
)
.listRowBackground(Color.clear)
@@ -2063,7 +2063,7 @@ private struct AidenWorkspacesDirectoryView: View {
}
Button { isShowingFolderBrowser = true } label: {
- Label("Add Mac Folder", systemImage: "folder.badge.plus")
+ Label("Add Desktop Folder", systemImage: "folder.badge.plus")
}
} label: {
Image(systemName: "plus")
@@ -2101,7 +2101,7 @@ private struct AidenWorkspacesDirectoryView: View {
}
.disabled(newWorkspaceName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
} message: {
- Text("Creates a workspace registry entry without a Mac folder. You can add a folder later from Aiden Agent.")
+ Text("Creates a workspace registry entry without a desktop folder. You can add a folder later from Aiden Agent.")
}
.confirmationDialog(
"Create a managed scratch workspace?",
@@ -2121,7 +2121,7 @@ private struct AidenWorkspacesDirectoryView: View {
}
Button("Cancel", role: .cancel) {}
} message: {
- Text("Aiden Agent will create and manage the worktree on your Mac.")
+ Text("Aiden Agent will create and manage the worktree on your desktop.")
}
.alert(
"Rename Workspace",
@@ -2156,7 +2156,7 @@ private struct AidenWorkspacesDirectoryView: View {
|| coordinator.isMutating
)
} message: {
- Text("This updates the workspace name in Aiden Agent on your Mac and paired clients. It does not rename the folder on disk.")
+ Text("This updates the workspace name in Aiden Agent on your desktop and paired clients. It does not rename the folder on disk.")
}
.alert(
"Archive on This Device?",
@@ -2176,7 +2176,7 @@ private struct AidenWorkspacesDirectoryView: View {
workspacePendingFirstArchive = nil
}
} message: {
- Text("This hides the workspace and its chats only on this iPhone or iPad. It stays available in Aiden Agent on your Mac and on other devices.")
+ Text("This hides the workspace and its chats only on this iPhone or iPad. It stays available in Aiden Agent on your desktop and on other devices.")
}
.alert(
"Remove from Aiden Agent?",
@@ -2204,7 +2204,7 @@ private struct AidenWorkspacesDirectoryView: View {
}
.disabled(coordinator.connectionState != .connected || coordinator.isMutating)
} message: {
- Text("This unregisters the workspace from Aiden Agent and paired clients. Its folder, files, and chats stay on your Mac, but its chats will no longer be listed. Delete the folder separately in Finder if you no longer need it.")
+ Text("This unregisters the workspace from Aiden Agent and paired clients. Its folder, files, and chats stay on your desktop, but its chats will no longer be listed. Delete the folder separately in your system file manager if you no longer need it.")
}
}
@@ -2581,7 +2581,7 @@ private struct AidenWorkspaceSettingsView: View {
} footer: {
Text(workspace.isManagedWorktree
? "Deleting an Aiden-managed worktree removes its checkout and may remove its branch when safe."
- : "Removing unregisters this workspace from Aiden Agent and paired clients. Its folder, files, and chats stay on your Mac, but its chats will no longer be listed.")
+ : "Removing unregisters this workspace from Aiden Agent and paired clients. Its folder, files, and chats stay on your desktop, but its chats will no longer be listed.")
}
}
}
@@ -2642,7 +2642,7 @@ private struct AidenWorkspaceSettingsView: View {
} message: {
Text(workspace.isManagedWorktree
? "This destructive Git operation is performed by Aiden Agent using its persisted worktree ownership record."
- : "The folder, its files, and chats remain on your Mac, but the chats will no longer be listed. Delete the folder separately in Finder if you no longer need it.")
+ : "The folder, its files, and chats remain on your desktop, but the chats will no longer be listed. Delete the folder separately in your system file manager if you no longer need it.")
}
}
.onAppear { coordinator.haptics.activate(scope: hapticScope) }
@@ -2933,7 +2933,7 @@ private struct AidenUsageView: View {
.foregroundStyle(palette.accent)
.frame(width: 28)
- Text("Privacy-safe aggregates are recorded by Aiden Agent on your Mac. Prompts, responses, chat IDs, workspace IDs, and file paths are not included.")
+ Text("Privacy-safe aggregates are recorded by Aiden Agent on your desktop. Prompts, responses, chat IDs, workspace IDs, and file paths are not included.")
.font(.footnote)
.foregroundStyle(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -3133,7 +3133,7 @@ private struct AidenAppSettingsView: View {
Form {
Section("Aiden Agent") {
LabeledContent(
- "Connected Mac",
+ "Connected desktop",
value: coordinator.installationStore.activeInstallation?.name ?? "Not connected"
)
Button {
@@ -3188,7 +3188,7 @@ private struct AidenAppSettingsView: View {
NavigationLink {
AidenMacTranscriptionSettingsView(coordinator: coordinator)
} label: {
- Label("Mac speech model", systemImage: "desktopcomputer")
+ Label("Desktop speech model", systemImage: "desktopcomputer")
}
}
} header: {
@@ -3196,7 +3196,7 @@ private struct AidenAppSettingsView: View {
} footer: {
Text(
voiceInputModeRaw == AidenVoiceInputMode.pairedMac.rawValue
- ? "Microphone audio is sent over Aiden's encrypted pinned connection, processed by Parakeet on your paired Mac, and not retained. Text appears after you stop recording."
+ ? "Microphone audio is sent over Aiden's encrypted pinned connection, processed by Parakeet on your paired desktop, and not retained. Text appears after you stop recording."
: "Uses Apple's on-device Speech framework. Microphone audio stays on this device."
)
}
@@ -3298,13 +3298,13 @@ private struct AidenMacTranscriptionSettingsView: View {
}
}
} else if isLoading {
- ProgressView("Loading Mac speech models…")
+ ProgressView("Loading desktop speech models…")
}
if let errorMessage {
Section { Text(errorMessage).foregroundStyle(.red) }
}
}
- .navigationTitle("Mac Transcription")
+ .navigationTitle("Desktop Transcription")
.navigationBarTitleDisplayMode(.inline)
.task { await refresh() }
.task(id: downloadPollKey) {
@@ -3642,7 +3642,7 @@ private struct AidenFolderBrowserView: View {
}
}
}
- .navigationTitle("Add Mac Folder")
+ .navigationTitle("Add Desktop Folder")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
diff --git a/ios/AidenOnTheGo/Models/AidenBot.swift b/ios/AidenOnTheGo/Models/AidenBot.swift
index 10b60d24..7d49a9e0 100644
--- a/ios/AidenOnTheGo/Models/AidenBot.swift
+++ b/ios/AidenOnTheGo/Models/AidenBot.swift
@@ -8,7 +8,7 @@ enum AidenBotContractError: Error, Equatable, LocalizedError {
switch self {
case .invalidCombination("no available provider and model"):
String(
- localized: "Set up a provider and model on your Mac. In Aiden Agent, open Settings → Providers, connect or refresh a provider, and make at least one chat model available. Then tap Try Again."
+ localized: "Set up a provider and model on your paired desktop. In Aiden Agent, open Settings → Providers, connect or refresh a provider, and make at least one chat model available. Then tap Try Again."
)
case .invalidCombination("unavailable custom access"):
String(
diff --git a/ios/AidenOnTheGo/Models/AidenChat.swift b/ios/AidenOnTheGo/Models/AidenChat.swift
index 3253535f..dfd9583c 100644
--- a/ios/AidenOnTheGo/Models/AidenChat.swift
+++ b/ios/AidenOnTheGo/Models/AidenChat.swift
@@ -441,7 +441,7 @@ enum AidenAgentActivityPresentation {
"web_search": ("Searching the web", "Searched the web"),
"schedule_task": ("Scheduling", "Scheduled"),
"edit_automation": ("Editing automation", "Edited automation"),
- "computer_use": ("Using Mac", "Used Mac"),
+ "computer_use": ("Using Computer Use", "Used Computer Use"),
"browser": ("Loading browser tools", "Loaded browser tools"),
"browser_status": ("Checking browser", "Checked browser"),
"browser_open": ("Opening browser", "Opened browser"),
@@ -572,7 +572,7 @@ enum AidenAgentActivityPresentation {
if changes > 0 { clauses.append("\(running ? "editing" : "edited") \(changes) file\(changes == 1 ? "" : "s")") }
if commands > 0 { clauses.append("\(running ? "running" : "ran") \(commands) command\(commands == 1 ? "" : "s")") }
if web > 0 { clauses.append("\(web) web search\(web == 1 ? "" : "es")") }
- if mac > 0 { clauses.append("\(mac) Mac action\(mac == 1 ? "" : "s")") }
+ if mac > 0 { clauses.append("\(mac) Computer Use action\(mac == 1 ? "" : "s")") }
if compactions > 0 { clauses.append(running ? "compacting context" : "compacted context") }
if other > 0 { clauses.append("\(other) tool call\(other == 1 ? "" : "s")") }
if clauses.isEmpty { return running ? "Working" : "Used \(tools.count) tool\(tools.count == 1 ? "" : "s")" }
diff --git a/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift b/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift
index d89f0fd9..399c074c 100644
--- a/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift
+++ b/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift
@@ -411,7 +411,7 @@ enum AidenRemoteClientError: Error, LocalizedError {
case .missingTrustConfiguration:
return "This Aiden installation must be paired again to establish secure server trust."
case .installationChanged:
- return "The active Aiden Agent changed. Try again on the selected Mac."
+ return "The active Aiden Agent changed. Try again on the selected desktop."
}
}
}
@@ -553,7 +553,7 @@ final class AidenRemoteClient: @unchecked Sendable {
)
} catch let AidenRemoteClientError.server(statusCode, body)
where statusCode == 400 && body.code.rawValue == "invalid_request" {
- // Strict early-v1 Macs reject additive request keys before consuming
+ // Strict early-v1 desktops reject additive request keys before consuming
// the one-time secret. Retry once with the frozen four-field shape.
exchange = try await client.send(
method: "POST",
diff --git a/ios/AidenOnTheGo/Networking/AidenRemoteContract.swift b/ios/AidenOnTheGo/Networking/AidenRemoteContract.swift
index 09171ace..a7bc19da 100644
--- a/ios/AidenOnTheGo/Networking/AidenRemoteContract.swift
+++ b/ios/AidenOnTheGo/Networking/AidenRemoteContract.swift
@@ -64,7 +64,7 @@ enum AidenManualPairingError: Error, Equatable, LocalizedError {
var errorDescription: String? {
switch self {
case .invalidCode:
- return String(localized: "Enter the 20-character setup code shown on your Mac.")
+ return String(localized: "Enter the 20-character setup code shown on your desktop.")
case .invalidBootstrap:
return String(localized: "Aiden Agent returned an invalid manual pairing response.")
case .decryptionFailed:
diff --git a/ios/AidenOnTheGo/Persistence/AidenChatCache.swift b/ios/AidenOnTheGo/Persistence/AidenChatCache.swift
index 74b2af76..3030b830 100644
--- a/ios/AidenOnTheGo/Persistence/AidenChatCache.swift
+++ b/ios/AidenOnTheGo/Persistence/AidenChatCache.swift
@@ -495,7 +495,7 @@ actor AidenChatCache {
// Older active-stream records did not contain deviceId and cannot
// decode with the current schema. Their outer envelope still has
// an exact installation identity, so explicit forget/re-pair can
- // remove them without touching another Mac's cache.
+ // remove them without touching another desktop's cache.
guard let data = try? Data(contentsOf: url),
data.count <= (maximumBytes ?? maxCacheFileBytes),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
diff --git a/ios/AidenOnTheGoTests/AidenBotGeneratedAvatarTests.swift b/ios/AidenOnTheGoTests/AidenBotGeneratedAvatarTests.swift
index 1953e20c..60452c97 100644
--- a/ios/AidenOnTheGoTests/AidenBotGeneratedAvatarTests.swift
+++ b/ios/AidenOnTheGoTests/AidenBotGeneratedAvatarTests.swift
@@ -723,7 +723,7 @@ final class AidenBotGeneratedAvatarTests: XCTestCase {
XCTAssertEqual(model.authoritativeBot?.avatar.asset?.assetRevision, macRevision)
XCTAssertNotNil(model.currentImage)
XCTAssertEqual(model.phase, .idle)
- XCTAssertTrue(model.errorMessage?.contains("changed on your Mac") == true)
+ XCTAssertTrue(model.errorMessage?.contains("changed on your paired desktop") == true)
}
@MainActor
@@ -804,7 +804,7 @@ final class AidenBotGeneratedAvatarTests: XCTestCase {
XCTAssertEqual(model.phase, .idle)
XCTAssertEqual(model.authoritativeBot?.avatar.asset?.assetRevision, replacementRevision)
XCTAssertNotNil(model.currentImage)
- XCTAssertTrue(model.errorMessage?.contains("changed on your Mac") == true)
+ XCTAssertTrue(model.errorMessage?.contains("changed on your paired desktop") == true)
}
@MainActor
diff --git a/ios/AidenOnTheGoTests/AidenChatTests.swift b/ios/AidenOnTheGoTests/AidenChatTests.swift
index 6d6c1cae..be11a63a 100644
--- a/ios/AidenOnTheGoTests/AidenChatTests.swift
+++ b/ios/AidenOnTheGoTests/AidenChatTests.swift
@@ -446,7 +446,7 @@ final class AidenChatTests: XCTestCase {
)
XCTAssertEqual(
AidenAgentActivityPresentation.summary(timeline),
- "1 web search, 1 Mac action, compacted context, 1 tool call"
+ "1 web search, 1 Computer Use action, compacted context, 1 tool call"
)
}
@@ -1694,7 +1694,7 @@ final class AidenChatTests: XCTestCase {
)),
.init(
title: "Generation failed",
- detail: "The model provider rejected its credentials. Check Provider Settings on your Mac.",
+ detail: "The model provider rejected its credentials. Check Provider Settings on your desktop.",
symbol: "exclamationmark.triangle",
isFailure: true
)
diff --git a/ios/AidenOnTheGoTests/AidenNativeIntegrationTests.swift b/ios/AidenOnTheGoTests/AidenNativeIntegrationTests.swift
index 059d327a..e7268f9c 100644
--- a/ios/AidenOnTheGoTests/AidenNativeIntegrationTests.swift
+++ b/ios/AidenOnTheGoTests/AidenNativeIntegrationTests.swift
@@ -591,7 +591,7 @@ final class AidenNativeIntegrationTests: XCTestCase {
XCTAssertEqual(AidenVoiceInputMode.defaultsKey, "aiden.voiceInput.mode")
XCTAssertEqual(AidenVoiceInputMode.allCases, [.onDevice, .pairedMac])
XCTAssertEqual(AidenVoiceInputMode.onDevice.title, "On this device")
- XCTAssertEqual(AidenVoiceInputMode.pairedMac.title, "Paired Mac")
+ XCTAssertEqual(AidenVoiceInputMode.pairedMac.title, "Paired desktop")
}
func testVoiceSessionFenceRejectsCallbacksFromAnInvalidatedSession() {
diff --git a/ios/AidenOnTheGoTests/AidenProductShellTests.swift b/ios/AidenOnTheGoTests/AidenProductShellTests.swift
index fd0629bf..4f7e0fe4 100644
--- a/ios/AidenOnTheGoTests/AidenProductShellTests.swift
+++ b/ios/AidenOnTheGoTests/AidenProductShellTests.swift
@@ -355,7 +355,7 @@ final class AidenProductShellTests: XCTestCase {
state: .waitingForApproval,
canRespondToApproval: false
)?.label,
- "Waiting for approval on Mac"
+ "Waiting for desktop approval"
)
XCTAssertEqual(
aidenBotInboxActivityStatus(state: .running, canRespondToApproval: false)?.label,
@@ -529,7 +529,7 @@ final class AidenProductShellTests: XCTestCase {
)
XCTAssertEqual(
aidenBotSwitcherCoachmarkDetail(canWrite: false),
- "This Mac shared Bots as read-only. You can open their conversations here, then change Bot access on your Mac if you want to let them act."
+ "This desktop shared Bots as read-only. You can open their conversations here, then change Bot access on your paired desktop if you want to let them act."
)
}
diff --git a/ios/CONTEXT.md b/ios/CONTEXT.md
index 1fd0bf78..a9393c6a 100644
--- a/ios/CONTEXT.md
+++ b/ios/CONTEXT.md
@@ -1,9 +1,9 @@
# Aiden On The Go terms
-- **Aiden installation:** one paired Aiden Agent Mac, identified by its server-issued instance ID.
-- **Device credential:** the per-phone or per-iPad bearer secret stored only in Keychain and revocable on the Mac.
+- **Aiden installation:** one paired Aiden Agent desktop, identified by its server-issued instance ID.
+- **Device credential:** the per-phone or per-iPad bearer secret stored only in Keychain and revocable on the desktop.
- **Workspace:** an Aiden registry entry with `full`, `ask`, or `none` permission.
-- **Approved root:** a Mac folder explicitly exposed for remote browsing by a local desktop action.
+- **Approved root:** a desktop folder explicitly exposed for remote browsing by a local desktop action.
- **Location handle:** a short-lived opaque browser capability; never a filesystem path.
- **Selection:** a short-lived, single-use capability consumed atomically when registering a selected folder.
- **Remote turn:** one idempotently admitted user message whose generation remains owned by Aiden Agent across network loss.
diff --git a/ios/PROJECT_INTENT.md b/ios/PROJECT_INTENT.md
index 11a7f5f6..c655eb48 100644
--- a/ios/PROJECT_INTENT.md
+++ b/ios/PROJECT_INTENT.md
@@ -1,12 +1,12 @@
# Aiden On The Go — Project Intent
-Aiden On The Go lets a user securely control their own Aiden Agent Mac from iPhone or iPad. It is a client, not an agent runtime or hosted service.
+Aiden On The Go lets a user securely control their own Aiden Agent desktop from iPhone or iPad. It is a client, not an agent runtime or hosted service.
Core boundaries:
-- Pair explicitly with a Mac using a short-lived QR bootstrap and per-device revocable credential.
-- Connect over pinned local HTTPS or the Mac's explicitly configured Tailscale route.
-- Match Aiden's chat and workspace behavior without widening permissions or exposing private Mac paths.
+- Pair explicitly with a desktop using a short-lived QR bootstrap and per-device revocable credential.
+- Connect over pinned local HTTPS or the desktop's explicitly configured Tailscale route.
+- Match Aiden's chat and workspace behavior without widening permissions or exposing private desktop paths.
- Keep credentials in Keychain and bounded offline presentation state on device.
- Use native SwiftUI controls and adaptive Apple navigation.
diff --git a/ios/PROJECT_SPEC.md b/ios/PROJECT_SPEC.md
index be9f5707..165d57b6 100644
--- a/ios/PROJECT_SPEC.md
+++ b/ios/PROJECT_SPEC.md
@@ -20,7 +20,7 @@ The complete planned product includes:
- Multiple paired Aiden installations with QR or 100-bit setup-code pairing, Keychain credentials, discovery, manual URL entry, switching, and revocation handling.
- Chat list/open/create/rename/delete, bounded attachments, provider/model/thinking selection, atomic turn start, resumable streaming, cancel, reasoning/tool/timeline status, and allow/deny approvals.
-- Workspace registry list/create/update/unregister, including folderless, managed scratch, and folders selected through a server-approved Mac directory browser.
+- Workspace registry list/create/update/unregister, including folderless, managed scratch, and folders selected through a server-approved desktop directory browser.
- Workspace Settings from the conversation toolbar ellipsis. Workspace permission is never a composer control.
- Device-local Aiden, Slate, Berry, and Moss appearance presets plus supported mobile appearance options.
- Aiden workspace file index/read/version-checked write and the existing Aiden Git review/diff/compare/branch/commit/push/managed-worktree operations.
@@ -39,13 +39,13 @@ Remove Kanban, Hermes projects/profiles/personalities, Hermes Skills/Memory/Insi
## 3. Security invariants
-- Remote Access is off by default and has no listener until enabled on the Mac.
+- Remote Access is off by default and has no listener until enabled on the desktop.
- Tailscale supplies reachability, never app authorization. Aiden manages only the exact non-Funnel Serve route it owns and never invokes `tailscale serve reset`.
- Local-network production transport is HTTPS. QR pairing pins the Aiden installation's stable P-256 SPKI SHA-256 fingerprint. Plain HTTP is development-build-only.
- Pairing secrets are high entropy, short lived, single use, rate limited, and never logged. The reviewed manual path uses a uniformly random 100-bit Crockford code only as a local HKDF input for authenticated decryption of the existing certificate-pinned trust envelope; lower-entropy human-sized codes still require a reviewed PAKE/SAS or explicit fingerprint confirmation.
-- Device credentials are random, stored as digests on Mac and in Keychain on iOS, capability scoped, revocable, and never placed in URLs, App Group data, App Intents, logs, or Live Activities.
+- Device credentials are random, stored as digests on the desktop and in Keychain on iOS, capability scoped, revocable, and never placed in URLs, App Group data, App Intents, logs, or Live Activities.
- DTOs are allowlists. Absolute paths, provider/MCP credentials, raw diagnostics, Git admin paths/tokens, schedule runtime internals, and private agent history never cross the API.
-- Directory and file handles are opaque server-side capabilities bound to instance, device, workspace/root identity, policy revision, expiry, and snapshot. The client never submits a free-form Mac path.
+- Directory and file handles are opaque server-side capabilities bound to instance, device, workspace/root identity, policy revision, expiry, and snapshot. The client never submits a free-form desktop path.
- Workspace selection consumption and workspace creation are atomic and idempotent. Filesystem identity and canonical root membership are revalidated immediately before mutation.
- Workspace turns honor the workspace's saved `full`, `ask`, or `none` permission. Bot turns instead honor a main-owned, revisioned Full/Custom policy: Full is explicit after the current notice, Custom uses exact reductions, and corrupt, missing-after-migration, or future-version policy state fails closed. Neither transport can mint Assistant/unattended modes or silently enable Computer Use.
- Every bot has exactly one main-owned managed home. The Mac sets it as the shell/tool working directory and ordinary save location, does not initialize `.git`, and injects the operating contract after editable bot instructions so a phone, renderer, or prompt cannot replace it. Full Access may inspect other OS-accessible Mac locations only as needed and remains subject to OS permissions, global disables, approvals, and destructive-action safeguards.
diff --git a/ios/README.md b/ios/README.md
index 7b578224..03eac8a2 100644
--- a/ios/README.md
+++ b/ios/README.md
@@ -1,6 +1,6 @@
# Aiden On The Go
-Aiden On The Go is the native SwiftUI companion for Aiden Agent on macOS. The Mac owns execution, persistence, providers, workspaces, and permissions; iPhone and iPad provide an authenticated remote control surface over a local network or Tailscale.
+Aiden On The Go is the native SwiftUI companion for Aiden Agent on macOS and Linux. The paired desktop owns execution, persistence, providers, workspaces, and permissions; iPhone and iPad provide an authenticated remote control surface over a local network or Tailscale.
The product and protocol sources of truth are:
diff --git a/ios/app-store/MOBILE_PRIVACY_SUPPORT_COPY.md b/ios/app-store/MOBILE_PRIVACY_SUPPORT_COPY.md
index 380e6afe..92c0fdb7 100644
--- a/ios/app-store/MOBILE_PRIVACY_SUPPORT_COPY.md
+++ b/ios/app-store/MOBILE_PRIVACY_SUPPORT_COPY.md
@@ -2,37 +2,37 @@
Status: ready for owner/legal review and publication on `chatwithaiden.com`. The website source is not part of this repository, so this file does not claim that the live page has changed.
-The current public policy says Aiden is a local-first macOS app. Before submitting Aiden On The Go, replace that product-limited wording and add the following sections while preserving the existing provider and website-hosting disclosures.
+The current public policy says Aiden is a local-first macOS app. Before submitting Aiden On The Go or shipping the Linux desktop build, replace that product-limited wording and add the following sections while preserving the existing provider and website-hosting disclosures.
## Overview replacement
-Aiden is designed as a local-first Mac app with an optional native iPhone and iPad companion called Aiden On The Go. We do not collect, sell, rent, or share personal information through the Aiden website or apps. Aiden On The Go connects directly to an Aiden Agent installation that you choose and control; Aiden does not provide a hosted relay or central synchronization service for that connection.
+Aiden is designed as a local-first macOS and Linux desktop app with an optional native iPhone and iPad companion called Aiden On The Go. We do not collect, sell, rent, or share personal information through the Aiden website or apps. Aiden On The Go connects directly to an Aiden Agent installation that you choose and control; Aiden does not provide a hosted relay or central synchronization service for that connection.
## Local app data replacement
-Aiden Agent stores chat history, workspace configuration, and app settings locally on your Mac. Provider API keys are stored on your Mac, such as in macOS Keychain, and are not copied into Aiden On The Go or sent to Aiden servers.
+Aiden Agent stores chat history, workspace configuration, and app settings locally on your desktop. Provider API keys are stored in the operating system's credential store (such as macOS Keychain or a supported Linux Secret Service or KWallet backend) and are not copied into Aiden On The Go or sent to Aiden servers.
Aiden On The Go stores its pairing credential in the iPhone or iPad Keychain. It may keep device-local settings and bounded caches for paired-installation names, workspaces, chats, navigation, and last-known run status. App Intents use a limited App Group cache containing stable identifiers and display names; they do not receive the pairing credential or contact the network. You can remove a paired installation from the mobile app, revoke a device from Aiden Agent, or remove the app and its local data using normal iOS or iPadOS controls.
## Mobile remote access
-Remote Access is off by default in Aiden Agent. When you enable and pair Aiden On The Go, the mobile app connects directly to your Mac over your local network or a Tailscale connection you configure. Pairing uses a short-lived, one-use session and creates a revocable device credential. Aiden does not enable Tailscale Funnel or route this traffic through an Aiden-operated service.
+Remote Access is off by default in Aiden Agent. When you enable and pair Aiden On The Go, the mobile app connects directly to your desktop over your local network or a Tailscale connection you configure. Pairing uses a short-lived, one-use session and creates a revocable device credential. Aiden does not enable Tailscale Funnel or route this traffic through an Aiden-operated service.
-Chats, prompts, selected attachments, workspace operations, and approval decisions sent from Aiden On The Go go to the paired Mac. If a request uses an AI provider configured in Aiden Agent, the Mac may then send prompts, selected files, metadata, and responses to that provider or local model service under the provider's privacy policy, retention rules, and account settings.
+Chats, prompts, selected attachments, workspace operations, and approval decisions sent from Aiden On The Go go to the paired desktop. If a request uses an AI provider configured in Aiden Agent, the desktop may then send prompts, selected files, metadata, and responses to that provider or local model service under the provider's privacy policy, retention rules, and account settings.
## iPhone and iPad permissions
- **Local Network:** used only to discover or connect to an Aiden Agent installation on a network you choose.
- **Camera:** used when you choose to scan a pairing QR code. Camera frames are processed for pairing and are not uploaded to Aiden.
-- **Photos and Files:** content is accessed only after you select it. Selected content is sent to the paired Mac and may be processed by the AI provider you chose for the request.
-- **Microphone and Speech Recognition:** requested only when you start dictation. In **On this device** mode, the app uses the platform speech API. In **Paired Mac** mode, it sends a bounded microphone recording through the authenticated, encrypted Aiden connection to the selected local Parakeet model on your Mac; neither endpoint stores the recording. The text composer remains usable if recognition is unavailable or permission is denied.
+- **Photos and Files:** content is accessed only after you select it. Selected content is sent to the paired desktop and may be processed by the AI provider you chose for the request.
+- **Microphone and Speech Recognition:** requested only when you start dictation. In **On this device** mode, the app uses the platform speech API. In **Paired desktop** mode, it sends a bounded microphone recording through the authenticated, encrypted Aiden connection to the selected local Parakeet model on your desktop; neither endpoint stores the recording. The text composer remains usable if recognition is unavailable or permission is denied.
- **Notifications and Live Activities:** used for device-local status. Live Activities contain bounded last-known run state, use no Aiden cloud push relay, and hide assistant response excerpts by default.
## Bot image creation
Bots always include an Aiden semantic avatar that works without Apple Intelligence. On supported devices running a compatible iOS or iPadOS version, you may choose **Create with Apple Intelligence** to open Apple's system Image Playground. Apple controls image generation and may use Private Cloud Compute under Apple's privacy terms. Personalization from people or the Photos library is disabled by Aiden, and Aiden supplies only the Bot name and purpose visible in the editor as starting concepts.
-Aiden does not send Image Playground concepts, rejected candidates, or temporary file locations to Aiden's developer or to your paired Mac. Apple controls the system sheet and any Private Cloud Compute processing of the visible Bot name and purpose concepts. After you explicitly accept an image in Apple's sheet, Aiden copies it temporarily inside the app, removes metadata, center-crops and re-encodes it, and shows a preview. Only when you choose **Use this image** does Aiden send the normalized image directly to your paired Mac over the authenticated Remote Access connection. The Mac independently validates and stores its canonical copy. Temporary mobile candidates are deleted after use, cancellation, replacement, pairing changes, or editor dismissal. You can remove a generated Bot photo and return to the semantic avatar at any time.
+Aiden does not send Image Playground concepts, rejected candidates, or temporary file locations to Aiden's developer or to your paired desktop. Apple controls the system sheet and any Private Cloud Compute processing of the visible Bot name and purpose concepts. After you explicitly accept an image in Apple's sheet, Aiden copies it temporarily inside the app, removes metadata, center-crops and re-encodes it, and shows a preview. Only when you choose **Use this image** does Aiden send the normalized image directly to your paired desktop over the authenticated Remote Access connection. The desktop independently validates and stores its canonical copy. Temporary mobile candidates are deleted after use, cancellation, replacement, pairing changes, or editor dismissal. You can remove a generated Bot photo and return to the semantic avatar at any time.
Opening a web link or displaying externally hosted transcript media can make a normal network request to that third-party host. Aiden credentials are not forwarded to the host; the host may receive ordinary request information such as the device's network address under its own policy.
@@ -45,6 +45,6 @@ Questions about privacy or support for Aiden Agent and Aiden On The Go can be se
- Update the policy's “Last updated” date when this copy is published.
- Preserve the existing disclosure that third-party AI providers process requests under their own policies.
- Keep the direct statement that Aiden does not collect chats, prompts, selected files, provider keys, model responses, device identifiers, precise location, payment information, or analytics events unless the shipped product or operational services change.
-- Describe Image Playground as Apple-controlled processing that may use Private Cloud Compute; do not promise universal on-device generation. Preserve the accepted-image-only direct-to-paired-Mac boundary.
+- Describe Image Playground as Apple-controlled processing that may use Private Cloud Compute; do not promise universal on-device generation. Preserve the accepted-image-only direct-to-paired-desktop boundary.
- Make the support email visibly reachable from `https://chatwithaiden.com/`, which is the App Store support URL.
- Recheck this copy against the final distribution candidate and App Privacy answers before every submission.
diff --git a/ios/app-store/metadata/version/0.1.0/en-US.json b/ios/app-store/metadata/version/0.1.0/en-US.json
index b56efca7..173de7a1 100644
--- a/ios/app-store/metadata/version/0.1.0/en-US.json
+++ b/ios/app-store/metadata/version/0.1.0/en-US.json
@@ -1,5 +1,5 @@
{
- "description": "Aiden On The Go is the native iPhone and iPad companion for Aiden Agent on your Mac. Pair directly with a Mac you control over your local network or Tailscale, then continue Aiden chats and manage workspaces from your mobile device.\n\nReview conversations, stream responses, handle approval requests, inspect workspace files, work with supported Git flows, and manage scheduled tasks. App Intents provide quick navigation, Live Activities show bounded run status, and optional voice dictation can use native recognition or a local model on your paired Mac. Read-aloud stays on device.\n\nYour Mac remains the execution authority. Remote Access is off by default, each mobile device uses a revocable credential, and provider credentials remain on the Mac.",
+ "description": "Aiden On The Go is the native iPhone and iPad companion for Aiden Agent on your macOS or Linux desktop. Pair directly with a desktop you control over your local network or Tailscale, then continue Aiden chats and manage workspaces from your mobile device.\n\nReview conversations, stream responses, handle approval requests, inspect workspace files, work with supported Git flows, and manage scheduled tasks. App Intents provide quick navigation, Live Activities show bounded run status, and optional voice dictation can use native recognition or a local model on your paired desktop. Read-aloud stays on device.\n\nYour desktop remains the execution authority. Remote Access is off by default, each mobile device uses a revocable credential, and provider credentials remain on the desktop.",
"keywords": "AI assistant,agent,developer,Git,workspace,chat,automation,remote,Tailscale,Swift",
"marketingUrl": "https://chatwithaiden.com/",
"supportUrl": "https://chatwithaiden.com/"
diff --git a/main/application-lifecycle-core.test.ts b/main/application-lifecycle-core.test.ts
new file mode 100644
index 00000000..92092f9e
--- /dev/null
+++ b/main/application-lifecycle-core.test.ts
@@ -0,0 +1,12 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { shouldQuitAfterAllWindowsClose } from "./application-lifecycle-core.js";
+
+test("macOS retains its conventional last-window behavior", () => {
+ assert.equal(shouldQuitAfterAllWindowsClose("darwin", false), false);
+});
+
+test("Linux quits without background ownership and stays alive for Remote Access", () => {
+ assert.equal(shouldQuitAfterAllWindowsClose("linux", false), true);
+ assert.equal(shouldQuitAfterAllWindowsClose("linux", true), false);
+});
diff --git a/main/application-lifecycle-core.ts b/main/application-lifecycle-core.ts
new file mode 100644
index 00000000..87e11183
--- /dev/null
+++ b/main/application-lifecycle-core.ts
@@ -0,0 +1,6 @@
+export function shouldQuitAfterAllWindowsClose(
+ platform: NodeJS.Platform,
+ backgroundServiceRunning: boolean,
+): boolean {
+ return platform !== "darwin" && !backgroundServiceRunning;
+}
diff --git a/main/bootstrap.ts b/main/bootstrap.ts
index 38bc76fc..8570e2d2 100644
--- a/main/bootstrap.ts
+++ b/main/bootstrap.ts
@@ -5,12 +5,14 @@ import { initDiagnosticHealth } from "./services/diagnostic-health.js";
import { projectDiagnosticError } from "./services/diagnostics-contract.js";
import { pruneExpiredDiagnosticCrashDumps } from "./services/diagnostic-support.js";
import { installProcessDiagnostics } from "./services/process-diagnostics.js";
+import { applyLinuxGraphicsFlags } from "./linux-graphics-flags.js";
import { configureRuntimeProfile } from "./runtime-profile.js";
import {
initSubagentRuntimeDiagnostics,
SUBAGENT_RUNTIME_LOG_FILENAME,
} from "./services/subagents/subagent-runtime-diagnostics.js";
+applyLinuxGraphicsFlags();
const runtimeProfile = configureRuntimeProfile();
const productionDiagnosticsDisabled =
runtimeProfile.id === "production" && process.env.AIDEN_DISABLE_PRODUCTION_DIAGNOSTICS === "1";
diff --git a/main/desktop-cli-core.test.ts b/main/desktop-cli-core.test.ts
new file mode 100644
index 00000000..13c87baa
--- /dev/null
+++ b/main/desktop-cli-core.test.ts
@@ -0,0 +1,29 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { desktopVersionRequested } from "./desktop-cli-core.js";
+
+test("packaged desktop recognizes only an explicit user --version argument", () => {
+ assert.equal(desktopVersionRequested(["/opt/Aiden Agent/aiden-agent"], false), false);
+ assert.equal(
+ desktopVersionRequested(
+ ["/opt/Aiden Agent/aiden-agent", "--no-sandbox", "--version"],
+ false,
+ ),
+ true,
+ );
+});
+
+test("development desktop does not mistake the application path for an argument", () => {
+ assert.equal(
+ desktopVersionRequested(["/repo/node_modules/.bin/electron", "--version"], true),
+ false,
+ );
+ assert.equal(
+ desktopVersionRequested(
+ ["/repo/node_modules/.bin/electron", "/repo", "--version"],
+ true,
+ ),
+ true,
+ );
+});
diff --git a/main/desktop-cli-core.ts b/main/desktop-cli-core.ts
new file mode 100644
index 00000000..274e01e8
--- /dev/null
+++ b/main/desktop-cli-core.ts
@@ -0,0 +1,8 @@
+export function desktopVersionRequested(
+ argv: readonly string[],
+ defaultApp: boolean,
+): boolean {
+ // Packaged Electron starts user arguments after argv[0]. Development
+ // Electron reserves argv[1] for the application path.
+ return argv.slice(defaultApp ? 2 : 1).includes("--version");
+}
diff --git a/main/handlers/aiden-remote.test.ts b/main/handlers/aiden-remote.test.ts
index 9460963b..3a529a16 100644
--- a/main/handlers/aiden-remote.test.ts
+++ b/main/handlers/aiden-remote.test.ts
@@ -53,6 +53,16 @@ test("saved endpoint repair is an explicit IPC action", async () => {
assert.match(source, /service\.moveToAvailablePort\(\)/u);
});
+test("pairing TLS probe failures resolve as a structured IPC outcome", async () => {
+ const source = await readFile(new URL("./aiden-remote.ts", import.meta.url), "utf8");
+ const handler = source.slice(
+ source.indexOf('ipcMain.handle("remote:beginPairing"'),
+ source.indexOf('ipcMain.handle("remote:closePairing"'),
+ );
+ assert.match(handler, /AidenRemoteTlsEndpointError/u);
+ assert.match(handler, /ok: false as const/u);
+ assert.match(handler, /code: error\.code/u);
+});
test("guided setup IPC binds the acknowledgement to its live document and settings", async () => {
const source = await readFile(new URL("./aiden-remote.ts", import.meta.url), "utf8");
@@ -62,5 +72,7 @@ test("guided setup IPC binds the acknowledgement to its live document and settin
assert.match(handler, /typeof review.enabled !== "boolean"/u);
assert.match(handler, /parseAidenRemoteConnectionMode\(review.connectionMode\)/u);
assert.match(handler, /service.setupPairing/u);
+ assert.match(handler, /AidenRemoteTlsEndpointError/u);
+ assert.match(handler, /ok: false as const/u);
assert.match(handler, /!owner.isDestroyed\(\)/u);
});
diff --git a/main/handlers/aiden-remote.ts b/main/handlers/aiden-remote.ts
index 4f2fe66f..81f393b3 100644
--- a/main/handlers/aiden-remote.ts
+++ b/main/handlers/aiden-remote.ts
@@ -1,6 +1,7 @@
import { BrowserWindow, dialog, ipcMain } from "../platform.js";
import { getAidenRemoteRuntime } from "../services/aiden-remote-service-main.js";
import type { AidenRemoteSettingsSnapshot } from "../../renderer/shared/aiden-remote.js";
+import { AidenRemoteTlsEndpointError } from "../services/aiden-remote-tls-identity.js";
import { rendererDocumentOwner } from "../services/renderer-document-owner.js";
import {
parseAidenRemoteConnectionMode,
@@ -138,25 +139,39 @@ export function registerAidenRemoteHandlers(): void {
|| typeof review.enabled !== "boolean") throw new Error("Invalid phone setup review.");
const connectionMode = parseAidenRemoteConnectionMode(review.connectionMode);
const service = (await getAidenRemoteRuntime()).service;
- const pairing = await service.setupPairing(selectedTransport, {
- instanceId: review.instanceId, enabled: review.enabled, connectionMode,
- }, () => !owner.isDestroyed());
- return { ...pairing.bootstrap, pairingSessionId: pairing.sessionId,
- qrPayload: pairing.qrPayload ?? service.pairingQrPayload(pairing.bootstrap, selectedTransport),
- manualCode: pairing.manualCode };
+ try {
+ const pairing = await service.setupPairing(selectedTransport, {
+ instanceId: review.instanceId, enabled: review.enabled, connectionMode,
+ }, () => !owner.isDestroyed());
+ return { ...pairing.bootstrap, pairingSessionId: pairing.sessionId,
+ qrPayload: pairing.qrPayload ?? service.pairingQrPayload(pairing.bootstrap, selectedTransport),
+ manualCode: pairing.manualCode };
+ } catch (error) {
+ if (error instanceof AidenRemoteTlsEndpointError) {
+ return { ok: false as const, code: error.code, message: error.message };
+ }
+ throw error;
+ }
});
ipcMain.handle("remote:beginPairing", async (_event, transport: unknown) => {
const selectedTransport = parseAidenRemoteTransport(transport);
const service = (await getAidenRemoteRuntime()).service;
- const pairing = await service.beginPairing(selectedTransport);
- return {
- ...pairing.bootstrap,
- pairingSessionId: pairing.sessionId,
- qrPayload: pairing.qrPayload
- ?? service.pairingQrPayload(pairing.bootstrap, selectedTransport),
- manualCode: pairing.manualCode,
- };
+ try {
+ const pairing = await service.beginPairing(selectedTransport);
+ return {
+ ...pairing.bootstrap,
+ pairingSessionId: pairing.sessionId,
+ qrPayload: pairing.qrPayload
+ ?? service.pairingQrPayload(pairing.bootstrap, selectedTransport),
+ manualCode: pairing.manualCode,
+ };
+ } catch (error) {
+ if (error instanceof AidenRemoteTlsEndpointError) {
+ return { ok: false as const, code: error.code, message: error.message };
+ }
+ throw error;
+ }
});
ipcMain.handle("remote:closePairing", async (_event, sessionId: unknown) => {
diff --git a/main/handlers/app.ts b/main/handlers/app.ts
index 73d0d61f..c102f76f 100644
--- a/main/handlers/app.ts
+++ b/main/handlers/app.ts
@@ -16,8 +16,11 @@
* ```
*/
+import { supportsAppUpdates } from "../services/app-updater.js";
import { app, logger } from "../platform.js";
import { currentRuntimeProfile } from "../runtime-profile.js";
+import { activeLinuxDictationHoldShortcut, linuxDictationHoldSetupAvailable, linuxDictationHoldTriggerDescription } from "../services/shortcut.js";
+import { hostPlatformCapabilities } from "../services/host-platform-capabilities.js";
import { subagentsEnabled } from "../services/subagents/feature-flag.js";
// App handlers - these are the methods your app provides to the frontend
@@ -25,12 +28,24 @@ export const appHandlers = {
// Example: Get app information
getInfo: async () => {
logger.info("app", "App info requested");
+ const host = hostPlatformCapabilities();
return {
name: app.getName(),
version: app.getVersion(),
environment: currentRuntimeProfile().id,
capabilities: {
+ platform: host.platform,
subagents: subagentsEnabled(),
+ bots: host.bots,
+ appUpdates: supportsAppUpdates(),
+ computerUse: host.computerUse,
+ dockIcon: host.dockIcon,
+ accessibilityPaste: host.accessibilityPaste,
+ dictationHoldToTalk: host.dictationHoldToTalk || activeLinuxDictationHoldShortcut(),
+ dictationHoldSetup: linuxDictationHoldSetupAvailable(),
+ dictationHoldTrigger: linuxDictationHoldTriggerDescription(),
+ nativeShare: host.nativeShare,
+ appleFoundationModels: host.appleFoundationModels,
},
};
},
diff --git a/main/handlers/bots-platform-contract.test.ts b/main/handlers/bots-platform-contract.test.ts
new file mode 100644
index 00000000..cd0e0066
--- /dev/null
+++ b/main/handlers/bots-platform-contract.test.ts
@@ -0,0 +1,38 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+test("Bot IPC registration is narrowed by the main-owned host policy", () => {
+ const source = readFileSync(new URL("./index.ts", import.meta.url), "utf8");
+ assert.match(
+ source,
+ /if \(hostPlatformCapabilities\(\)\.bots\) registerBotHandlers\(\);\s+registerBtwHandlers\(\)/u,
+ );
+});
+
+test("ordinary chat paths cannot activate Bot services on unsupported hosts", () => {
+ const chatHandlers = readFileSync(new URL("./chats.ts", import.meta.url), "utf8");
+ const llmClient = readFileSync(
+ new URL("../services/llm-client.ts", import.meta.url),
+ "utf8",
+ );
+ assert.match(
+ chatHandlers,
+ /if \(source\.botId\) \{\s+if \(!hostPlatformCapabilities\(\)\.bots\)/u,
+ );
+ assert.match(
+ chatHandlers,
+ /const result = chat\?\.botId && hostPlatformCapabilities\(\)\.bots\s+\? await botApplicationService\.deleteChat\(\{ botId: chat\.botId, chatId \}\)\s+: await chatApplicationService\.remove\(chatId\)/u,
+ );
+ assert.match(chatHandlers, /await memoryStore\.deleteScope\(\{ kind: "bot", id: chat\.botId \}\)/u);
+ assert.match(
+ llmClient,
+ /if \(chat\.botId && !hostPlatformCapabilities\(\)\.bots\) \{\s+throw new Error\("Bot chats are not available on this platform\."\)/u,
+ );
+});
+
+test("Bot keyring initialization failure leaves ordinary application startup available", () => {
+ const source = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
+ assert.match(source, /if \(hostPlatformCapabilities\(\)\.bots\) \{\s+try \{\s+await initializeBotApplicationService\(\);\s+\} catch \(error\)/u);
+ assert.match(source, /Bot storage could not be restored safely; the rest of Aiden will remain available for repair\./u);
+});
diff --git a/main/handlers/bots.contract.test.ts b/main/handlers/bots.contract.test.ts
index 65018d97..7bcebb3f 100644
--- a/main/handlers/bots.contract.test.ts
+++ b/main/handlers/bots.contract.test.ts
@@ -175,7 +175,7 @@ test("Remote production wires Bot notice and retained-chat policy authority", ()
assert.match(remote, /revokeNoticeAudience\(deviceId\)/u);
assert.match(
remote,
- /const revoked = await revokeAidenRemoteRuntimeDevice[\s\S]*await botApplicationService\.revokeNoticeAudience\(deviceId\);\s*return revoked;/u,
+ /const revoked = await revokeAidenRemoteRuntimeDevice[\s\S]*if \(hostPlatformCapabilities\(\)\.bots\) \{\s*await botApplicationService\.revokeNoticeAudience\(deviceId\);\s*\}\s*return revoked;/u,
);
});
@@ -189,7 +189,11 @@ test("Telegram authority reduction stays independent from Bot mutation health",
new URL("../services/telegram/telegram-bot-bindings.ts", import.meta.url),
"utf8",
);
- assert.match(bindings, /authority:\s*\{[\s\S]*createTelegramBotBindingKeychainAnchor/u);
+ assert.match(bindings, /createBotAuthorities\(\{ account \}\)/u);
+ assert.match(bindings, /authority:\s*\{\s*head: authorities!\.telegramAnchor,\s*bootstrap: authorities!\.telegramBootstrapMarker/u);
+ const authorityFactory = readFileSync(new URL("../services/bot-capability-authority.ts", import.meta.url), "utf8");
+ assert.match(authorityFactory, /platform === "darwin"[\s\S]*telegramAnchor: keychain\.createTelegramBotBindingKeychainAnchor/u);
+ assert.match(authorityFactory, /platform === "linux"[\s\S]*telegramAnchor: secretService\.createTelegramBotBindingSecretServiceAnchor/u);
assert.match(bindings, /createTelegramBotBindingAuthorityNarrower\(telegramBotBindings\)/u);
assert.match(handlers, /bots:unbindTelegram[\s\S]*telegramBotBindingAuthority\.disableBot/u);
assert.match(botMain, /disableBinding: \(botId\)[\s\S]*telegramBotBindingAuthority\.disableBot/u);
diff --git a/main/handlers/chats.ts b/main/handlers/chats.ts
index a20dcd56..f383e14e 100644
--- a/main/handlers/chats.ts
+++ b/main/handlers/chats.ts
@@ -60,6 +60,7 @@ import {
compactDesktopChat,
} from "../services/context-lifecycle-adapters.js";
import { botApplicationService } from "../services/bot-application-service-main.js";
+import { hostPlatformCapabilities } from "../services/host-platform-capabilities.js";
import { piCompactionSessionStore } from "../services/pi-compaction-session-store.js";
import { memoryStore } from "../services/memory-store-main.js";
import { isTodoSnapshotFailure, replayTodoState } from "../services/rpiv-todo/replay.js";
@@ -293,6 +294,9 @@ export function registerChatHistoryHandlers(): void {
}
const runCopy = async () => {
if (source.botId) {
+ if (!hostPlatformCapabilities().bots) {
+ throw new Error("Bot chats are not available on this platform.");
+ }
const assertCurrent = () => {
if (owner.isDestroyed()) {
throw new Error("The application changed before the Bot chat was copied.");
@@ -543,7 +547,7 @@ export function registerChatHistoryHandlers(): void {
ipcMain.handle("chats:remove", async (_event, id: unknown) => {
const chatId = asString(id, "id");
const chat = await chatStore.get(chatId);
- const result = chat?.botId
+ const result = chat?.botId && hostPlatformCapabilities().bots
? await botApplicationService.deleteChat({ botId: chat.botId, chatId })
: await chatApplicationService.remove(chatId);
if (chat?.botId) await memoryStore.deleteScope({ kind: "bot", id: chat.botId });
diff --git a/main/handlers/computer-use.ts b/main/handlers/computer-use.ts
index e0b7a453..7cdb6aa9 100644
--- a/main/handlers/computer-use.ts
+++ b/main/handlers/computer-use.ts
@@ -1,6 +1,10 @@
import { ipcMain } from "../platform.js";
import { computerUseStatus } from "../services/computer-use/status.js";
import { computerUseSettings } from "../services/computer-use/settings.js";
+import {
+ computerUseSupported,
+ unsupportedComputerUseStatus,
+} from "../services/computer-use/platform.js";
import {
rendererDocumentOwner,
type RendererDocumentOwner,
@@ -32,14 +36,23 @@ async function ownedStatusRequest(
}
export function registerComputerUseHandlers(): void {
- ipcMain.handle("computerUse:status", async (event, force: unknown) =>
- ownedStatusRequest(event, (_owner, signal) =>
+ ipcMain.handle("computerUse:status", async (event, force: unknown) => {
+ if (!computerUseSupported()) {
+ requestOwner(event);
+ return unsupportedComputerUseStatus();
+ }
+ return ownedStatusRequest(event, (_owner, signal) =>
computerUseStatus.status({ force: force === true, signal }),
- ),
- );
+ );
+ });
ipcMain.handle("computerUse:setEnabled", async (event, enabled: unknown) => {
if (typeof enabled !== "boolean") throw new Error("Invalid Computer Use setting.");
+ if (!computerUseSupported()) {
+ requestOwner(event);
+ if (enabled) throw new Error("Computer Use is not available on this platform.");
+ return unsupportedComputerUseStatus();
+ }
return ownedStatusRequest(event, async (owner, signal) => {
await computerUseSettings.setEnabled(enabled, () => !owner.isDestroyed());
if (owner.isDestroyed()) throw new Error("The renderer document is no longer active.");
@@ -47,7 +60,13 @@ export function registerComputerUseHandlers(): void {
});
});
- ipcMain.handle("computerUse:requestPermissions", async (event) =>
- ownedStatusRequest(event, (_owner, signal) => computerUseStatus.requestPermissions({ signal })),
- );
+ ipcMain.handle("computerUse:requestPermissions", async (event) => {
+ if (!computerUseSupported()) {
+ requestOwner(event);
+ return unsupportedComputerUseStatus();
+ }
+ return ownedStatusRequest(event, (_owner, signal) =>
+ computerUseStatus.requestPermissions({ signal }),
+ );
+ });
}
diff --git a/main/handlers/diagnostics.ts b/main/handlers/diagnostics.ts
index 1b71519c..fb993655 100644
--- a/main/handlers/diagnostics.ts
+++ b/main/handlers/diagnostics.ts
@@ -142,7 +142,7 @@ export function registerDiagnosticHandlers(): void {
type: "warning",
title: "Include sensitive crash dumps?",
message: "Crash memory may contain prompts, workspace content, credentials, or other in-memory data.",
- detail: "The export stays on this Mac. Aiden will not upload it.",
+ detail: "The export stays on this device. Aiden will not upload it.",
buttons: ["Cancel", "Include & export"],
defaultId: 0,
cancelId: 0,
@@ -183,7 +183,7 @@ export function registerDiagnosticHandlers(): void {
type: "warning",
title: "Enable local crash capture?",
message: "Crash memory may contain prompts, workspace content, credentials, or other in-memory data.",
- detail: "Dumps stay on this Mac, are never uploaded automatically, and capture turns off when Aiden restarts.",
+ detail: "Dumps stay on this device, are never uploaded automatically, and capture turns off when Aiden restarts.",
buttons: ["Cancel", "Enable until restart"],
defaultId: 0,
cancelId: 0,
diff --git a/main/handlers/index.ts b/main/handlers/index.ts
index cd150e86..c981327c 100644
--- a/main/handlers/index.ts
+++ b/main/handlers/index.ts
@@ -28,6 +28,7 @@ import { registerAidenRemoteHandlers } from "./aiden-remote.js";
import { registerPeerHostHandlers } from "./peer-hosts.js";
import { registerBotHandlers } from "./bots.js";
import { registerDiagnosticHandlers } from "./diagnostics.js";
+import { hostPlatformCapabilities } from "../services/host-platform-capabilities.js";
import { registerBtwHandlers } from "./btw.js";
import { initializeAdvisorRuntime } from "../services/advisor-runtime-main.js";
@@ -66,7 +67,7 @@ export function registerHandlers(): void {
registerSubagentHandlers();
registerAidenRemoteHandlers();
registerPeerHostHandlers();
- registerBotHandlers();
+ if (hostPlatformCapabilities().bots) registerBotHandlers();
registerBtwHandlers();
logger.info("handlers", "✓ IPC handlers registered");
diff --git a/main/handlers/profile.ts b/main/handlers/profile.ts
index b6d66714..d11c39c0 100644
--- a/main/handlers/profile.ts
+++ b/main/handlers/profile.ts
@@ -8,7 +8,7 @@ export function registerProfileHandlers(): void {
if (typeof value !== "string") throw new Error("Profile name must be text.");
return profileService.setName(value);
});
- ipcMain.handle("profile:shareImage", async (event, dataUrl: unknown) => {
- await shareProfilePng(dataUrl, BrowserWindow.fromWebContents(event.sender));
- });
+ ipcMain.handle("profile:shareImage", async (event, dataUrl: unknown) =>
+ shareProfilePng(dataUrl, BrowserWindow.fromWebContents(event.sender)),
+ );
}
diff --git a/main/handlers/providers.ts b/main/handlers/providers.ts
index 9dd9ef94..6de44ae9 100644
--- a/main/handlers/providers.ts
+++ b/main/handlers/providers.ts
@@ -2,6 +2,8 @@ import { isCompactionEngine } from "../../renderer/shared/compaction.js";
// Provider configuration + API key IPC handlers. Thin — logic lives in services.
import { ipcMain } from "../platform.js";
+import { activeLinuxDictationHoldShortcut, bindLinuxDictationHoldShortcut, disableLinuxDictationHoldShortcut } from "../services/shortcut.js";
+import { DictationHoldSettingsTransaction } from "../services/dictation-hold-settings.js";
import { configStore } from "../services/config-store.js";
import { skillRegistry } from "../services/skill-registry-main.js";
import { canUseStoredProviderKey } from "../services/provider-key-policy.js";
@@ -236,6 +238,12 @@ async function refreshProviderCatalogs(providerIds?: readonly string[], force =
};
}
+const linuxHoldSettings = new DictationHoldSettingsTransaction({
+ active: activeLinuxDictationHoldShortcut,
+ bind: bindLinuxDictationHoldShortcut,
+ disable: disableLinuxDictationHoldShortcut,
+});
+
export function registerProviderHandlers(): void {
forwardCodexProviderStatusChanges(
providerRegistry.codex,
@@ -528,13 +536,15 @@ export function registerProviderHandlers(): void {
next.dictationAccelerator = p.dictationAccelerator;
if (
p.chatTitleProviderId === "automatic" ||
- p.chatTitleProviderId === "apple-foundation-models" ||
+ (p.chatTitleProviderId === "apple-foundation-models" && process.platform === "darwin") ||
p.chatTitleProviderId === "chat-model"
) {
next.chatTitleProviderId = p.chatTitleProviderId;
}
if (p.appearance !== undefined) next.appearance = parseAppearanceConfig(p.appearance);
- const saved = await configStore.setSettings(next);
+ const saved = process.platform === "linux" && next.dictationHoldToTalk !== undefined
+ ? await linuxHoldSettings.apply(next.dictationHoldToTalk, (isCurrent) => configStore.setSettings(next, isCurrent))
+ : await configStore.setSettings(next);
if (next.skillsEnabled !== undefined) {
skillRegistry.invalidate();
invalidateBotRuntimeInventoryAuthority("skill_configuration");
diff --git a/main/handlers/title-providers.ts b/main/handlers/title-providers.ts
index 0df251de..c0953692 100644
--- a/main/handlers/title-providers.ts
+++ b/main/handlers/title-providers.ts
@@ -1,9 +1,14 @@
import { ipcMain } from "../platform.js";
import { foundationModelsConnection } from "../services/foundation-models-connection.js";
+import { hostPlatformCapabilities } from "../services/host-platform-capabilities.js";
export function registerTitleProviderHandlers(): void {
- ipcMain.handle("titleProviders:status", async () => foundationModelsConnection.status());
- ipcMain.handle("titleProviders:refresh", async () =>
- foundationModelsConnection.status({ force: true }),
- );
+ ipcMain.handle("titleProviders:status", async () => {
+ if (!hostPlatformCapabilities().appleFoundationModels) return null;
+ return foundationModelsConnection.status();
+ });
+ ipcMain.handle("titleProviders:refresh", async () => {
+ if (!hostPlatformCapabilities().appleFoundationModels) return null;
+ return foundationModelsConnection.status({ force: true });
+ });
}
diff --git a/main/index.ts b/main/index.ts
index 69f148d6..a497ac28 100644
--- a/main/index.ts
+++ b/main/index.ts
@@ -18,6 +18,7 @@ import { browserService } from "./services/browser/service.js";
import { registerBrowserHandlers } from "./handlers/browser.js";
import { TerminalHistoryStore } from "./services/terminal-history.js";
import { getPreloadPath, getWindowUrl } from "./windows/window-paths.js";
+import { mainWindowOptions } from "./windows/main-window-options.js";
import {
initShortcut,
initDictationShortcut,
@@ -69,6 +70,7 @@ import {
effectiveBindings,
migrateLegacyKeybindings,
} from "../renderer/shared/keybindings.js";
+import { applicationMenuTemplate } from "./services/application-menu-core.js";
import type { NotificationChannel } from "../renderer/preload-channels.js";
import type { AppSettings, Chat } from "./services/types.js";
import { ONBOARDING_COMPLETE_STORAGE_KEY } from "../renderer/shared/onboarding.js";
@@ -126,6 +128,7 @@ import {
import { rendererDocumentOwner } from "./services/renderer-document-owner.js";
import { decideRendererRecovery } from "./services/renderer-crash-recovery.js";
import {
+ aidenRemoteServiceKeepsApplicationAlive,
initializeAidenRemoteService,
stopAidenRemoteServiceAndSettle,
} from "./services/aiden-remote-service-main.js";
@@ -133,6 +136,20 @@ import { initializeBotApplicationService } from "./services/bot-application-serv
import { botSkillContentWatcher } from "./services/bot-capability-services-main.js";
import { geminiLiveTranscription } from "./services/gemini-live-transcription.js";
import { mainWindowState } from "./services/main-window-state.js";
+import { desktopVersionRequested } from "./desktop-cli-core.js";
+import { shouldQuitAfterAllWindowsClose } from "./application-lifecycle-core.js";
+import { hostPlatformCapabilities } from "./services/host-platform-capabilities.js";
+
+if (desktopVersionRequested(process.argv, process.defaultApp === true)) {
+ process.stdout.write(`${app.getVersion()}\n`);
+ app.exit(0);
+}
+
+if (process.platform === "linux") {
+ // Supporting Wayland compositors can register Electron global shortcuts
+ // through the desktop portal instead of relying on X11 key grabs.
+ app.commandLine.appendSwitch("enable-features", "GlobalShortcutsPortal");
+}
registerGenerativeUiScheme();
@@ -933,14 +950,7 @@ async function applyDockIconPreference(
preference: DockIconPreference,
): Promise {
if (process.platform !== "darwin" || !app.dock) return false;
- const iconPath =
- preference === "monochrome"
- ? isPackagedRuntime()
- ? path.join(process.resourcesPath, "app-icon-monochrome.png")
- : path.join(app.getAppPath(), "resources", "app-icon-monochrome.png")
- : isPackagedRuntime()
- ? path.join(process.resourcesPath, "app-icon.png")
- : path.join(app.getAppPath(), "resources", "app-icon.png");
+ const iconPath = applicationIconPath(preference === "monochrome");
const icon = nativeImage.createFromPath(iconPath);
if (icon.isEmpty())
throw new Error(`Dock icon is unavailable: ${path.basename(iconPath)}`);
@@ -949,6 +959,13 @@ async function applyDockIconPreference(
return true;
}
+function applicationIconPath(monochrome = false): string {
+ const fileName = monochrome ? "app-icon-monochrome.png" : "app-icon.png";
+ return isPackagedRuntime()
+ ? path.join(process.resourcesPath, fileName)
+ : path.join(app.getAppPath(), "resources", fileName);
+}
+
async function restoreDockIconPreference(
preference: DockIconPreference,
): Promise {
@@ -996,6 +1013,11 @@ function openExternalUrl(value: string): void {
}
}
+function refreshFoundationModelsStatus(force = false): void {
+ if (!hostPlatformCapabilities().appleFoundationModels) return;
+ void foundationModelsConnection.status(force ? { force: true } : undefined);
+}
+
async function createMainWindow(): Promise {
let rendererCrashTimes: number[] = [];
// macOS activate, a second-instance event, or a newly registered global
@@ -1029,24 +1051,16 @@ async function createMainWindow(): Promise {
}
mainWindow = new BrowserWindow({
+ ...mainWindowOptions(
+ getPreloadPath(),
+ process.platform,
+ nativeTheme.shouldUseDarkColors,
+ ),
...restoredWindowState.bounds,
- minWidth: 390,
- minHeight: 456,
title: app.getName(),
- titleBarStyle: "hiddenInset",
- // Center the 12px macOS window controls in the renderer's 52px top bar.
- trafficLightPosition: { x: 14, y: 20 },
- backgroundColor: "#00000000",
- transparent: true,
- vibrancy: "sidebar",
- visualEffectState: "active",
- show: false,
- webPreferences: {
- preload: getPreloadPath(),
- contextIsolation: true,
- nodeIntegration: false,
- sandbox: true,
- },
+ ...(process.platform === "linux"
+ ? { icon: nativeImage.createFromPath(applicationIconPath()) }
+ : {}),
});
resetRendererReadiness();
@@ -1498,99 +1512,23 @@ function setupApplicationMenu(
const bindings = effectiveBindings(
migrateLegacyKeybindings(settings.keybindings, settings),
);
- const command = (commandId: keyof typeof bindings) =>
- bindings[commandId] ?? undefined;
- const menu = Menu.buildFromTemplate([
- {
- label: app.getName(),
- submenu: [
- { role: "about" },
- {
- label: "Check for Updates…",
- click: () => void appUpdateService.checkNow(true),
- },
- { type: "separator" },
- {
- label: "Command Palette…",
- accelerator: command("commandPalette.toggle"),
- click: () =>
- deliverMainWindowNotificationSafely("app:command", {
- commandId: "commandPalette.toggle",
- }),
- },
- {
- label: "Settings…",
- accelerator: command("settings.open"),
- click: () =>
- deliverMainWindowNotificationSafely("app:command", {
- commandId: "settings.open",
- }),
- },
- { type: "separator" },
- { role: "services" },
- { type: "separator" },
- { role: "hide" },
- { role: "hideOthers" },
- { role: "unhide" },
- { type: "separator" },
- { role: "quit" },
- ],
- },
- {
- label: "File",
- submenu: [
- {
- label: "New Chat",
- accelerator: command("chat.new"),
- click: () =>
- deliverMainWindowNotificationSafely("app:command", {
- commandId: "chat.new",
- }),
- },
- {
- label: "Open Workspace in Preferred Editor",
- accelerator: command("workspace.openPreferredEditor"),
- click: () =>
- deliverMainWindowNotificationSafely("app:command", {
- commandId: "workspace.openPreferredEditor",
- }),
- },
- { type: "separator" },
- { role: "close" },
- ],
- },
- { role: "editMenu" },
- {
- label: "View",
- submenu: [
- {
- label: "Reload",
- accelerator: "Command+R",
- click: () => {
- if (mainWindow && !mainWindow.isDestroyed())
- void requestWindowReload(mainWindow);
- },
- },
- {
- label: "Force Reload",
- accelerator: "Command+Shift+R",
- click: () => {
- if (mainWindow && !mainWindow.isDestroyed()) {
- void requestWindowReload(mainWindow, { ignoreCache: true });
- }
- },
+ const menu = Menu.buildFromTemplate(
+ applicationMenuTemplate({
+ platform: process.platform,
+ appName: app.getName(),
+ bindings,
+ actions: {
+ checkForUpdates: () => void appUpdateService.checkNow(true),
+ deliverCommand: (commandId) =>
+ deliverMainWindowNotificationSafely("app:command", { commandId }),
+ reload: (ignoreCache) => {
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ void requestWindowReload(mainWindow, { ignoreCache });
+ }
},
- { role: "toggleDevTools" },
- { type: "separator" },
- { role: "resetZoom" },
- { role: "zoomIn" },
- { role: "zoomOut" },
- { type: "separator" },
- { role: "togglefullscreen" },
- ],
- },
- { role: "windowMenu" },
- ]);
+ },
+ }),
+ );
Menu.setApplicationMenu(menu);
}
@@ -1621,14 +1559,23 @@ if (!ownsSingleInstanceLock) {
app.on("second-instance", () => showMainWindow());
app.on("window-all-closed", () => {
+ const backgroundServiceRunning = aidenRemoteServiceKeepsApplicationAlive();
logger.info("electron-lifecycle", "All application windows closed", {
platform: process.platform,
+ backgroundServiceRunning,
});
- if (process.platform !== "darwin") app.quit();
+ if (
+ shouldQuitAfterAllWindowsClose(
+ process.platform,
+ backgroundServiceRunning,
+ )
+ ) {
+ app.quit();
+ }
});
app.on("activate", () => {
- void foundationModelsConnection.status({ force: true });
+ refreshFoundationModelsStatus(true);
showMainWindow();
});
@@ -1867,14 +1814,16 @@ if (!ownsSingleInstanceLock) {
);
}
}
- try {
- await initializeBotApplicationService();
- } catch (error) {
- logger.error(
- "bots",
- "Bot storage could not be restored safely; the rest of Aiden will remain available for repair.",
- error,
- );
+ if (hostPlatformCapabilities().bots) {
+ try {
+ await initializeBotApplicationService();
+ } catch (error) {
+ logger.error(
+ "bots",
+ "Bot storage could not be restored safely; the rest of Aiden will remain available for repair.",
+ error,
+ );
+ }
}
// One-time legacy cleanup runs after recoverable artifacts and Bot identity
// restoration, but before renderers, schedules, or remote clients can write.
@@ -2011,7 +1960,7 @@ if (!ownsSingleInstanceLock) {
error,
);
}
- void foundationModelsConnection.status();
+ refreshFoundationModelsStatus();
resolveShortcutInitialization?.();
resolveShortcutInitialization = null;
diff --git a/main/linux-graphics-flags.ts b/main/linux-graphics-flags.ts
new file mode 100644
index 00000000..29bc4eda
--- /dev/null
+++ b/main/linux-graphics-flags.ts
@@ -0,0 +1,12 @@
+import { app } from "electron";
+import { disableVulkanFeature, shouldSuppressOzoneWaylandVulkan } from "./linux-wayland-vulkan-core.js";
+
+export function applyLinuxGraphicsFlags(): void {
+ const ozonePlatformOverride = app.commandLine.hasSwitch("ozone-platform")
+ ? app.commandLine.getSwitchValue("ozone-platform")
+ : undefined;
+ if (!shouldSuppressOzoneWaylandVulkan(process.platform, process.env, ozonePlatformOverride)) {
+ return;
+ }
+ app.commandLine.appendSwitch("disable-features", disableVulkanFeature(app.commandLine.getSwitchValue("disable-features")));
+}
diff --git a/main/linux-wayland-vulkan-core.test.ts b/main/linux-wayland-vulkan-core.test.ts
new file mode 100644
index 00000000..7b3aaf58
--- /dev/null
+++ b/main/linux-wayland-vulkan-core.test.ts
@@ -0,0 +1,35 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ disableVulkanFeature,
+ isWaylandSession,
+ shouldSuppressOzoneWaylandVulkan,
+} from "./linux-wayland-vulkan-core.js";
+
+test("Wayland session detection mirrors Chromium ozone auto-selection", () => {
+ assert.equal(isWaylandSession({ XDG_SESSION_TYPE: "wayland" }), true);
+ assert.equal(isWaylandSession({ XDG_SESSION_TYPE: "Wayland" }), true);
+ assert.equal(isWaylandSession({ WAYLAND_DISPLAY: "wayland-0" }), true);
+ assert.equal(isWaylandSession({ XDG_SESSION_TYPE: "x11" }), false);
+ assert.equal(isWaylandSession({ XDG_SESSION_TYPE: "tty" }), false);
+ assert.equal(isWaylandSession({ WAYLAND_DISPLAY: " " }), false);
+ assert.equal(isWaylandSession({}), false);
+});
+
+test("Vulkan suppression is Linux Wayland only and respects an explicit X11 ozone override", () => {
+ const wayland = { XDG_SESSION_TYPE: "wayland" };
+ assert.equal(shouldSuppressOzoneWaylandVulkan("linux", wayland), true);
+ assert.equal(shouldSuppressOzoneWaylandVulkan("linux", { WAYLAND_DISPLAY: "wayland-1" }), true);
+ assert.equal(shouldSuppressOzoneWaylandVulkan("linux", wayland, "x11"), false);
+ assert.equal(shouldSuppressOzoneWaylandVulkan("linux", wayland, "X11"), false);
+ assert.equal(shouldSuppressOzoneWaylandVulkan("linux", { XDG_SESSION_TYPE: "x11" }), false);
+ assert.equal(shouldSuppressOzoneWaylandVulkan("darwin", wayland), false);
+ assert.equal(shouldSuppressOzoneWaylandVulkan("win32", wayland), false);
+ assert.equal(shouldSuppressOzoneWaylandVulkan("linux", {}), false);
+});
+
+test("Vulkan suppression preserves existing disabled features without duplicates", () => {
+ assert.equal(disableVulkanFeature(""), "Vulkan");
+ assert.equal(disableVulkanFeature("ExistingFeature, AnotherFeature"), "ExistingFeature,AnotherFeature,Vulkan");
+ assert.equal(disableVulkanFeature("ExistingFeature,Vulkan"), "ExistingFeature,Vulkan");
+});
diff --git a/main/linux-wayland-vulkan-core.ts b/main/linux-wayland-vulkan-core.ts
new file mode 100644
index 00000000..6ac71993
--- /dev/null
+++ b/main/linux-wayland-vulkan-core.ts
@@ -0,0 +1,18 @@
+export function isWaylandSession(env: NodeJS.ProcessEnv = process.env): boolean {
+ if (env.XDG_SESSION_TYPE?.trim().toLowerCase() === "wayland") return true;
+ return (env.WAYLAND_DISPLAY ?? "").trim().length > 0;
+}
+
+export function shouldSuppressOzoneWaylandVulkan(
+ platform: NodeJS.Platform = process.platform,
+ env: NodeJS.ProcessEnv = process.env,
+ ozonePlatformOverride?: string,
+): boolean {
+ if (platform !== "linux") return false;
+ if (ozonePlatformOverride?.trim().toLowerCase() === "x11") return false;
+ return isWaylandSession(env);
+}
+
+export function disableVulkanFeature(existing: string): string {
+ return [...new Set([...existing.split(",").map((value) => value.trim()).filter(Boolean), "Vulkan"])].join(",");
+}
diff --git a/main/platform.ts b/main/platform.ts
index 7ee76d25..34bad5b6 100644
--- a/main/platform.ts
+++ b/main/platform.ts
@@ -23,6 +23,7 @@ import {
formatDiagnosticConsole,
writeLegacyDiagnostic,
} from "./services/diagnostic-journal.js";
+import { hostPlatformCapabilities } from "./services/host-platform-capabilities.js";
type LogValue = unknown;
@@ -81,6 +82,7 @@ const ACCESSIBILITY_SETTINGS_URL =
"x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility";
async function waitForAccessibilityTrust(): Promise {
+ if (!hostPlatformCapabilities().accessibilityPaste) return false;
for (let attempt = 0; attempt < 10; attempt += 1) {
if (systemPreferences.isTrustedAccessibilityClient(false)) return true;
await new Promise((resolve) => setTimeout(resolve, 200));
@@ -115,15 +117,22 @@ export function registerNativeHandlers(): void {
electronIpcMain.handle(
"aiden:media:status",
(_event, mediaType: "microphone" | "camera" | "screen") =>
- systemPreferences.getMediaAccessStatus(mediaType),
+ process.platform === "darwin"
+ ? systemPreferences.getMediaAccessStatus(mediaType)
+ : "unknown",
);
electronIpcMain.handle("aiden:media:request", (_event, mediaType: "microphone" | "camera") =>
- systemPreferences.askForMediaAccess(mediaType),
+ process.platform === "darwin"
+ ? systemPreferences.askForMediaAccess(mediaType)
+ : true,
);
electronIpcMain.handle("aiden:accessibility:status", () =>
- systemPreferences.isTrustedAccessibilityClient(false),
+ hostPlatformCapabilities().accessibilityPaste
+ ? systemPreferences.isTrustedAccessibilityClient(false)
+ : false,
);
electronIpcMain.handle("aiden:accessibility:request", async (event) => {
+ if (!hostPlatformCapabilities().accessibilityPaste) return false;
const parent = BrowserWindow.fromWebContents(event.sender);
parent?.show();
parent?.focus();
@@ -133,6 +142,7 @@ export function registerNativeHandlers(): void {
return waitForAccessibilityTrust();
});
electronIpcMain.handle("aiden:accessibility:open-settings", async () => {
+ if (!hostPlatformCapabilities().accessibilityPaste) return false;
await shell.openExternal(ACCESSIBILITY_SETTINGS_URL);
return true;
});
diff --git a/main/runtime-profile-bootstrap.test.ts b/main/runtime-profile-bootstrap.test.ts
index d39188a7..8e800b9b 100644
--- a/main/runtime-profile-bootstrap.test.ts
+++ b/main/runtime-profile-bootstrap.test.ts
@@ -5,14 +5,23 @@ import test from "node:test";
test("runtime identity is configured before the main module can take its lock", () => {
const bootstrap = readFileSync(new URL("./bootstrap.ts", import.meta.url), "utf8");
const main = readFileSync(new URL("./index.ts", import.meta.url), "utf8");
+ const graphicsFlags = bootstrap.indexOf("applyLinuxGraphicsFlags()");
const configure = bootstrap.indexOf("configureRuntimeProfile()");
const loadMain = bootstrap.indexOf('await import("./index.js")');
- assert.ok(configure >= 0 && loadMain > configure);
+ assert.ok(graphicsFlags >= 0 && configure > graphicsFlags && loadMain > configure);
assert.match(main, /app\.requestSingleInstanceLock\(\)/u);
assert.doesNotMatch(main, /app\.setName\(/u);
});
+test("Linux Wayland launches disable Chromium Vulkan before the main module loads", () => {
+ const bootstrap = readFileSync(new URL("./bootstrap.ts", import.meta.url), "utf8");
+ const flags = readFileSync(new URL("./linux-graphics-flags.ts", import.meta.url), "utf8");
+ assert.match(bootstrap, /applyLinuxGraphicsFlags\(\)/u);
+ assert.match(flags, /appendSwitch\("disable-features", disableVulkanFeature\(app\.commandLine\.getSwitchValue\("disable-features"\)\)\)/u);
+ assert.doesNotMatch(flags, /disableHardwareAcceleration/u);
+});
+
test("the Electron build enters through the profile bootstrap", () => {
const buildScript = readFileSync(
new URL("../scripts/build-electron.mjs", import.meta.url),
@@ -49,7 +58,7 @@ test("development shortcut registration is gated without removing in-app menu ac
test("visible main-process branding derives from the configured app name", () => {
const main = readFileSync(new URL("./index.ts", import.meta.url), "utf8");
assert.match(main, /title: app\.getName\(\)/u);
- assert.match(main, /label: app\.getName\(\)/u);
+ assert.match(main, /appName: app\.getName\(\)/u);
assert.match(main, /app\.dock\?\.setBadge\("DEV"\)/u);
});
@@ -72,6 +81,40 @@ test("optional background services cannot close an already visible desktop windo
);
});
+test("Apple Foundation Models status probes remain behind the host capability policy", () => {
+ const main = readFileSync(new URL("./index.ts", import.meta.url), "utf8");
+ const chatTitle = readFileSync(
+ new URL("./services/chat-title.ts", import.meta.url),
+ "utf8",
+ );
+ const titleProviders = readFileSync(
+ new URL("./handlers/title-providers.ts", import.meta.url),
+ "utf8",
+ );
+ assert.match(
+ main,
+ /function refreshFoundationModelsStatus[\s\S]*?if \(!hostPlatformCapabilities\(\)\.appleFoundationModels\) return;[\s\S]*?foundationModelsConnection\.status/u,
+ );
+ assert.doesNotMatch(
+ main,
+ /app\.on\("activate", \(\) => \{\s*void foundationModelsConnection\.status/u,
+ );
+ assert.match(
+ chatTitle,
+ /!hostPlatformCapabilities\(\)\.appleFoundationModels\s+\? null\s+: await foundationModelsConnection\.status/u,
+ );
+ assert.match(
+ chatTitle,
+ /generateFoundationModelsRename[\s\S]*?if \(!hostPlatformCapabilities\(\)\.appleFoundationModels\)/u,
+ );
+ assert.equal(
+ titleProviders.match(
+ /if \(!hostPlatformCapabilities\(\)\.appleFoundationModels\) return null;/gu,
+ )?.length,
+ 2,
+ );
+});
+
test("packaged test launches retain their explicit private user-data directory", () => {
const profile = readFileSync(new URL("./runtime-profile.ts", import.meta.url), "utf8");
const soak = readFileSync(
diff --git a/main/services/aiden-remote-chats.ts b/main/services/aiden-remote-chats.ts
index a6a917cc..682f7444 100644
--- a/main/services/aiden-remote-chats.ts
+++ b/main/services/aiden-remote-chats.ts
@@ -770,7 +770,7 @@ export class AidenRemoteChatService {
if (result.imageArtifactRecoveryUnavailable) {
throw new AidenRemoteServiceError(
"operation_in_progress",
- "This chat is waiting for image-artifact storage repair on the Mac.",
+ "This chat is waiting for image-artifact storage repair on the desktop.",
409,
true,
);
diff --git a/main/services/aiden-remote-files.ts b/main/services/aiden-remote-files.ts
index 8e9de63e..2141f7fa 100644
--- a/main/services/aiden-remote-files.ts
+++ b/main/services/aiden-remote-files.ts
@@ -235,7 +235,7 @@ export class AidenRemoteFileService {
if (error instanceof AidenRemoteServiceError) throw error;
throw new AidenRemoteServiceError(
"workspace_unavailable",
- "This workspace's files are not currently available on the Mac.",
+ "This workspace's files are not currently available on the desktop.",
409,
);
});
@@ -321,13 +321,13 @@ export class AidenRemoteFileService {
if (error instanceof WorkspaceFileError && error.code === "changed_on_disk") {
throw new AidenRemoteServiceError(
"revision_conflict",
- "This file changed on the Mac. Reload it before saving.",
+ "This file changed on the desktop. Reload it before saving.",
409,
);
}
throw new AidenRemoteServiceError(
"workspace_unavailable",
- "Aiden could not safely save this file on the Mac.",
+ "Aiden could not safely save this file on the desktop.",
409,
);
}
diff --git a/main/services/aiden-remote-pairing.test.ts b/main/services/aiden-remote-pairing.test.ts
index 0d9c86ec..0b416fdf 100644
--- a/main/services/aiden-remote-pairing.test.ts
+++ b/main/services/aiden-remote-pairing.test.ts
@@ -15,7 +15,7 @@ import {
const endpoint = "https://aiden.example.test/api/aiden/v1";
const fingerprint = `sha256/${Buffer.alloc(32, 4).toString("base64")}`;
-function fixture(options: { issueFails?: boolean } = {}) {
+function fixture(options: { issueFails?: boolean; botCapabilitiesSupported?: boolean } = {}) {
let now = 1_000;
let issued = 0;
let issuedAcceptsBotCapabilities: boolean | undefined;
@@ -50,6 +50,7 @@ function fixture(options: { issueFails?: boolean } = {}) {
statusChanges += 1;
},
() => "Studio Mac",
+ () => options.botCapabilitiesSupported ?? true,
);
return {
service,
@@ -239,6 +240,20 @@ test("pairing grants Bot authority only to clients that explicitly accept its vo
assert.equal(current.issuedAcceptsBotCapabilities(), true);
});
+test("Linux host policy narrows a Bot-aware pairing request to legacy authority", async () => {
+ const linux = fixture({ botCapabilitiesSupported: false });
+ const opened = linux.service.begin(endpoint, fingerprint);
+ const result = await linux.service.exchange(
+ exchange(opened.bootstrap.secret, true, true),
+ "linux-bot-aware-client",
+ );
+
+ assert.deepEqual(result.capabilities, AIDEN_REMOTE_LEGACY_CAPABILITIES);
+ assert.equal(result.capabilities.includes("bot:read"), false);
+ assert.equal(result.capabilities.includes("bot:write"), false);
+ assert.equal(linux.issuedAcceptsBotCapabilities(), false);
+});
+
test("an expired, closed, or invalid pairing window fails with stable safe codes", async () => {
const pairing = fixture();
await assert.rejects(
diff --git a/main/services/aiden-remote-pairing.ts b/main/services/aiden-remote-pairing.ts
index cbe258cf..13eedd80 100644
--- a/main/services/aiden-remote-pairing.ts
+++ b/main/services/aiden-remote-pairing.ts
@@ -242,6 +242,7 @@ export class AidenRemotePairingService {
},
private readonly onStatusChanged: () => void = () => undefined,
private readonly displayName: () => string = () => "Aiden Agent",
+ private readonly botCapabilitiesSupported: () => boolean = () => true,
) {}
begin(
@@ -474,16 +475,18 @@ export class AidenRemotePairingService {
// persistence failures can never turn this high-authority secret reusable.
current.consumed = true;
this.onStatusChanged();
+ const acceptsBotCapabilities =
+ input.acceptsBotCapabilities === true && this.botCapabilitiesSupported();
let issued: Awaited>;
try {
issued = await this.devices.issueDevice({
name: input.deviceName,
type: input.deviceType,
clientVersion: input.clientVersion,
- capabilities: input.acceptsBotCapabilities
+ capabilities: acceptsBotCapabilities
? AIDEN_REMOTE_CAPABILITIES
: AIDEN_REMOTE_LEGACY_CAPABILITIES,
- acceptsBotCapabilities: input.acceptsBotCapabilities === true,
+ acceptsBotCapabilities,
authorizeCommit: () => this.window === current && !current.cancelled,
});
if (this.window !== current || current.cancelled) {
diff --git a/main/services/aiden-remote-platform-contract.test.ts b/main/services/aiden-remote-platform-contract.test.ts
new file mode 100644
index 00000000..723194b9
--- /dev/null
+++ b/main/services/aiden-remote-platform-contract.test.ts
@@ -0,0 +1,22 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+test("Remote runtime keeps Linux lifecycle and Bot route gates explicit", () => {
+ const source = readFileSync(
+ new URL("./aiden-remote-service-main.ts", import.meta.url),
+ "utf8",
+ );
+ assert.match(source, /createAidenRemoteBonjourPublisher\(writeRemoteLog\)/u);
+ assert.match(source, /const botsSupported = hostPlatformCapabilities\(\)\.bots/u);
+ assert.match(
+ source,
+ /new AidenRemoteStateRegistry\([\s\S]*?botCapabilitiesSupported: \(\) => hostPlatformCapabilities\(\)\.bots/u,
+ );
+ assert.match(
+ source,
+ /new AidenRemoteService\([\s\S]*?botCapabilitiesSupported: \(\) => hostPlatformCapabilities\(\)\.bots/u,
+ );
+ assert.match(source, /\.\.\.\(botsSupported[\s\S]*?botFiles,[\s\S]*?bots,[\s\S]*?botNotice:/u);
+ assert.match(source, /aidenRemoteServiceKeepsApplicationAlive/u);
+});
diff --git a/main/services/aiden-remote-service-main.ts b/main/services/aiden-remote-service-main.ts
index 2616e774..05ae3c0d 100644
--- a/main/services/aiden-remote-service-main.ts
+++ b/main/services/aiden-remote-service-main.ts
@@ -10,7 +10,7 @@ import { AidenRemoteApprovedRootService } from "./aiden-remote-approved-roots.js
import { DataStore } from "./data-store.js";
import {
AidenRemoteService,
- DnsSdAidenRemoteBonjourPublisher,
+ createAidenRemoteBonjourPublisher,
type AidenRemoteServiceLogEntry,
} from "./aiden-remote-service.js";
import {
@@ -112,6 +112,7 @@ import {
botFavoritesStore,
withBotFavoritesMutation,
} from "./bot-favorites-main.js";
+import { hostPlatformCapabilities } from "./host-platform-capabilities.js";
const STATE_FILE = "aiden-remote-v1.json";
const OPERATIONS_FILE = "aiden-remote-operations-v1.json";
@@ -135,7 +136,8 @@ async function mapWithConcurrency(
return output;
}
-async function macComputerName(): Promise {
+async function computerDisplayName(): Promise {
+ if (process.platform !== "darwin") return os.hostname();
try {
const { stdout } = await execFileAsync(
"/usr/sbin/scutil",
@@ -237,12 +239,13 @@ export interface AidenRemoteRuntime {
}
let runtimePromise: Promise | null = null;
+let activeRuntime: AidenRemoteRuntime | null = null;
async function createRuntime(): Promise {
const runtimeProfile = currentRuntimeProfile();
const userData = app.getPath("userData");
const hostname = os.hostname();
- const defaultDisplayName = defaultAidenRemoteDisplayName(await macComputerName());
+ const defaultDisplayName = defaultAidenRemoteDisplayName(await computerDisplayName());
const store = new DataStore(
STATE_FILE,
createDefaultAidenRemoteState(
@@ -294,6 +297,8 @@ async function createRuntime(): Promise {
await store.save(document);
ipcMain.broadcast("remote:changed", {});
},
+ }, undefined, {
+ botCapabilitiesSupported: () => hostPlatformCapabilities().bots,
});
const operationStore = new DataStore(
OPERATIONS_FILE,
@@ -362,14 +367,14 @@ async function createRuntime(): Promise {
models: AidenRemoteModelService;
streams: AidenRemoteStreamService;
files: AidenRemoteFileService;
- botFiles: AidenRemoteBotFileService;
+ botFiles?: AidenRemoteBotFileService;
git: AidenRemoteGitService;
schedules: AidenRemoteScheduleService;
memorySettings: AidenRemoteMemorySettingsService;
usage: typeof usageStore;
speech: AidenRemoteSpeechService;
- bots: AidenRemoteBotService;
- botNotice: {
+ bots?: AidenRemoteBotService;
+ botNotice?: {
status: typeof botApplicationService.noticeStatus;
acknowledge: typeof botApplicationService.acknowledgeNotice;
};
@@ -382,13 +387,14 @@ async function createRuntime(): Promise {
const service = new AidenRemoteService({
state,
appVersion: app.getVersion(),
+ botCapabilitiesSupported: () => hostPlatformCapabilities().bots,
hostname,
tailscale,
portCandidates: (preferredPort) => aidenRemotePortCandidatesForProfile(
runtimeProfile.id,
preferredPort,
),
- bonjour: new DnsSdAidenRemoteBonjourPublisher(writeRemoteLog),
+ bonjour: createAidenRemoteBonjourPublisher(writeRemoteLog),
notifyPairingChanged: () => ipcMain.broadcast("remote:changed", {}),
workspaceApi: async (instanceId) => {
if (!workspaceApi || workspaceApiInstanceId !== instanceId) {
@@ -436,6 +442,7 @@ async function createRuntime(): Promise {
logger.error("aiden-remote", "Could not persist the remote stream journal.", error),
});
activeStreams = streams;
+ const botsSupported = hostPlatformCapabilities().bots;
const chats = new AidenRemoteChatService({
application: chatApplicationService,
chatStore,
@@ -454,10 +461,29 @@ async function createRuntime(): Promise {
},
streams,
models,
- bots: botStore,
- botMutations: botMutationGate,
- retainedBotChatAuthorizer: authorizeRemoteRetainedBotChat,
- botTurnAuthorityPreflight: preflightBotTurnAuthority,
+ bots: botsSupported
+ ? botStore
+ : { get: async () => null },
+ botMutations: botsSupported
+ ? botMutationGate
+ : {
+ run: async (
+ _botId: string,
+ _action: () => Promise,
+ ): Promise => {
+ throw new AidenRemoteServiceError(
+ "not_found",
+ "This Aiden chat no longer exists.",
+ 404,
+ );
+ },
+ },
+ ...(botsSupported
+ ? {
+ retainedBotChatAuthorizer: authorizeRemoteRetainedBotChat,
+ botTurnAuthorityPreflight: preflightBotTurnAuthority,
+ }
+ : {}),
idempotency,
persistIdempotency: (snapshot) => operationStore.save(snapshot),
notifyChanged: () => ipcMain.broadcast("chats:changed", {}),
@@ -496,7 +522,7 @@ async function createRuntime(): Promise {
return "unavailable";
}
};
- const bots = new AidenRemoteBotService({
+ const bots = botsSupported ? new AidenRemoteBotService({
application: botApplicationService,
chatStore,
avatar: createMainBotAvatarApplicationAdapter(instanceId),
@@ -600,26 +626,28 @@ async function createRuntime(): Promise {
persistIdempotency: (snapshot) => operationStore.save(snapshot),
notifyBotsChanged: () => ipcMain.broadcast("bots:changed", {}),
notifyChatsChanged: () => ipcMain.broadcast("chats:changed", {}),
- });
+ }) : undefined;
const files = new AidenRemoteFileService({
instanceId,
application: workspaceEnvironmentApplicationService,
owners: workspaceOwners,
});
- const botFiles = new AidenRemoteBotFileService({
- instanceId,
- authority: botRuntimeAuthority,
- archivedRead: createBotArchivedFileReadAuthority({
- bots: botStore,
- chats: chatStore,
- capabilities: botCapabilityStore,
- catalog: botCapabilityCatalog,
- managedWorkspace: botManagedWorkspace,
- mutationGate: botMutationGate,
- inventoryLeases: botRuntimeInventoryLeases,
- }),
- chats: chatStore,
- });
+ const botFiles = botsSupported
+ ? new AidenRemoteBotFileService({
+ instanceId,
+ authority: botRuntimeAuthority,
+ archivedRead: createBotArchivedFileReadAuthority({
+ bots: botStore,
+ chats: chatStore,
+ capabilities: botCapabilityStore,
+ catalog: botCapabilityCatalog,
+ managedWorkspace: botManagedWorkspace,
+ mutationGate: botMutationGate,
+ inventoryLeases: botRuntimeInventoryLeases,
+ }),
+ chats: chatStore,
+ })
+ : undefined;
const git = new AidenRemoteGitService({
application: workspaceEnvironmentApplicationService,
owners: workspaceOwners,
@@ -656,21 +684,31 @@ async function createRuntime(): Promise {
models,
streams,
files,
- botFiles,
git,
schedules,
memorySettings,
usage: usageStore,
speech,
- bots,
- botNotice: {
- status: (deviceId) => botApplicationService.noticeStatus(deviceId),
- acknowledge: (deviceId, acknowledgement) =>
- botApplicationService.acknowledgeNotice(
- deviceId,
- acknowledgement,
- ),
- },
+ ...(botsSupported
+ ? {
+ botFiles,
+ bots,
+ botNotice: {
+ status: (deviceId: string) =>
+ botApplicationService.noticeStatus(deviceId),
+ acknowledge: (
+ deviceId: string,
+ acknowledgement: Parameters<
+ typeof botApplicationService.acknowledgeNotice
+ >[1],
+ ) =>
+ botApplicationService.acknowledgeNotice(
+ deviceId,
+ acknowledgement,
+ ),
+ },
+ }
+ : {}),
settle: () => streams.settlePersistence(),
workspaces: new AidenRemoteWorkspaceService({
application: workspaceApplicationService,
@@ -690,7 +728,7 @@ async function createRuntime(): Promise {
}),
log: writeRemoteLog,
});
- return {
+ const runtime: AidenRemoteRuntime = {
service,
state,
approvedRoots: new AidenRemoteApprovedRootService(state),
@@ -703,13 +741,17 @@ async function createRuntime(): Promise {
}, deviceId);
// Cleanup is intentionally idempotent: a retry after a crash between the
// device tombstone and notice removal must still remove the acceptance.
- await botApplicationService.revokeNoticeAudience(deviceId);
+ if (hostPlatformCapabilities().bots) {
+ await botApplicationService.revokeNoticeAudience(deviceId);
+ }
return revoked;
},
pendingApprovalForChat: (chatId) => activeStreams?.pendingApprovalForChat(chatId) ?? null,
respondApprovalFromHost: (chatId, approvalId, decision) =>
activeStreams?.respondApprovalFromHost(chatId, approvalId, decision) ?? false,
};
+ activeRuntime = runtime;
+ return runtime;
}
export function getAidenRemoteService(): Promise {
@@ -722,6 +764,10 @@ export function getAidenRemoteRuntime(): Promise {
return runtimePromise;
}
+export function aidenRemoteServiceKeepsApplicationAlive(): boolean {
+ return activeRuntime?.service.keepsApplicationAlive() === true;
+}
+
export async function initializeAidenRemoteService(): Promise {
const service = await getAidenRemoteService();
await service.initialize();
diff --git a/main/services/aiden-remote-service.test.ts b/main/services/aiden-remote-service.test.ts
index 0c9af1a6..a5a72097 100644
--- a/main/services/aiden-remote-service.test.ts
+++ b/main/services/aiden-remote-service.test.ts
@@ -10,18 +10,25 @@ import test from "node:test";
import {
AidenRemotePortInUseError,
AidenRemoteService,
+ aidenRemoteBonjourBackend,
aidenRemoteBonjourServiceName,
aidenRemotePortCandidates,
} from "./aiden-remote-service.js";
+
import {
AidenRemoteStateRegistry,
createDefaultAidenRemoteState,
type AidenRemoteStateDocument,
} from "./aiden-remote-state.js";
-import { loadOrCreateAidenRemoteTlsIdentity } from "./aiden-remote-tls-identity.js";
+import { loadOrCreateAidenRemoteTlsIdentity, AidenRemoteTlsEndpointError } from "./aiden-remote-tls-identity.js";
import type { AidenTailscaleStatus } from "./aiden-remote-tailscale-route.js";
import { revokeAidenRemoteRuntimeDevice } from "./aiden-remote-revocation.js";
+test("Remote discovery selects the Node Bonjour backend on Linux", () => {
+ assert.equal(aidenRemoteBonjourBackend("darwin"), "dns-sd");
+ assert.equal(aidenRemoteBonjourBackend("linux"), "node");
+});
+
async function canBind(
port: number,
host: "::" | "127.0.0.1" = "127.0.0.1",
@@ -132,6 +139,8 @@ interface FixtureOptions {
transport: "lan" | "tailscale";
port: number;
}) => Promise;
+ connectFailsWith?: string;
+ resolveTlsEndpointPin?: (hostname: string, port?: number) => Promise;
}
async function fixture(
@@ -211,6 +220,7 @@ async function fixture(
) => {
tailscale.connects += 1;
tailscale.targets.push(target);
+ if (options.connectFailsWith) throw new Error(options.connectFailsWith);
const ownership = { path: "/api/aiden/v1" as const, target };
await persistOwnership?.(ownership);
return ownership;
@@ -275,7 +285,8 @@ async function fixture(
hostname: "Aiden-Test",
bonjour,
tailscale,
- resolveTlsEndpointPin: async () => `sha256/${Buffer.alloc(32, 9).toString("base64")}`,
+ resolveTlsEndpointPin: options.resolveTlsEndpointPin
+ ?? (async () => `sha256/${Buffer.alloc(32, 9).toString("base64")}`),
loadTlsIdentity: async () => {
identityLoads += 1;
return loadOrCreateAidenRemoteTlsIdentity({
@@ -1165,6 +1176,70 @@ test("Tailscale connect ownership persists only after connect and explicit disab
}
});
+test("Tailscale operator denial resolves as a settings latch instead of rejecting connect", async () => {
+ const app = await fixture({ mode: "both", connectFailsWith: "tailscale_permission_denied" });
+ try {
+ await app.service.setEnabled(true);
+ await app.service.connectTailscale();
+ const status = await app.service.status();
+ assert.equal(status.tailscaleErrorCode, "permission_denied");
+ assert.equal(status.tailscaleConnected, false);
+ assert.equal(app.persisted().tailscaleOwnership, undefined);
+ assert.equal(app.tailscale.connects, 1);
+ } finally {
+ await app.cleanup();
+ }
+});
+
+test("Tailscale operator denial latch clears after a successful connect", async () => {
+ const app = await fixture({ mode: "both", connectFailsWith: "tailscale_permission_denied" });
+ try {
+ await app.service.setEnabled(true);
+ await app.service.connectTailscale();
+ assert.equal((await app.service.status()).tailscaleErrorCode, "permission_denied");
+ app.tailscale.connect = async (
+ target: string,
+ _ownership?: { path: "/api/aiden/v1"; target: string },
+ persistOwnership?: (ownership: { path: "/api/aiden/v1"; target: string }) => Promise,
+ ) => {
+ const ownership = { path: "/api/aiden/v1" as const, target };
+ await persistOwnership?.(ownership);
+ return ownership;
+ };
+ await app.service.connectTailscale();
+ assert.equal((await app.service.status()).tailscaleErrorCode, undefined);
+ assert.equal(app.persisted().tailscaleOwnership?.path, "/api/aiden/v1");
+ } finally {
+ await app.cleanup();
+ }
+});
+
+test("Tailscale pairing TLS probe failures stay classified and create no pairing session", async () => {
+ const app = await fixture({
+ mode: "both",
+ tailscaleAssessment: { state: "owned" },
+ resolveTlsEndpointPin: async () => {
+ throw new Error("Aiden Remote TLS endpoint timed out.");
+ },
+ initial: (state) => {
+ state.tailscaleOwnership = {
+ path: "/api/aiden/v1",
+ target: `http://127.0.0.1:${state.lanPort + 1}/api/aiden/v1`,
+ };
+ },
+ });
+ try {
+ await app.service.setEnabled(true);
+ await assert.rejects(
+ app.service.beginPairing("tailscale"),
+ (error: unknown) => error instanceof AidenRemoteTlsEndpointError && error.code === "timed_out",
+ );
+ assert.equal(app.service.pairingStatus(), undefined);
+ } finally {
+ await app.cleanup();
+ }
+});
+
test("Tailscale connect removes only a persisted origin-only route before canonical migration", async () => {
const app = await fixture("both");
const legacyTarget = `http://127.0.0.1:${app.persisted().lanPort + 1}`;
@@ -1610,6 +1685,22 @@ test("two paired devices authenticate independently and revoking one leaves the
}
});
+for (const errorCode of ["not_connected", "https_unavailable", "status_unavailable"] as const) {
+ test(`Tailscale ${errorCode} supersedes an earlier operator denial`, async () => {
+ const assessment: { state: "available" | "unavailable"; errorCode?: typeof errorCode } = { state: "available" };
+ const app = await fixture({ mode: "both", connectFailsWith: "tailscale_permission_denied", tailscaleAssessment: assessment });
+ try {
+ await app.service.setEnabled(true);
+ await app.service.connectTailscale();
+ assert.equal((await app.service.status()).tailscaleErrorCode, "permission_denied");
+ assessment.state = "unavailable";
+ assessment.errorCode = errorCode;
+ assert.equal((await app.service.status()).tailscaleErrorCode, errorCode);
+ } finally {
+ await app.cleanup();
+ }
+ });
+}
test("guided LAN setup enables access and issues one expiring pairing in one operation", async () => {
const f = await fixture();
@@ -1800,3 +1891,35 @@ test("guided setup never changes the mode of a saved private connection", async
assert.equal(f.tailscale.disconnects, 0);
} finally { await f.cleanup(); }
});
+
+
+test("guided Tailscale setup preserves operator denial and rolls back fresh access", async () => {
+ const f = await fixture({ connectFailsWith: "tailscale_permission_denied" });
+ try {
+ const before = await f.state.snapshot();
+ await assert.rejects(f.service.setupPairing("tailscale", before), /tailscale_permission_denied/u);
+ const after = await f.state.snapshot();
+ assert.equal(after.enabled, false);
+ assert.equal(after.connectionMode, before.connectionMode);
+ assert.equal(after.tailscaleOwnership, undefined);
+ assert.equal(f.service.pairingStatus(), undefined);
+ assert.equal((await f.service.status()).running, false);
+ } finally { await f.cleanup(); }
+});
+
+test("guided Tailscale TLS failure stays classified while rolling back the new route", async () => {
+ const f = await fixture({
+ tailscaleAssessment: { state: "owned" },
+ resolveTlsEndpointPin: async () => { throw new Error("connect ECONNREFUSED"); },
+ });
+ try {
+ const before = await f.state.snapshot();
+ await assert.rejects(f.service.setupPairing("tailscale", before), AidenRemoteTlsEndpointError);
+ const after = await f.state.snapshot();
+ assert.equal(after.enabled, false);
+ assert.equal(after.connectionMode, before.connectionMode);
+ assert.equal(after.tailscaleOwnership, undefined);
+ assert.equal(f.service.pairingStatus(), undefined);
+ assert.equal(f.tailscale.disconnects, 1);
+ } finally { await f.cleanup(); }
+});
diff --git a/main/services/aiden-remote-service.ts b/main/services/aiden-remote-service.ts
index 2468e67a..0eec7915 100644
--- a/main/services/aiden-remote-service.ts
+++ b/main/services/aiden-remote-service.ts
@@ -1,4 +1,5 @@
import { spawn, type ChildProcess } from "node:child_process";
+import Bonjour from "bonjour-service";
import { createHash, X509Certificate } from "node:crypto";
import { createServer as createHttpServer, type Server as HttpServer } from "node:http";
import { createServer as createHttpsServer, type Server as HttpsServer } from "node:https";
@@ -22,7 +23,7 @@ import type {
AidenRemoteStateRegistry,
} from "./aiden-remote-state.js";
import type { AidenRemoteTlsIdentity } from "./aiden-remote-tls-identity.js";
-import { fetchTlsServerSpkiSha256 } from "./aiden-remote-tls-identity.js";
+import { fetchTlsServerSpkiSha256, classifyAidenRemoteTlsEndpointFailure } from "./aiden-remote-tls-identity.js";
import type {
AidenRemoteTailscaleController,
AidenTailscaleConnectionStatus,
@@ -116,6 +117,7 @@ export function aidenRemoteBonjourServiceName(
export interface AidenRemoteServiceOptions {
state: AidenRemoteStateRegistry;
appVersion: string;
+ botCapabilitiesSupported?: () => boolean;
hostname?: string;
loadTlsIdentity(): Promise;
resolveTlsEndpointPin?: (hostname: string, port?: number) => Promise;
@@ -234,7 +236,7 @@ export interface AidenRemoteServiceStatus {
tailscaleConnected: boolean;
tailscaleInstalled: boolean;
tailscaleRouteState: AidenTailscaleRouteState;
- tailscaleErrorCode?: AidenTailscaleConnectionStatus["errorCode"];
+ tailscaleErrorCode?: AidenTailscaleConnectionStatus["errorCode"] | "permission_denied";
pairedDeviceCount: number;
approvedRootCount: number;
errorCode?: "remote_port_in_use";
@@ -376,6 +378,92 @@ export class DnsSdAidenRemoteBonjourPublisher implements AidenRemoteBonjourPubli
}
}
+export class NodeAidenRemoteBonjourPublisher implements AidenRemoteBonjourPublisher {
+ private bonjour: Bonjour | null = null;
+ private generation = 0;
+
+ constructor(
+ private readonly log: (entry: AidenRemoteServiceLogEntry) => void = () => undefined,
+ ) {}
+
+ async start(
+ input: { instanceId: string; displayName: string; port: number },
+ onUnexpectedFailure: (error: Error) => void,
+ ): Promise {
+ this.stop();
+ const generation = ++this.generation;
+ let ready = false;
+ let failed = false;
+ let rejectStartup: (error: Error) => void = () => undefined;
+ const startupFailure = new Promise((_resolve, reject) => {
+ rejectStartup = reject;
+ });
+ const fail = (value: unknown) => {
+ if (failed || this.generation !== generation) return;
+ failed = true;
+ const error = value instanceof Error ? value : new Error(String(value));
+ this.log({
+ level: "warn",
+ event: "bonjour_failed",
+ details: { message: error.message },
+ });
+ if (!ready) rejectStartup(error);
+ else onUnexpectedFailure(error);
+ };
+ const bonjour = new Bonjour(undefined, fail);
+ this.bonjour = bonjour;
+ const service = bonjour.publish({
+ name: aidenRemoteBonjourServiceName(input.displayName, input.instanceId),
+ type: "aiden-agent",
+ protocol: "tcp",
+ port: input.port,
+ txt: { v: "1", instance: input.instanceId },
+ });
+ const readySignal = new Promise((resolve, reject) => {
+ const timer = setTimeout(
+ () => reject(new Error("Local discovery did not become ready in time.")),
+ 3_000,
+ );
+ timer.unref();
+ service.once("up", () => {
+ clearTimeout(timer);
+ if (this.generation !== generation) {
+ reject(new Error("Local discovery was stopped before it became ready."));
+ return;
+ }
+ ready = true;
+ resolve();
+ });
+ });
+ await Promise.race([readySignal, startupFailure]).catch((error: unknown) => {
+ this.stop();
+ throw error;
+ });
+ }
+
+ stop(): void {
+ this.generation += 1;
+ const bonjour = this.bonjour;
+ this.bonjour = null;
+ bonjour?.destroy();
+ }
+}
+
+export function aidenRemoteBonjourBackend(
+ platform: NodeJS.Platform = process.platform,
+): "dns-sd" | "node" {
+ return platform === "darwin" ? "dns-sd" : "node";
+}
+
+export function createAidenRemoteBonjourPublisher(
+ log: (entry: AidenRemoteServiceLogEntry) => void = () => undefined,
+ platform: NodeJS.Platform = process.platform,
+): AidenRemoteBonjourPublisher {
+ return aidenRemoteBonjourBackend(platform) === "dns-sd"
+ ? new DnsSdAidenRemoteBonjourPublisher(log)
+ : new NodeAidenRemoteBonjourPublisher(log);
+}
+
export class AidenRemoteService {
private lanServer: HttpsServer | null = null;
private tailscaleServer: HttpServer | null = null;
@@ -386,6 +474,7 @@ export class AidenRemoteService {
private activeState: AidenRemoteStateDocument | null = null;
private lastError: string | undefined;
private lastErrorCode: "remote_port_in_use" | undefined;
+ private tailscalePermissionDenied = false;
private setupInFlight = false;
private operationTail: Promise = Promise.resolve();
private settleRemoteApi: (() => Promise) | undefined;
@@ -425,6 +514,7 @@ export class AidenRemoteService {
undefined,
this.options.notifyPairingChanged,
() => this.activeState?.displayName ?? state.displayName,
+ this.options.botCapabilitiesSupported,
);
const workspaceApi = await this.options.workspaceApi?.(state.instanceId);
this.settleRemoteApi = workspaceApi?.settle;
@@ -694,6 +784,10 @@ export class AidenRemoteService {
this.tailscaleServer?.close();
}
+ keepsApplicationAlive(): boolean {
+ return this.lanServer !== null || this.tailscaleServer !== null;
+ }
+
async setEnabled(enabled: boolean): Promise {
return this.serialized(() => this.setEnabledInternal(enabled));
}
@@ -724,6 +818,7 @@ export class AidenRemoteService {
await this.options.state.setEnabled(false);
this.lastError = undefined;
this.lastErrorCode = undefined;
+ this.tailscalePermissionDenied = false;
if (disconnectError) throw disconnectError;
}
@@ -769,6 +864,7 @@ export class AidenRemoteService {
await this.disconnectTailscaleInternal(current);
}
await this.options.state.setConnectionMode(connectionMode);
+ if (connectionMode === "lan") this.tailscalePermissionDenied = false;
if (current.enabled) {
if (!this.activeState || !this.lanServer || !this.tailscaleServer) {
await this.startConfigured({ ...current, connectionMode });
@@ -830,7 +926,14 @@ export class AidenRemoteService {
}
async connectTailscale(): Promise {
- return this.serialized(() => this.connectTailscaleInternal());
+ return this.serialized(async () => {
+ try {
+ await this.connectTailscaleInternal();
+ } catch (error) {
+ if (error instanceof Error && error.message === "tailscale_permission_denied") return;
+ throw error;
+ }
+ });
}
private async connectTailscaleInternal(): Promise {
@@ -853,11 +956,19 @@ export class AidenRemoteService {
);
ownership = undefined;
}
- await this.options.tailscale.connect(
- target,
- ownership,
- (nextOwnership) => this.options.state.commitTailscaleOutcome(nextOwnership),
- );
+ try {
+ await this.options.tailscale.connect(
+ target,
+ ownership,
+ (nextOwnership) => this.options.state.commitTailscaleOutcome(nextOwnership),
+ );
+ this.tailscalePermissionDenied = false;
+ } catch (error) {
+ if (error instanceof Error && error.message === "tailscale_permission_denied") {
+ this.tailscalePermissionDenied = true;
+ }
+ throw error;
+ }
}
async reviewTailscaleTakeover(): Promise {
@@ -987,9 +1098,13 @@ export class AidenRemoteService {
}
if (!status.dnsName) throw new Error("Tailscale does not report a stable DNS name.");
endpoint = `https://${status.dnsName}${AIDEN_REMOTE_BASE_PATH}`;
- serverSpkiSha256 = await (
- this.options.resolveTlsEndpointPin ?? fetchTlsServerSpkiSha256
- )(status.dnsName, 443);
+ try {
+ serverSpkiSha256 = await (
+ this.options.resolveTlsEndpointPin ?? fetchTlsServerSpkiSha256
+ )(status.dnsName, 443);
+ } catch (error) {
+ throw classifyAidenRemoteTlsEndpointFailure(error);
+ }
}
const pairing = this.pairing.begin(endpoint, serverSpkiSha256);
try {
@@ -1026,7 +1141,7 @@ export class AidenRemoteService {
// Changing a saved transport can strand existing devices. Keep that an
// explicit advanced operation, rather than silently choosing both.
if (mode !== current.connectionMode && (current.devices.length || current.tailscaleOwnership)) {
- throw new Error("This Mac already has a saved connection. Use its current method, or review Connection settings before changing it.");
+ throw new Error("This computer already has a saved connection. Use its current method, or review Connection settings before changing it.");
}
if (["finishing", "awaiting_scan"].includes(this.pairingStatus()?.state ?? "")) {
throw new Error("A phone connection is already open. Finish or close it before adding another device.");
@@ -1207,7 +1322,11 @@ export class AidenRemoteService {
tailscaleConnected,
tailscaleInstalled: tailscaleStatus.installed,
tailscaleRouteState,
- ...(tailscaleErrorCode ? { tailscaleErrorCode } : {}),
+ ...(this.tailscalePermissionDenied && !tailscaleConnected && !tailscaleErrorCode
+ ? { tailscaleErrorCode: "permission_denied" as const }
+ : tailscaleErrorCode
+ ? { tailscaleErrorCode }
+ : {}),
pairedDeviceCount: state.devices.length,
approvedRootCount: state.approvedRoots.length,
...(this.lastErrorCode ? { errorCode: this.lastErrorCode } : {}),
diff --git a/main/services/aiden-remote-speech-lane.ts b/main/services/aiden-remote-speech-lane.ts
index 5ee6886f..3e0ebea5 100644
--- a/main/services/aiden-remote-speech-lane.ts
+++ b/main/services/aiden-remote-speech-lane.ts
@@ -3,7 +3,7 @@ import { AidenRemoteServiceError } from "./aiden-remote-errors.js";
/**
* A small FIFO admission lane for memory-heavy local speech work. Admission is
* synchronous, while operations settle serially, so callers cannot allocate or
- * decode multiple PCM buffers in parallel while another recognizer owns the Mac.
+ * decode multiple PCM buffers in parallel while another recognizer owns the desktop engine.
*/
export class AidenRemoteSpeechLane {
private tail: Promise = Promise.resolve();
@@ -19,7 +19,7 @@ export class AidenRemoteSpeechLane {
if (this.admitted >= this.maximumAdmitted) {
throw new AidenRemoteServiceError(
"rate_limited",
- "The Mac speech engine is busy. Try again in a moment.",
+ "The desktop speech engine is busy. Try again in a moment.",
429,
true,
{ retryAfterSeconds: 2 },
diff --git a/main/services/aiden-remote-speech-transcription.ts b/main/services/aiden-remote-speech-transcription.ts
index ace403b2..342259bf 100644
--- a/main/services/aiden-remote-speech-transcription.ts
+++ b/main/services/aiden-remote-speech-transcription.ts
@@ -23,7 +23,7 @@ function speechUsage(modelId: string, status: "completed" | "failed"): UsageRequ
return unreportedUsageRecord({
source: "voice-transcription",
providerId: "local-voice",
- providerLabel: "Paired Mac voice",
+ providerLabel: "Paired desktop voice",
modelId,
local: true,
status,
diff --git a/main/services/aiden-remote-speech.ts b/main/services/aiden-remote-speech.ts
index 1b220634..489fd22f 100644
--- a/main/services/aiden-remote-speech.ts
+++ b/main/services/aiden-remote-speech.ts
@@ -63,7 +63,7 @@ export class AidenRemoteSpeechService {
return {
engine: {
ready: engine.ready,
- error: engine.ready ? null : "The Mac speech engine is unavailable. Restart Aiden Agent and try again.",
+ error: engine.ready ? null : "The desktop speech engine is unavailable. Restart Aiden Agent and try again.",
},
selectedModelId: settings.localVoiceModel || null,
models: listModels().map((model) => ({
@@ -138,7 +138,7 @@ export class AidenRemoteSpeechService {
}
const id = modelId(value.modelId);
const installed = listModels().some((candidate) => candidate.id === id && candidate.installed);
- if (!installed) throw new AidenRemoteServiceError("operation_stale", "The selected speech model is not installed on the Mac.", 409, true);
+ if (!installed) throw new AidenRemoteServiceError("operation_stale", "The selected speech model is not installed on the desktop.", 409, true);
return this.transcriptionLane.run(async () => {
// A queued request may wait while model management runs. Revalidate at
// execution time so deletion cannot leave an admitted request pointing at
@@ -148,7 +148,7 @@ export class AidenRemoteSpeechService {
if (!stillInstalled) {
throw new AidenRemoteServiceError(
"operation_stale",
- "The selected speech model is no longer installed on the Mac.",
+ "The selected speech model is no longer installed on the desktop.",
409,
true,
);
diff --git a/main/services/aiden-remote-state.test.ts b/main/services/aiden-remote-state.test.ts
index ec3ebe04..6b5dcd5c 100644
--- a/main/services/aiden-remote-state.test.ts
+++ b/main/services/aiden-remote-state.test.ts
@@ -19,7 +19,7 @@ import {
AIDEN_REMOTE_PRODUCTION_LAN_PORT,
} from "./aiden-remote-ports.js";
-function fixture(initial?: unknown) {
+function fixture(initial?: unknown, botCapabilitiesSupported = true) {
let stored = initial ?? createDefaultAidenRemoteState(() => Buffer.alloc(24, 7));
const writes: AidenRemoteStateDocument[] = [];
let failNextSave = false;
@@ -41,6 +41,8 @@ function fixture(initial?: unknown) {
randomBytes: (size) => Buffer.alloc(size, ++randomCounter),
deriveCredentialDigest: async (credential, salt) =>
createHash("sha256").update(credential).update(salt).digest(),
+ }, {
+ botCapabilitiesSupported: () => botCapabilitiesSupported,
});
return {
registry,
@@ -165,6 +167,38 @@ test("Bot-aware devices preserve only coherent explicitly negotiated Bot grants"
);
});
+test("Linux host policy removes persisted Bot negotiation and grants", async () => {
+ const darwin = fixture();
+ const issued = await darwin.registry.issueDevice({
+ name: "Previously Bot-aware iPhone",
+ type: "iphone",
+ clientVersion: "2.0",
+ capabilities: ["server:read", "bot:read", "bot:write"],
+ acceptsBotCapabilities: true,
+ });
+
+ const linux = fixture(darwin.stored(), false);
+ const initialized = await linux.registry.initialize();
+ assert.equal(initialized.devices[0]?.acceptsBotCapabilities, false);
+ assert.deepEqual(initialized.devices[0]?.capabilities, ["server:read"]);
+ assert.equal(linux.writes.length, 1);
+
+ const authenticated = await linux.registry.authenticate(issued.credential);
+ assert.equal(authenticated?.acceptsBotCapabilities, false);
+ assert.deepEqual([...authenticated!.capabilities], ["server:read"]);
+
+ await assert.rejects(
+ linux.registry.issueDevice({
+ name: "New Bot-aware iPhone",
+ type: "iphone",
+ clientVersion: "2.0",
+ capabilities: ["server:read", "bot:read", "bot:write"],
+ acceptsBotCapabilities: true,
+ }),
+ /device capabilities/u,
+ );
+});
+
test("device issuance checks pairing authorization inside the durable mutation", async () => {
const state = fixture();
await assert.rejects(
diff --git a/main/services/aiden-remote-state.ts b/main/services/aiden-remote-state.ts
index a107b8a6..8f7a577b 100644
--- a/main/services/aiden-remote-state.ts
+++ b/main/services/aiden-remote-state.ts
@@ -105,6 +105,10 @@ export interface AidenRemoteStateDependencies {
deriveCredentialDigest(credential: string, salt: Buffer): Promise;
}
+export interface AidenRemoteStateHostPolicy {
+ botCapabilitiesSupported(): boolean;
+}
+
function ownRecord(value: unknown): Record | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record)
@@ -501,6 +505,9 @@ export class AidenRemoteStateRegistry {
private readonly storage: AidenRemoteStateStorage,
private readonly dependencies: AidenRemoteStateDependencies =
defaultAidenRemoteStateDependencies(),
+ private readonly hostPolicy: AidenRemoteStateHostPolicy = {
+ botCapabilitiesSupported: () => true,
+ },
) {}
private serialized(operation: () => Promise): Promise {
@@ -517,6 +524,20 @@ export class AidenRemoteStateRegistry {
if (this.document) return structuredClone(this.document);
const raw = await this.storage.load();
const loaded = parseAidenRemoteStateDocument(raw);
+ const botCapabilitiesSupported = this.hostPolicy.botCapabilitiesSupported();
+ const devicesNeedHostPolicyMigration = !botCapabilitiesSupported
+ && loaded.devices.some(
+ (device) =>
+ device.acceptsBotCapabilities || device.capabilities.some(isBotCapability),
+ );
+ if (devicesNeedHostPolicyMigration) {
+ for (const device of loaded.devices) {
+ device.acceptsBotCapabilities = false;
+ device.capabilities = device.capabilities.filter(
+ (capability) => !isBotCapability(capability),
+ );
+ }
+ }
const rawRecord = ownRecord(raw);
const storageNeedsSave = this.storage.needsSaveAfterLoad
? await this.storage.needsSaveAfterLoad()
@@ -534,7 +555,11 @@ export class AidenRemoteStateRegistry {
)
);
});
- if (storageNeedsSave || devicesNeedVocabularyMigration) {
+ if (
+ storageNeedsSave ||
+ devicesNeedVocabularyMigration ||
+ devicesNeedHostPolicyMigration
+ ) {
await this.storage.save(loaded);
}
this.document = loaded;
@@ -671,7 +696,9 @@ export class AidenRemoteStateRegistry {
);
if (
!capabilities ||
- (input.acceptsBotCapabilities !== true && capabilities.some(isBotCapability))
+ (input.acceptsBotCapabilities !== true && capabilities.some(isBotCapability)) ||
+ (!this.hostPolicy.botCapabilitiesSupported() &&
+ (input.acceptsBotCapabilities === true || capabilities.some(isBotCapability)))
) {
throw new Error("Invalid device capabilities.");
}
@@ -744,16 +771,19 @@ export class AidenRemoteStateRegistry {
// changed while the expensive credential digest was being derived.
const current = draft.devices.find((candidate) => candidate.id === device.id);
if (!current) return { changed: false, value: null };
+ const acceptsBotCapabilities =
+ this.hostPolicy.botCapabilitiesSupported() &&
+ current.acceptsBotCapabilities === true;
const capabilities = parsePersistedCapabilities(
current.capabilities,
- current.acceptsBotCapabilities === true,
+ acceptsBotCapabilities,
);
if (!capabilities) return { changed: false, value: null };
const authenticated: AidenRemoteAuthenticatedDevice = {
id: current.id,
name: current.name,
capabilities: new Set(capabilities),
- acceptsBotCapabilities: current.acceptsBotCapabilities === true,
+ acceptsBotCapabilities,
revoked: current.revokedAt !== undefined,
};
const shouldPersistLastSeen =
diff --git a/main/services/aiden-remote-streams.ts b/main/services/aiden-remote-streams.ts
index a70ea388..7a85bd8e 100644
--- a/main/services/aiden-remote-streams.ts
+++ b/main/services/aiden-remote-streams.ts
@@ -1133,7 +1133,7 @@ export class AidenRemoteStreamService {
if (decision === "allow" && (approvalIsHostOnly(approval.details) || !approval.canAllow)) {
throw new AidenRemoteServiceError(
"capability_denied",
- "This approval can only be allowed from the Mac.",
+ "This approval can only be allowed from the Aiden desktop app.",
403,
);
}
diff --git a/main/services/aiden-remote-tailscale.test.ts b/main/services/aiden-remote-tailscale.test.ts
index cdc27575..c7cfb85c 100644
--- a/main/services/aiden-remote-tailscale.test.ts
+++ b/main/services/aiden-remote-tailscale.test.ts
@@ -5,6 +5,8 @@ import test from "node:test";
import {
AidenRemoteTailscaleController,
createSystemTailscaleCommandRunner,
+ tailscaleBinaryCandidates,
+ tailscaleCommandErrorCode,
withAidenTailscaleRouteLock,
type AidenTailscaleCommandRunner,
type AidenTailscaleStatusReadFailureCategory,
@@ -19,6 +21,7 @@ test("system Tailscale runner forces CLI mode for Finder-style production launch
environment: NodeJS.ProcessEnv | undefined;
}> = [];
const runner = await createSystemTailscaleCommandRunner({
+ platform: "darwin",
environment: {
HOME: "/test-home",
TAILSCALE_BE_CLI: "0",
@@ -32,7 +35,8 @@ test("system Tailscale runner forces CLI mode for Finder-style production launch
return {
stdout: args[0] === "status"
? JSON.stringify({
- Self: { DNSName: "aiden.tailnet.ts.net." },
+ BackendState: "Running",
+ Self: { DNSName: "aiden.tailnet.ts.net.", Online: true },
CertDomains: ["aiden.tailnet.ts.net"],
})
: "{}",
@@ -53,6 +57,71 @@ test("system Tailscale runner forces CLI mode for Finder-style production launch
}
});
+test("Tailscale discovery uses fixed platform-specific executable locations", () => {
+ assert.deepEqual(tailscaleBinaryCandidates("linux"), [
+ "/usr/bin/tailscale",
+ "/usr/local/bin/tailscale",
+ "/run/current-system/sw/bin/tailscale",
+ ]);
+ assert.deepEqual(tailscaleBinaryCandidates("darwin"), [
+ "/Applications/Tailscale.app/Contents/MacOS/Tailscale",
+ "/usr/local/bin/tailscale",
+ "/opt/homebrew/bin/tailscale",
+ ]);
+ assert.deepEqual(tailscaleBinaryCandidates("win32"), []);
+});
+
+test("Linux Tailscale runner does not force the packaged macOS CLI environment", async () => {
+ let environment: NodeJS.ProcessEnv | undefined;
+ const runner = await createSystemTailscaleCommandRunner({
+ platform: "linux",
+ environment: { AIDEN_TEST: "1" },
+ resolveBinary: async () => "/usr/bin/tailscale",
+ execute: async (_binary, _args, options) => {
+ environment = options.env;
+ return { stdout: "{}" };
+ },
+ });
+ await runner?.run(["status", "--json"]);
+ assert.equal(environment?.AIDEN_TEST, "1");
+ assert.equal(environment?.TAILSCALE_BE_CLI, undefined);
+});
+
+test("Linux operator denial maps to an actionable stable code", () => {
+ assert.equal(
+ tailscaleCommandErrorCode({
+ stderr:
+ "Access denied: serve config denied; run tailscale set --operator=$USER",
+ }),
+ "tailscale_permission_denied",
+ );
+ assert.equal(
+ tailscaleCommandErrorCode(new Error("tailscale_permission_denied")),
+ "tailscale_permission_denied",
+ );
+ assert.equal(
+ tailscaleCommandErrorCode(
+ new Error("Access denied: serve config denied; run tailscale set --operator=$USER"),
+ ),
+ "tailscale_permission_denied",
+ );
+ assert.equal(tailscaleCommandErrorCode({ stderr: "permission denied" }), undefined);
+});
+
+test("a named but offline Tailscale node remains disconnected", async () => {
+ const controller = new AidenRemoteTailscaleController({
+ run: async (args) =>
+ args[0] === "status"
+ ? JSON.stringify({
+ BackendState: "Stopped",
+ Self: { DNSName: "aiden.tailnet.ts.net.", Online: false },
+ CertDomains: ["aiden.tailnet.ts.net"],
+ })
+ : "{}",
+ });
+ assert.equal((await controller.status()).errorCode, "not_connected");
+});
+
async function availableLoopbackPort(): Promise {
const socket = createSocket({ type: "udp4", reuseAddr: false });
await new Promise((resolve, reject) => {
@@ -72,7 +141,8 @@ function fixture(options: { emptyServeStatus?: boolean; certDomains?: unknown }
calls.push([...args]);
if (args[0] === "status") {
return JSON.stringify({
- Self: { DNSName: "aiden.tailnet.ts.net." },
+ BackendState: "Running",
+ Self: { DNSName: "aiden.tailnet.ts.net.", Online: true },
CertDomains: options.certDomains ?? ["aiden.tailnet.ts.net"],
});
}
@@ -116,6 +186,39 @@ test("Tailscale controller connects and verifies only Aiden's route", async () =
]);
});
+test("Linux operator denial leaves the route untouched with a typed permission code", async () => {
+ const calls: string[][] = [];
+ const runner: AidenTailscaleCommandRunner = {
+ run: async (args) => {
+ calls.push([...args]);
+ if (args[0] === "status") {
+ return JSON.stringify({
+ BackendState: "Running",
+ Self: { DNSName: "aiden.tailnet.ts.net.", Online: true },
+ CertDomains: ["aiden.tailnet.ts.net"],
+ });
+ }
+ if (args[0] === "serve" && args[1] === "status") return "{}";
+ throw new Error("Access denied: serve config denied; run tailscale set --operator=$USER");
+ },
+ };
+ const outcomes: unknown[] = [];
+ const controller = new AidenRemoteTailscaleController(runner, {
+ outcomeStore: {
+ begin: async (outcome) => { outcomes.push(outcome); },
+ snapshot: async () => undefined,
+ commit: async () => undefined,
+ clear: async () => { outcomes.length = 0; },
+ },
+ });
+ await assert.rejects(
+ controller.connect(target),
+ (error: unknown) => error instanceof Error && error.message === "tailscale_permission_denied",
+ );
+ assert.equal(outcomes.length, 0);
+ assert.equal(calls.some((args) => args.includes("--set-path=/api/aiden/v1") && !args.includes("off")), true);
+});
+
test("Tailscale controller reports stable URL identity without mutating configuration", async () => {
const app = fixture();
assert.deepEqual(await app.controller.status(), {
@@ -159,7 +262,8 @@ test("combined route inspection retries a transient CLI read and recovers", asyn
}
if (args[0] === "status") {
return JSON.stringify({
- Self: { DNSName: "aiden.tailnet.ts.net." },
+ BackendState: "Running",
+ Self: { DNSName: "aiden.tailnet.ts.net.", Online: true },
CertDomains: ["aiden.tailnet.ts.net"],
});
}
@@ -286,7 +390,8 @@ test("first-listener verification rejects a route without explicit TCP 443 HTTPS
calls.push([...args]);
if (args[0] === "status") {
return JSON.stringify({
- Self: { DNSName: "aiden.tailnet.ts.net." },
+ BackendState: "Running",
+ Self: { DNSName: "aiden.tailnet.ts.net.", Online: true },
CertDomains: ["aiden.tailnet.ts.net"],
});
}
@@ -379,7 +484,8 @@ function takeoverFixture(options: {
calls.push([...args]);
if (args[0] === "status") {
return JSON.stringify({
- Self: { DNSName: "aiden.tailnet.ts.net." },
+ BackendState: "Running",
+ Self: { DNSName: "aiden.tailnet.ts.net.", Online: true },
CertDomains: ["aiden.tailnet.ts.net"],
});
}
diff --git a/main/services/aiden-remote-tailscale.ts b/main/services/aiden-remote-tailscale.ts
index 10a31d85..6453b426 100644
--- a/main/services/aiden-remote-tailscale.ts
+++ b/main/services/aiden-remote-tailscale.ts
@@ -17,11 +17,16 @@ import {
} from "./aiden-remote-tailscale-route.js";
const execFileAsync = promisify(execFile);
-const TAILSCALE_CANDIDATES = [
+const DARWIN_TAILSCALE_CANDIDATES = [
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
"/usr/local/bin/tailscale",
"/opt/homebrew/bin/tailscale",
] as const;
+const LINUX_TAILSCALE_CANDIDATES = [
+ "/usr/bin/tailscale",
+ "/usr/local/bin/tailscale",
+ "/run/current-system/sw/bin/tailscale",
+] as const;
const MAX_STATUS_BYTES = 256 * 1_024;
const MAX_HEALTH_BYTES = 1_024;
const HEALTH_TIMEOUT_MS = 800;
@@ -54,6 +59,7 @@ export interface AidenTailscaleSystemRunnerOptions {
environment?: NodeJS.ProcessEnv;
execute?: AidenTailscaleCommandExecutor;
resolveBinary?: () => Promise;
+ platform?: NodeJS.Platform;
}
export interface AidenTailscaleConnectionStatus {
@@ -135,10 +141,37 @@ export interface AidenTailscaleRouteLockOptions {
}
interface AidenTailscaleNodeStatus {
+ connected: boolean;
dnsName?: string;
httpsAvailable: boolean;
}
+export function tailscaleBinaryCandidates(
+ platform: NodeJS.Platform = process.platform,
+): readonly string[] {
+ if (platform === "darwin") return DARWIN_TAILSCALE_CANDIDATES;
+ if (platform === "linux") return LINUX_TAILSCALE_CANDIDATES;
+ return [];
+}
+
+export function tailscaleCommandErrorCode(
+ error: unknown,
+): "tailscale_permission_denied" | undefined {
+ if (error instanceof Error && error.message === "tailscale_permission_denied") {
+ return "tailscale_permission_denied";
+ }
+ const value = record(error);
+ const stderr = typeof value?.stderr === "string" ? value.stderr : "";
+ const message = error instanceof Error
+ ? error.message
+ : typeof value?.message === "string" ? value.message : "";
+ const haystack = `${stderr}\n${message}`;
+ return haystack.includes("Access denied: serve config denied") &&
+ haystack.includes("tailscale set --operator=")
+ ? "tailscale_permission_denied"
+ : undefined;
+}
+
function record(value: unknown): Record | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record
@@ -167,11 +200,13 @@ function normalizeDnsName(value: unknown): string | undefined {
function parseNodeStatus(serialized: string): AidenTailscaleNodeStatus {
const root = record(parseBoundedJson(serialized, "Tailscale status"));
const self = record(root?.Self);
+ const connected = root?.BackendState === "Running" && self?.Online === true;
const dnsName = normalizeDnsName(self?.DNSName);
const certDomains = Array.isArray(root?.CertDomains)
? root.CertDomains.map(normalizeDnsName).filter((value): value is string => value !== undefined)
: [];
return {
+ connected,
...(dnsName ? { dnsName } : {}),
// An exact certificate-domain match proves that the tailnet owner has
// already enabled HTTPS. Aiden never follows or accepts Tailscale's
@@ -330,8 +365,10 @@ export async function withAidenTailscaleRouteLock(
}
}
-export async function resolveTailscaleBinary(): Promise {
- for (const candidate of TAILSCALE_CANDIDATES) {
+export async function resolveTailscaleBinary(
+ platform: NodeJS.Platform = process.platform,
+): Promise {
+ for (const candidate of tailscaleBinaryCandidates(platform)) {
try {
await fs.access(candidate, fs.constants.X_OK);
return candidate;
@@ -345,7 +382,10 @@ export async function resolveTailscaleBinary(): Promise {
export async function createSystemTailscaleCommandRunner(
options: AidenTailscaleSystemRunnerOptions = {},
): Promise {
- const binary = await (options.resolveBinary ?? resolveTailscaleBinary)();
+ const platform = options.platform ?? process.platform;
+ const binary = await (
+ options.resolveBinary ?? (() => resolveTailscaleBinary(platform))
+ )();
if (!binary) return null;
const execute = options.execute ?? (async (command, args, execOptions) => {
const { stdout } = await execFileAsync(command, [...args], execOptions);
@@ -354,20 +394,23 @@ export async function createSystemTailscaleCommandRunner(
const environment = options.environment ?? process.env;
return {
run: async (args) => {
- const { stdout } = await execute(binary, args, {
- encoding: "utf8",
- env: {
- ...environment,
- // Tailscale's macOS app and CLI share one executable. Finder-launched
- // apps do not inherit TERM/SHLVL, so force the documented CLI mode
- // instead of relying on Tailscale's terminal-environment heuristic.
- TAILSCALE_BE_CLI: "1",
- },
- maxBuffer: MAX_STATUS_BYTES,
- timeout: 15_000,
- windowsHide: true,
- });
- return stdout;
+ try {
+ const { stdout } = await execute(binary, args, {
+ encoding: "utf8",
+ env: {
+ ...environment,
+ ...(platform === "darwin" ? { TAILSCALE_BE_CLI: "1" } : {}),
+ },
+ maxBuffer: MAX_STATUS_BYTES,
+ timeout: 15_000,
+ windowsHide: true,
+ });
+ return stdout;
+ } catch (error) {
+ const code = tailscaleCommandErrorCode(error);
+ if (code) throw new Error(code);
+ throw error;
+ }
},
};
}
@@ -424,7 +467,7 @@ export class AidenRemoteTailscaleController {
nodeStatus: AidenTailscaleNodeStatus,
serveStatus: AidenTailscaleStatus,
): AidenTailscaleConnectionStatus {
- const errorCode = !nodeStatus.dnsName
+ const errorCode = !nodeStatus.connected || !nodeStatus.dnsName
? "not_connected" as const
: !nodeStatus.httpsAvailable
? "https_unavailable" as const
@@ -492,7 +535,9 @@ export class AidenRemoteTailscaleController {
if (!this.runner) throw new Error("tailscale_not_installed");
const nodeStatus = await this.nodeStatus();
const serveStatus = await this.serveStatus();
- if (!nodeStatus.dnsName) throw new Error("tailscale_not_connected");
+ if (!nodeStatus.connected || !nodeStatus.dnsName) {
+ throw new Error("tailscale_not_connected");
+ }
if (!nodeStatus.httpsAvailable) throw new Error("tailscale_https_unavailable");
return { nodeStatus, serveStatus };
}
@@ -670,11 +715,13 @@ export class AidenRemoteTailscaleController {
createdAt: this.now(),
});
let commandFailed = false;
+ let commandFailureCode: string | undefined;
try {
if (nextTarget) await this.setExactRoute(nextTarget);
else await this.clearExactRoute();
- } catch {
+ } catch (error) {
commandFailed = true;
+ commandFailureCode = tailscaleCommandErrorCode(error);
}
const observed = await this.serveStatusAfterMutation("tailscale_route_outcome_unknown");
const observedSnapshot = aidenTailscaleCanonicalRouteSnapshot(observed);
@@ -700,6 +747,7 @@ export class AidenRemoteTailscaleController {
await this.outcomeStore?.clear();
} else if (observedFingerprint === serveFingerprint(before)) {
await this.outcomeStore?.clear();
+ if (commandFailureCode) throw new Error(commandFailureCode);
}
throw new Error(commandFailed
? "tailscale_route_outcome_unknown"
diff --git a/main/services/aiden-remote-tls-identity.test.ts b/main/services/aiden-remote-tls-identity.test.ts
index f18607d7..e9a6a5bf 100644
--- a/main/services/aiden-remote-tls-identity.test.ts
+++ b/main/services/aiden-remote-tls-identity.test.ts
@@ -4,7 +4,7 @@ import * as fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
-import { loadOrCreateAidenRemoteTlsIdentity } from "./aiden-remote-tls-identity.js";
+import { loadOrCreateAidenRemoteTlsIdentity, classifyAidenRemoteTlsEndpointFailure, AidenRemoteTlsEndpointError, fetchTlsServerSpkiSha256 } from "./aiden-remote-tls-identity.js";
async function temporaryDirectory(): Promise {
return fs.mkdtemp(path.join(os.tmpdir(), "aiden-remote-tls-"));
@@ -67,3 +67,42 @@ test("TLS identity fails closed instead of silently rotating an incomplete ident
await fs.rm(directory, { force: true, recursive: true });
}
});
+
+test("TLS endpoint probe failures classify into stable pairing codes", () => {
+ const timedOut = classifyAidenRemoteTlsEndpointFailure(
+ new Error("Aiden Remote TLS endpoint timed out."),
+ );
+ assert.equal(timedOut.code, "timed_out");
+ assert.match(timedOut.message, /did not respond/u);
+
+ const refused = classifyAidenRemoteTlsEndpointFailure(
+ Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:443"), { code: "ECONNREFUSED" }),
+ );
+ assert.equal(refused.code, "unreachable");
+
+ const untrusted = classifyAidenRemoteTlsEndpointFailure(
+ Object.assign(new Error("unable to verify the first certificate"), {
+ code: "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
+ }),
+ );
+ assert.equal(untrusted.code, "untrusted");
+
+ const invalid = classifyAidenRemoteTlsEndpointFailure(
+ new Error("Aiden Remote TLS endpoint is invalid."),
+ );
+ assert.equal(invalid.code, "invalid_endpoint");
+
+ const already = new AidenRemoteTlsEndpointError("timed_out", "kept");
+ assert.equal(classifyAidenRemoteTlsEndpointFailure(already), already);
+});
+
+test("invalid TLS endpoints fail closed without opening a socket", async () => {
+ await assert.rejects(
+ fetchTlsServerSpkiSha256("not a host"),
+ (error: unknown) => error instanceof AidenRemoteTlsEndpointError && error.code === "invalid_endpoint",
+ );
+ await assert.rejects(
+ fetchTlsServerSpkiSha256("aiden.tailnet.ts.net", 0),
+ (error: unknown) => error instanceof AidenRemoteTlsEndpointError && error.code === "invalid_endpoint",
+ );
+});
diff --git a/main/services/aiden-remote-tls-identity.ts b/main/services/aiden-remote-tls-identity.ts
index 06bcd687..2f0e3714 100644
--- a/main/services/aiden-remote-tls-identity.ts
+++ b/main/services/aiden-remote-tls-identity.ts
@@ -9,6 +9,7 @@ import * as fs from "node:fs/promises";
import path from "node:path";
import tls from "node:tls";
import { promisify } from "node:util";
+import type { AidenRemoteTlsEndpointErrorCode } from "../../renderer/shared/aiden-remote.js";
const execFileAsync = promisify(execFile);
const DEFAULT_OPENSSL_PATH = "/usr/bin/openssl";
@@ -82,6 +83,76 @@ function spkiDigest(value: string | Buffer): string {
return `sha256/${createHash("sha256").update(spki).digest("base64")}`;
}
+const TLS_PROBE_TIMEOUT_MS = 5_000;
+const UNREACHABLE_SYSTEM_CODES = new Set([
+ "ECONNREFUSED",
+ "ECONNRESET",
+ "EHOSTUNREACH",
+ "ENETUNREACH",
+ "ENOTFOUND",
+ "EPIPE",
+ "ETIMEDOUT",
+]);
+
+export class AidenRemoteTlsEndpointError extends Error {
+ readonly code: AidenRemoteTlsEndpointErrorCode;
+
+ constructor(code: AidenRemoteTlsEndpointErrorCode, message: string) {
+ super(message);
+ this.name = "AidenRemoteTlsEndpointError";
+ this.code = code;
+ }
+}
+
+function errorCode(error: unknown): string {
+ if (typeof error !== "object" || error === null || !("code" in error)) return "";
+ return typeof error.code === "string" ? error.code : "";
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+export function classifyAidenRemoteTlsEndpointFailure(error: unknown): AidenRemoteTlsEndpointError {
+ if (error instanceof AidenRemoteTlsEndpointError) return error;
+ const code = errorCode(error);
+ const message = errorMessage(error);
+ if (code === "ERR_INVALID_ARG" || /TLS endpoint is invalid/u.test(message)) {
+ return new AidenRemoteTlsEndpointError(
+ "invalid_endpoint",
+ "The Tailscale DNS name is invalid.",
+ );
+ }
+ if (/timed out/iu.test(message) || code === "ETIMEDOUT") {
+ return new AidenRemoteTlsEndpointError(
+ "timed_out",
+ "The Tailscale HTTPS endpoint did not respond. Confirm Serve still points at this Aiden profile, then try pairing again.",
+ );
+ }
+ if (
+ UNREACHABLE_SYSTEM_CODES.has(code)
+ || /ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|ENETUNREACH|ECONNRESET/u.test(message)
+ ) {
+ return new AidenRemoteTlsEndpointError(
+ "unreachable",
+ "Aiden couldn't reach the Tailscale HTTPS endpoint. Confirm Tailscale is connected and the Serve route is current.",
+ );
+ }
+ if (
+ /certificate|UNABLE_TO_VERIFY|CERT_|ERR_TLS|altname|self[- ]signed/iu.test(`${code} ${message}`)
+ || /has no certificate/u.test(message)
+ ) {
+ return new AidenRemoteTlsEndpointError(
+ "untrusted",
+ "The Tailscale HTTPS certificate could not be verified. Check HTTPS on this Tailscale name, then try again.",
+ );
+ }
+ return new AidenRemoteTlsEndpointError(
+ "unreachable",
+ "Aiden couldn't reach the Tailscale HTTPS endpoint. Confirm Tailscale is connected and the Serve route is current.",
+ );
+}
+
export async function fetchTlsServerSpkiSha256(
hostname: string,
port = 443,
@@ -92,9 +163,15 @@ export async function fetchTlsServerSpkiSha256(
port < 1 ||
port > 65_535
) {
- throw new Error("Aiden Remote TLS endpoint is invalid.");
+ throw new AidenRemoteTlsEndpointError(
+ "invalid_endpoint",
+ "The Tailscale DNS name is invalid.",
+ );
}
return new Promise((resolve, reject) => {
+ const fail = (error: unknown) => {
+ reject(classifyAidenRemoteTlsEndpointFailure(error));
+ };
const socket = tls.connect({
host: hostname,
port,
@@ -102,15 +179,23 @@ export async function fetchTlsServerSpkiSha256(
rejectUnauthorized: true,
});
const timeout = setTimeout(() => {
- socket.destroy(new Error("Aiden Remote TLS endpoint timed out."));
- }, 5_000);
+ socket.destroy(new AidenRemoteTlsEndpointError(
+ "timed_out",
+ "The Tailscale HTTPS endpoint did not respond. Confirm Serve still points at this Aiden profile, then try pairing again.",
+ ));
+ }, TLS_PROBE_TIMEOUT_MS);
socket.once("secureConnect", () => {
try {
const certificate = socket.getPeerCertificate(true);
- if (!certificate.raw?.length) throw new Error("Aiden Remote TLS endpoint has no certificate.");
+ if (!certificate.raw?.length) {
+ throw new AidenRemoteTlsEndpointError(
+ "untrusted",
+ "The Tailscale HTTPS certificate could not be verified. Check HTTPS on this Tailscale name, then try again.",
+ );
+ }
resolve(spkiDigest(certificate.raw));
} catch (error) {
- reject(error);
+ fail(error);
} finally {
clearTimeout(timeout);
socket.end();
@@ -118,7 +203,7 @@ export async function fetchTlsServerSpkiSha256(
});
socket.once("error", (error) => {
clearTimeout(timeout);
- reject(error);
+ fail(error);
});
});
}
diff --git a/main/services/aiden-remote-workspace-browser.ts b/main/services/aiden-remote-workspace-browser.ts
index e0e4c46a..e94cef84 100644
--- a/main/services/aiden-remote-workspace-browser.ts
+++ b/main/services/aiden-remote-workspace-browser.ts
@@ -174,7 +174,7 @@ export class AidenRemoteWorkspaceBrowserService {
if (error instanceof AidenOpaqueHandleError) mapHandleError(error);
throw new AidenRemoteServiceError(
"workspace_unavailable",
- "This approved folder is not currently available on the Mac.",
+ "This approved folder is not currently available on the desktop.",
409,
);
}
diff --git a/main/services/app-updater-core.test.ts b/main/services/app-updater-core.test.ts
index cb86f1eb..650d9dad 100644
--- a/main/services/app-updater-core.test.ts
+++ b/main/services/app-updater-core.test.ts
@@ -4,6 +4,7 @@ import test from "node:test";
import {
AppUpdateController,
+ AppUpdateInstallHandoff,
appUpdateRetryDelay,
configureAppUpdater,
shouldEnableAppUpdates,
@@ -327,3 +328,24 @@ test("production updater awaits downloads and exposes a sender-scoped retry entr
assert.match(handler, /event\.sender\.id !== mainWindow\.webContents\.id/u);
assert.match(handler, /appUpdateService\.checkNow\(false\)/u);
});
+
+test("Linux install handoff reports swallowed installer failure so protected shutdown can quit", () => {
+ const handoff = new AppUpdateInstallHandoff();
+ let quitScheduled = false;
+ const upstreamQuitAndInstall = (install: () => boolean): void => {
+ // Model BaseUpdater: install catches doInstall errors, while quitAndInstall
+ // returns void and schedules quit only when install actually returned true.
+ const installed = handoff.recordInstall(() => {
+ try { return install(); } catch { return false; }
+ });
+ if (installed) quitScheduled = true;
+ };
+ for (const install of [() => false, () => { throw new Error("checksum changed"); }, () => { throw new Error("disk failure"); }, () => { throw new Error("inode changed"); }]) {
+ assert.equal(handoff.run(() => upstreamQuitAndInstall(install)), false);
+ assert.equal(quitScheduled, false);
+ }
+ assert.equal(handoff.run(() => upstreamQuitAndInstall(() => true)), true);
+ assert.equal(quitScheduled, true);
+ // A duplicate or early-return call must not reuse a previous success.
+ assert.equal(handoff.run(() => {}), false);
+});
diff --git a/main/services/app-updater-core.ts b/main/services/app-updater-core.ts
index e274e116..fdd1f44d 100644
--- a/main/services/app-updater-core.ts
+++ b/main/services/app-updater-core.ts
@@ -10,6 +10,7 @@ export interface AppUpdaterEnvironment {
platform: NodeJS.Platform;
runtimeProfile: "production" | "development";
updateConfigExists: boolean;
+ linuxAppImageEligible?: boolean;
}
export interface AppUpdateDriverResult {
@@ -32,8 +33,7 @@ export interface ConfigurableAppUpdater {
}
export function configureAppUpdater(updater: ConfigurableAppUpdater): void {
- // Aiden's GitHub release flow intentionally publishes one verified ZIP and
- // no separate blockmaps. Own the full download so failures and retries are
+ // Own full ZIP/AppImage downloads so failures and retries are
// observable instead of letting electron-updater start a detached promise.
updater.autoDownload = false;
updater.autoInstallOnAppQuit = true;
@@ -73,7 +73,7 @@ export class AppUpdateController {
listener(this.currentSnapshot);
} catch {
// A renderer notification failure must not abort or duplicate the
- // signed download. Other listeners still receive the state change.
+ // update download. Other listeners still receive the state change.
}
}
}
@@ -198,9 +198,26 @@ export class AppUpdateController {
export function shouldEnableAppUpdates(environment: AppUpdaterEnvironment): boolean {
return (
- environment.platform === "darwin" &&
+ (environment.platform === "darwin" || (environment.platform === "linux" && environment.linuxAppImageEligible === true)) &&
environment.isPackaged &&
environment.runtimeProfile === "production" &&
environment.updateConfigExists
);
}
+
+/** BaseUpdater.quitAndInstall returns void even when synchronous install fails. */
+export class AppUpdateInstallHandoff {
+ private installed = false;
+
+ recordInstall(install: () => boolean): boolean {
+ this.installed = false;
+ this.installed = install();
+ return this.installed;
+ }
+
+ run(quitAndInstall: () => void): boolean {
+ this.installed = false;
+ quitAndInstall();
+ return this.installed;
+ }
+}
diff --git a/main/services/app-updater-linux.test.ts b/main/services/app-updater-linux.test.ts
new file mode 100644
index 00000000..e5f147a2
--- /dev/null
+++ b/main/services/app-updater-linux.test.ts
@@ -0,0 +1,104 @@
+import { createHash } from "node:crypto";
+import assert from "node:assert/strict";
+import { mkdtempSync, readFileSync, readdirSync, renameSync, statSync, realpathSync, mkdirSync, writeFileSync, chmodSync, symlinkSync, rmSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+import { appImageIdentity, canUpdateLinuxAppImage, isRegularAppImage, replaceAppImageAtomically } from "./app-updater-linux.js";
+import { shouldEnableAppUpdates } from "./app-updater-core.js";
+
+function fixture() {
+ const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), "aiden-appimage-update-")));
+ const appDir = path.join(root, "mount");
+ const resourcesPath = path.join(appDir, "resources");
+ mkdirSync(resourcesPath, { recursive: true });
+ const executablePath = path.join(appDir, "aiden-agent");
+ const appImage = path.join(root, "Aiden.AppImage");
+ writeFileSync(appImage, Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1, 0, 0x41, 0x49, 2, 0]), { mode: 0o755 });
+ writeFileSync(executablePath, "executable");
+ writeFileSync(path.join(resourcesPath, "app-update.yml"), "provider: github\n");
+ const runtime = { appImage, appDir, resourcesPath, executablePath, mountInfo: `1 0 0:1 / ${appDir} ro - fuse.Aiden Aiden ro`, uid: process.getuid?.() };
+ return { root, runtime, cleanup: () => rmSync(root, { recursive: true, force: true }) };
+}
+
+test("Linux updates require packaged production plus verified AppImage eligibility", () => {
+ for (const isPackaged of [false, true]) for (const runtimeProfile of ["development", "production"] as const) for (const updateConfigExists of [false, true]) for (const linuxAppImageEligible of [false, true]) {
+ assert.equal(shouldEnableAppUpdates({ platform: "linux", isPackaged, runtimeProfile, updateConfigExists, linuxAppImageEligible }), isPackaged && runtimeProfile === "production" && updateConfigExists && linuxAppImageEligible);
+ }
+});
+
+test("mounted portable AppImage is eligible but extraction and distro packages are not", () => {
+ const f = fixture();
+ try {
+ assert.equal(canUpdateLinuxAppImage(f.runtime), true);
+ assert.equal(canUpdateLinuxAppImage({ ...f.runtime, mountInfo: "" }), false);
+ assert.equal(canUpdateLinuxAppImage({ ...f.runtime, appImage: undefined }), false);
+ assert.equal(canUpdateLinuxAppImage({ ...f.runtime, executablePath: process.execPath }), false);
+ for (const type of ["deb", "rpm", "pacman", "unknown"]) {
+ writeFileSync(path.join(f.runtime.resourcesPath, "package-type"), type);
+ assert.equal(canUpdateLinuxAppImage(f.runtime), false);
+ }
+ writeFileSync(path.join(f.runtime.resourcesPath, "package-type"), "appimage");
+ assert.equal(canUpdateLinuxAppImage(f.runtime), true);
+ } finally { f.cleanup(); }
+});
+
+test("image and embedded metadata must remain regular and replacement-ready", () => {
+ const f = fixture();
+ try {
+ const link = path.join(f.root, "linked.AppImage");
+ symlinkSync(f.runtime.appImage, link);
+ assert.equal(canUpdateLinuxAppImage({ ...f.runtime, appImage: link }), false);
+ assert.equal(isRegularAppImage(link), false);
+ assert.equal(canUpdateLinuxAppImage({ ...f.runtime, uid: (process.getuid?.() ?? 0) + 1 }), false);
+ chmodSync(f.runtime.appImage, 0o555);
+ assert.equal(canUpdateLinuxAppImage(f.runtime), false);
+ chmodSync(f.runtime.appImage, 0o644);
+ assert.equal(canUpdateLinuxAppImage(f.runtime), false);
+ chmodSync(f.runtime.appImage, 0o755);
+ writeFileSync(path.join(f.runtime.resourcesPath, "app-update.yml"), "");
+ assert.equal(canUpdateLinuxAppImage(f.runtime), false);
+ writeFileSync(path.join(f.runtime.resourcesPath, "app-update.yml"), "provider: github\n");
+ writeFileSync(f.runtime.appImage, "ordinary executable");
+ assert.equal(canUpdateLinuxAppImage(f.runtime), false);
+ } finally { f.cleanup(); }
+});
+
+test("AppImage replacement verifies its digest and atomically preserves the current filename", () => {
+ const f = fixture();
+ try {
+ const installer = path.join(f.root, "Aiden-9.9.9.AppImage");
+ const next = Buffer.concat([readFileSync(f.runtime.appImage), Buffer.from("new-version")]);
+ writeFileSync(installer, next);
+ const original = appImageIdentity(f.runtime.appImage);
+ const sha512 = createHash("sha512").update(next).digest("base64");
+ assert.equal(replaceAppImageAtomically({ current: original, installer, sha512, eligible: () => canUpdateLinuxAppImage(f.runtime) }), original.path);
+ assert.deepEqual(readFileSync(original.path), next);
+ assert.equal(statSync(original.path).mode & 0o777, 0o755);
+ assert.notEqual(statSync(original.path).ino, original.ino);
+ assert.ok(readdirSync(f.root).every(name => !name.startsWith(".aiden-update-")));
+ } finally { f.cleanup(); }
+});
+
+test("failed AppImage checksum and changed installation retain the original file and clean temporary data", () => {
+ const f = fixture();
+ try {
+ const installer = path.join(f.root, "next.AppImage");
+ const original = readFileSync(f.runtime.appImage);
+ const next = Buffer.concat([original, Buffer.from("new-version")]);
+ writeFileSync(installer, next);
+ const identity = appImageIdentity(f.runtime.appImage);
+ const sha512 = createHash("sha512").update(next).digest("base64");
+ assert.throws(() => replaceAppImageAtomically({ current: identity, installer, sha512: createHash("sha512").update("wrong").digest("base64"), eligible: () => true }), /checksum/);
+ assert.deepEqual(readFileSync(identity.path), original);
+ let checks = 0;
+ assert.throws(() => replaceAppImageAtomically({ current: identity, installer, sha512, eligible: () => ++checks === 1 }), /changed/);
+ assert.deepEqual(readFileSync(identity.path), original);
+ const moved = path.join(f.root, "replacement.AppImage");
+ writeFileSync(moved, original);
+ renameSync(moved, identity.path);
+ assert.throws(() => replaceAppImageAtomically({ current: identity, installer, sha512, eligible: () => true }), /unavailable/);
+ assert.deepEqual(readFileSync(identity.path), original);
+ assert.ok(readdirSync(f.root).every(name => !name.startsWith(".aiden-update-")));
+ } finally { f.cleanup(); }
+});
diff --git a/main/services/app-updater-linux.ts b/main/services/app-updater-linux.ts
new file mode 100644
index 00000000..c7f4453e
--- /dev/null
+++ b/main/services/app-updater-linux.ts
@@ -0,0 +1,127 @@
+import { createHash, randomUUID } from "node:crypto";
+import { accessSync, closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, openSync, readFileSync, readSync, realpathSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
+import path from "node:path";
+
+export interface LinuxAppImageUpdateRuntime {
+ appImage?: string;
+ appDir?: string;
+ resourcesPath: string;
+ executablePath: string;
+ mountInfo?: string;
+ uid?: number;
+}
+
+function inside(root: string, target: string): boolean {
+ const relative = path.relative(root, target);
+ return relative !== "" && !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative);
+}
+
+/** Type-2 AppImage ELF marker; arbitrary executables are never replacement inputs. */
+export function isRegularAppImage(file: string, uid?: number): boolean {
+ let fd: number | undefined;
+ try {
+ if (!path.isAbsolute(file) || file.includes("\0")) return false;
+ const metadata = lstatSync(file);
+ if (!metadata.isFile() || metadata.nlink !== 1 || (uid !== undefined && metadata.uid !== uid)) return false;
+ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
+ const header = Buffer.alloc(11);
+ return readSync(fd, header, 0, header.length, 0) === header.length &&
+ header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) &&
+ header.subarray(8, 11).equals(Buffer.from([0x41, 0x49, 0x02]));
+ } catch { return false; } finally { if (fd !== undefined) closeSync(fd); }
+}
+
+/** Only a mounted, replaceable AppImage owns its updates; distro packages stay external. */
+export function canUpdateLinuxAppImage(runtime: LinuxAppImageUpdateRuntime): boolean {
+ try {
+ const image = runtime.appImage;
+ const appDir = runtime.appDir;
+ if (!image || !appDir || !path.isAbsolute(appDir) || !isRegularAppImage(image, runtime.uid)) return false;
+ if (realpathSync(image) !== image || realpathSync(appDir) !== appDir || !statSync(appDir).isDirectory()) return false;
+ const resources = realpathSync(runtime.resourcesPath);
+ if (!inside(appDir, resources) || !inside(appDir, realpathSync(runtime.executablePath))) return false;
+ const config = path.join(resources, "app-update.yml");
+ const configStat = lstatSync(config);
+ if (!configStat.isFile() || configStat.size === 0 || configStat.size > 65_536) return false;
+ accessSync(config, constants.R_OK);
+ try {
+ if (readFileSync(path.join(resources, "package-type"), "utf8").trim() !== "appimage") return false;
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false; }
+ if ((lstatSync(image).mode & 0o300) !== 0o300) return false;
+ accessSync(image, constants.R_OK | constants.W_OK | constants.X_OK);
+ accessSync(path.dirname(image), constants.W_OK | constants.X_OK);
+ const mounts = runtime.mountInfo ?? readFileSync("/proc/self/mountinfo", "utf8");
+ return mounts.split("\n").some((line) => {
+ const [fields, filesystem] = line.split(" - ");
+ const mount = fields?.split(" ")[4]?.replace(/\\([0-7]{3})/gu, (_, octal: string) => String.fromCharCode(Number.parseInt(octal, 8)));
+ const type = filesystem?.split(" ")[0];
+ return mount === appDir && (type === "fuse" || type?.startsWith("fuse.") === true || type === "squashfs");
+ });
+ } catch { return false; }
+}
+
+export interface AppImageIdentity { path: string; dev: number; ino: number; }
+export function appImageIdentity(file: string): AppImageIdentity {
+ const info = lstatSync(file);
+ if (!info.isFile()) throw new Error("The current AppImage is no longer a regular file.");
+ return { path: file, dev: info.dev, ino: info.ino };
+}
+
+/** Keep the old image intact until the fully copied, digest-verified image is durable. */
+export function replaceAppImageAtomically(options: {
+ current: AppImageIdentity;
+ installer: string;
+ sha512: string;
+ eligible: () => boolean;
+}): string {
+ const { current, installer, sha512 } = options;
+ const stillCurrent = (): boolean => {
+ const actual = appImageIdentity(current.path);
+ return actual.dev === current.dev && actual.ino === current.ino;
+ };
+ if (!options.eligible() || !stillCurrent() || !isRegularAppImage(installer) ||
+ !/^[A-Za-z0-9+/]{86}==$/u.test(sha512)) throw new Error("AppImage update installation is unavailable.");
+ const temporary = path.join(path.dirname(current.path), `.aiden-update-${randomUUID()}.tmp`);
+ let source: number | undefined;
+ let target: number | undefined;
+ let directory: number | undefined;
+ let temporaryExists = false;
+ try {
+ source = openSync(installer, constants.O_RDONLY | constants.O_NOFOLLOW);
+ const sourceInfo = fstatSync(source);
+ if (!sourceInfo.isFile()) throw new Error("The downloaded AppImage is unavailable.");
+ target = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
+ temporaryExists = true;
+ const hash = createHash("sha512");
+ const buffer = Buffer.alloc(1024 * 1024);
+ let copied = 0;
+ while (copied < sourceInfo.size) {
+ const count = readSync(source, buffer, 0, Math.min(buffer.length, sourceInfo.size - copied), copied);
+ if (count === 0) throw new Error("The downloaded AppImage was truncated.");
+ hash.update(buffer.subarray(0, count));
+ let written = 0;
+ while (written < count) {
+ const bytes = writeSync(target, buffer, written, count - written);
+ if (bytes === 0) throw new Error("The downloaded AppImage could not be copied.");
+ written += bytes;
+ }
+ copied += count;
+ }
+ if (fstatSync(source).size !== sourceInfo.size || hash.digest("base64") !== sha512) throw new Error("The downloaded AppImage checksum changed.");
+ fchmodSync(target, 0o755);
+ fsyncSync(target);
+ closeSync(target);
+ target = undefined;
+ if (!options.eligible() || !stillCurrent()) throw new Error("The current AppImage changed during installation.");
+ renameSync(temporary, current.path);
+ temporaryExists = false;
+ directory = openSync(path.dirname(current.path), constants.O_RDONLY);
+ fsyncSync(directory);
+ return current.path;
+ } finally {
+ if (source !== undefined) closeSync(source);
+ if (target !== undefined) closeSync(target);
+ if (directory !== undefined) closeSync(directory);
+ if (temporaryExists) unlinkSync(temporary);
+ }
+}
diff --git a/main/services/app-updater.ts b/main/services/app-updater.ts
index ba3a63db..455c16af 100644
--- a/main/services/app-updater.ts
+++ b/main/services/app-updater.ts
@@ -1,3 +1,4 @@
+import { appImageIdentity, canUpdateLinuxAppImage, replaceAppImageAtomically, type AppImageIdentity } from "./app-updater-linux.js";
import { existsSync } from "node:fs";
import path from "node:path";
import electronUpdater, {
@@ -14,6 +15,7 @@ import { isPackagedRuntime } from "../runtime-mode.js";
import { currentRuntimeProfile } from "../runtime-profile.js";
import {
AppUpdateController,
+ AppUpdateInstallHandoff,
appUpdateRetryDelay,
configureAppUpdater,
shouldEnableAppUpdates,
@@ -22,14 +24,55 @@ import {
const INITIAL_CHECK_DELAY_MS = 15_000;
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1_000;
const DOWNLOAD_STALL_TIMEOUT_MS = 2 * 60 * 1_000;
-const { autoUpdater, CancellationToken } = electronUpdater;
+const { CancellationToken } = electronUpdater;
+class GuardedAppImageUpdater extends electronUpdater.AppImageUpdater {
+ private originalImage: AppImageIdentity | undefined;
+ private readonly installHandoff = new AppUpdateInstallHandoff();
-function updaterEnabled(): boolean {
+ override install(isSilent = false, isForceRunAfter = false): boolean {
+ return this.installHandoff.recordInstall(() => super.install(isSilent, isForceRunAfter));
+ }
+
+ quitAndInstallWithResult(): boolean {
+ return this.installHandoff.run(() => this.quitAndInstall(false, true));
+ }
+
+ pinCurrentImage(): void {
+ this.originalImage = appImageIdentity(process.env.APPIMAGE!);
+ }
+
+ protected override doInstall(options: { isForceRunAfter: boolean }): boolean {
+ if (!this.originalImage || !this.installerPath) return false;
+ const sha512 = this.downloadedUpdateHelper?.downloadedFileInfo?.sha512;
+ if (!sha512) return false;
+ const destination = replaceAppImageAtomically({
+ current: this.originalImage,
+ installer: this.installerPath,
+ sha512,
+ eligible: () => process.env.APPIMAGE === this.originalImage?.path && supportsAppUpdates(),
+ });
+ // Upstream download/checksum behavior is retained; replace without shell tools
+ // and preserve the current filename. A normal quit needs no helper execution.
+ if (options.isForceRunAfter) void this.spawnLog(destination, [], { ...process.env, APPIMAGE_SILENT_INSTALL: "true" });
+ return true;
+ }
+}
+// Never let electron-updater select a DEB/RPM installer from package-type.
+const autoUpdater = process.platform === "linux" ? new GuardedAppImageUpdater() : electronUpdater.autoUpdater;
+
+export function supportsAppUpdates(): boolean {
return shouldEnableAppUpdates({
isPackaged: isPackagedRuntime(),
platform: process.platform,
runtimeProfile: currentRuntimeProfile().id,
- updateConfigExists: existsSync(path.join(process.resourcesPath, "app-update.yml")),
+ updateConfigExists: typeof process.resourcesPath === "string" && existsSync(path.join(process.resourcesPath, "app-update.yml")),
+ linuxAppImageEligible: process.platform === "linux" && canUpdateLinuxAppImage({
+ appImage: process.env.APPIMAGE,
+ appDir: process.env.APPDIR,
+ resourcesPath: process.resourcesPath,
+ executablePath: process.execPath,
+ uid: process.getuid?.(),
+ }),
});
}
@@ -110,13 +153,14 @@ export class AppUpdateService {
}
canInstallDownloadedUpdate(): boolean {
- return this.snapshot().status === "ready";
+ return supportsAppUpdates() && this.snapshot().status === "ready";
}
installDownloadedUpdateAndRestart(): boolean {
if (!this.canInstallDownloadedUpdate()) return false;
try {
autoUpdater.autoRunAppAfterInstall = true;
+ if (autoUpdater instanceof GuardedAppImageUpdater) return autoUpdater.quitAndInstallWithResult();
autoUpdater.quitAndInstall(false, true);
return true;
} catch (error) {
@@ -126,7 +170,11 @@ export class AppUpdateService {
}
start(): void {
- if (this.started || !updaterEnabled()) return;
+ if (this.started || !supportsAppUpdates()) return;
+ if (autoUpdater instanceof GuardedAppImageUpdater) {
+ try { autoUpdater.pinCurrentImage(); }
+ catch { return; }
+ }
this.started = true;
autoUpdater.logger = updaterLogger();
configureAppUpdater(autoUpdater);
@@ -140,12 +188,14 @@ export class AppUpdateService {
}
async checkNow(manual: boolean): Promise {
- if (!updaterEnabled()) {
+ if (!supportsAppUpdates()) {
if (manual) {
await dialog.showMessageBox({
type: "info",
title: "Updates unavailable in this build",
- message: "Automatic updates are available in signed Aiden Agent distribution builds.",
+ message: process.platform === "linux"
+ ? "In-app updates require a mounted, writable Aiden Agent AppImage. Update distro packages with your package manager."
+ : "Automatic updates are available in signed Aiden Agent distribution builds.",
buttons: ["OK"],
defaultId: 0,
noLink: true,
@@ -154,6 +204,7 @@ export class AppUpdateService {
return { outcome: "unavailable" };
}
if (!this.started) this.start();
+ if (!this.started) return { outcome: "unavailable" };
if (this.checkPromise) return this.checkPromise;
if (this.retryTimer) clearTimeout(this.retryTimer);
this.retryTimer = null;
diff --git a/main/services/application-menu-core.test.ts b/main/services/application-menu-core.test.ts
new file mode 100644
index 00000000..5b2919bf
--- /dev/null
+++ b/main/services/application-menu-core.test.ts
@@ -0,0 +1,56 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ applicationMenuTemplate,
+ platformMenuAccelerator,
+} from "./application-menu-core.js";
+
+const actions = {
+ checkForUpdates() {},
+ deliverCommand() {},
+ reload() {},
+};
+
+test("macOS menu retains application services and update entry", () => {
+ const menu = applicationMenuTemplate({
+ platform: "darwin",
+ appName: "Aiden Agent",
+ bindings: {},
+ actions,
+ });
+ assert.equal(menu[0]?.label, "Aiden Agent");
+ assert.ok(
+ Array.isArray(menu[0]?.submenu) &&
+ menu[0].submenu.some((item) => item.role === "services"),
+ );
+ assert.ok(
+ Array.isArray(menu[0]?.submenu) &&
+ menu[0].submenu.some((item) => item.label === "Check for Updates…"),
+ );
+});
+
+test("Linux menu uses conventional File and Help ownership", () => {
+ const menu = applicationMenuTemplate({
+ platform: "linux",
+ appName: "Aiden Agent",
+ bindings: {},
+ actions,
+ });
+ assert.deepEqual(
+ menu.map((item) => item.label ?? item.role),
+ ["File", "editMenu", "View", "windowMenu", "Help"],
+ );
+ const serialized = JSON.stringify(menu);
+ assert.equal(serialized.includes("Check for Updates"), false);
+ assert.equal(serialized.includes('"role":"services"'), false);
+ const file = menu[0];
+ assert.ok(Array.isArray(file.submenu) && file.submenu.some((item) => item.role === "quit"));
+});
+
+test("Linux native menus translate canonical Command bindings to Ctrl", () => {
+ assert.equal(platformMenuAccelerator("Command+Shift+N", "linux"), "CommandOrControl+Shift+N");
+ assert.equal(platformMenuAccelerator("Control+K", "linux"), "Super+K");
+ assert.equal(platformMenuAccelerator("Command+Shift+N", "darwin"), "Command+Shift+N");
+ assert.equal(platformMenuAccelerator(null, "linux"), undefined);
+});
diff --git a/main/services/application-menu-core.ts b/main/services/application-menu-core.ts
new file mode 100644
index 00000000..e8e545c5
--- /dev/null
+++ b/main/services/application-menu-core.ts
@@ -0,0 +1,117 @@
+import type { MenuItemConstructorOptions } from "electron";
+import { electronAcceleratorForPlatform } from "../../renderer/shared/keybindings.js";
+
+export interface ApplicationMenuActions {
+ checkForUpdates(): void;
+ deliverCommand(commandId: string): void;
+ reload(ignoreCache: boolean): void;
+}
+
+export function platformMenuAccelerator(
+ binding: string | null | undefined,
+ platform: NodeJS.Platform,
+): string | undefined {
+ if (!binding) return undefined;
+ return electronAcceleratorForPlatform(binding, platform);
+}
+
+export function applicationMenuTemplate({
+ platform,
+ appName,
+ bindings,
+ actions,
+}: {
+ platform: NodeJS.Platform;
+ appName: string;
+ bindings: Readonly>;
+ actions: ApplicationMenuActions;
+}): MenuItemConstructorOptions[] {
+ const commandItem = (
+ label: string,
+ commandId: string,
+ ): MenuItemConstructorOptions => ({
+ label,
+ accelerator: platformMenuAccelerator(bindings[commandId], platform),
+ click: () => actions.deliverCommand(commandId),
+ });
+ const fileItems: MenuItemConstructorOptions[] = [
+ commandItem("New Chat", "chat.new"),
+ commandItem(
+ "Open Workspace in Preferred Editor",
+ "workspace.openPreferredEditor",
+ ),
+ ];
+ if (platform !== "darwin") {
+ fileItems.push(
+ { type: "separator" },
+ commandItem("Settings…", "settings.open"),
+ { type: "separator" },
+ { role: "quit" },
+ );
+ } else {
+ fileItems.push({ type: "separator" }, { role: "close" });
+ }
+
+ const template: MenuItemConstructorOptions[] = [];
+ if (platform === "darwin") {
+ template.push({
+ label: appName,
+ submenu: [
+ { role: "about" },
+ {
+ label: "Check for Updates…",
+ click: actions.checkForUpdates,
+ },
+ { type: "separator" },
+ commandItem("Command Palette…", "commandPalette.toggle"),
+ commandItem("Settings…", "settings.open"),
+ { type: "separator" },
+ { role: "services" },
+ { type: "separator" },
+ { role: "hide" },
+ { role: "hideOthers" },
+ { role: "unhide" },
+ { type: "separator" },
+ { role: "quit" },
+ ],
+ });
+ }
+ template.push(
+ { label: "File", submenu: fileItems },
+ { role: "editMenu" },
+ {
+ label: "View",
+ submenu: [
+ {
+ label: "Reload",
+ accelerator: "CmdOrCtrl+R",
+ click: () => actions.reload(false),
+ },
+ {
+ label: "Force Reload",
+ accelerator: "CmdOrCtrl+Shift+R",
+ click: () => actions.reload(true),
+ },
+ { role: "toggleDevTools" },
+ { type: "separator" },
+ { role: "resetZoom" },
+ { role: "zoomIn" },
+ { role: "zoomOut" },
+ { type: "separator" },
+ { role: "togglefullscreen" },
+ ],
+ },
+ { role: "windowMenu" },
+ );
+ if (platform !== "darwin") {
+ template.push({
+ label: "Help",
+ submenu: [
+ commandItem("Command Palette…", "commandPalette.toggle"),
+ { type: "separator" },
+ { role: "about" },
+ ],
+ });
+ }
+ return template;
+}
diff --git a/main/services/assistant/system-prompt.ts b/main/services/assistant/system-prompt.ts
index 1d70ffb0..cda3c236 100644
--- a/main/services/assistant/system-prompt.ts
+++ b/main/services/assistant/system-prompt.ts
@@ -245,7 +245,7 @@ export function buildAssistantSystemPrompt(input: AssistantPromptInput): string
"live value.",
].join(" ");
const prompt = [
- "You are Aiden, the in-app assistant for Aiden Agent, a macOS desktop app for",
+ "You are Aiden, the in-app assistant for Aiden Agent, a desktop app for",
"chatting with AI models across a user's coding projects. You help the user",
"understand and operate the app itself: you answer questions about it and explain",
"its settings.",
diff --git a/main/services/bot-capability-authority-item.ts b/main/services/bot-capability-authority-item.ts
new file mode 100644
index 00000000..aead5c85
--- /dev/null
+++ b/main/services/bot-capability-authority-item.ts
@@ -0,0 +1,163 @@
+import { createHash } from "node:crypto";
+import * as path from "node:path";
+import type { BotCapabilityBootstrapMarker, BotCapabilityBootstrapMarkerState, BotCapabilityRollbackAnchor } from "./bot-capability-state-checkpoint.js";
+import { BotCapabilityUnavailableError } from "./bot-capability-store-core.js";
+export const ROLLBACK_SERVICE = "com.aiden.bot-capability.rollback-authority.v1";
+export const BOOTSTRAP_SERVICE = "com.aiden.bot-capability.bootstrap-consumed.v1";
+export const TELEGRAM_BINDING_SERVICE = "com.aiden.telegram-bot-binding.rollback-authority.v1";
+export const TELEGRAM_BINDING_BOOTSTRAP_SERVICE =
+ "com.aiden.telegram-bot-binding.bootstrap-consumed.v1";
+export const MAX_VALUE_BYTES = 1_024;
+const ACCOUNT_PREFIX = "user-data:";
+const MARKER_PATTERN = /^(pending|consumed):([a-f0-9]{64})$/u;
+export interface AuthorityTransport {
+ read(account: string): Promise;
+ write(account: string, value: string): Promise;
+}
+export function botCapabilityAuthorityAccountForCanonicalRoot(
+ root: string,
+): string {
+ if (!path.isAbsolute(root) || path.resolve(root) === path.parse(root).root) {
+ throw new BotCapabilityUnavailableError(
+ "Bot rollback authority requires a canonical private user-data root.",
+ );
+ }
+ return `${ACCOUNT_PREFIX}${createHash("sha256").update(path.resolve(root)).digest("hex")}`;
+}
+
+export function validateAccount(value: string): string {
+ if (
+ !value.startsWith(ACCOUNT_PREFIX) ||
+ value.length !== ACCOUNT_PREFIX.length + 64 ||
+ !/^[a-f0-9]+$/u.test(value.slice(ACCOUNT_PREFIX.length))
+ ) {
+ throw new BotCapabilityUnavailableError(
+ "Bot rollback authority account is invalid.",
+ );
+ }
+ return value;
+}
+
+export function validateValue(value: string): string {
+ if (
+ value.length === 0 ||
+ Buffer.byteLength(value, "utf8") > MAX_VALUE_BYTES ||
+ value.includes("\0") ||
+ value.includes("\n") ||
+ value.includes("\r")
+ ) {
+ throw new BotCapabilityUnavailableError(
+ "Bot rollback authority value is invalid.",
+ );
+ }
+ return value;
+}
+
+export interface BotCapabilityAuthorityItemOptions {
+ account: string | (() => string | Promise);
+}
+
+export function createAuthorityItem(
+ options: BotCapabilityAuthorityItemOptions,
+ transport: AuthorityTransport,
+ label: string,
+): BotCapabilityRollbackAnchor {
+ let accountPromise: Promise | undefined;
+ const account = (): Promise => {
+ accountPromise ??= Promise.resolve(
+ typeof options.account === "function"
+ ? options.account()
+ : options.account,
+ )
+ .then(validateAccount)
+ .catch((error) => {
+ accountPromise = undefined;
+ throw error;
+ });
+ return accountPromise;
+ };
+
+ const read = async (): Promise => {
+ const accountValue = await account();
+ try {
+ const value = await transport.read(accountValue);
+ return value === null ? null : validateValue(value);
+ } catch {
+ throw new BotCapabilityUnavailableError(`${label} is unavailable.`);
+ }
+ };
+
+ return {
+ load: read,
+
+ async store(value, expected): Promise {
+ const safeValue = validateValue(value);
+ const accountValue = await account();
+ if ((await read()) !== expected) {
+ throw new BotCapabilityUnavailableError(
+ `${label} changed outside the active transaction.`,
+ );
+ }
+ try {
+ await transport.write(accountValue, safeValue);
+ } catch {
+ throw new BotCapabilityUnavailableError(`${label} could not be updated.`);
+ }
+ if ((await read()) !== safeValue) {
+ throw new BotCapabilityUnavailableError(
+ `${label} could not be verified.`,
+ );
+ }
+ },
+ };
+}
+
+function markerValue(state: BotCapabilityBootstrapMarkerState): string {
+ if (
+ (state.phase !== "pending" && state.phase !== "consumed") ||
+ !/^[a-f0-9]{64}$/u.test(state.keyProof)
+ ) {
+ throw new BotCapabilityUnavailableError(
+ "Bot bootstrap marker key proof is invalid.",
+ );
+ }
+ return `${state.phase}:${state.keyProof}`;
+}
+
+function parseMarker(value: string): BotCapabilityBootstrapMarkerState {
+ const match = MARKER_PATTERN.exec(value);
+ if (!match) {
+ throw new BotCapabilityUnavailableError("Bot bootstrap marker is invalid.");
+ }
+ return {
+ phase: match[1] as BotCapabilityBootstrapMarkerState["phase"],
+ keyProof: match[2]!,
+ };
+}
+
+export function createAuthorityBootstrapMarker(
+ item: BotCapabilityRollbackAnchor,
+): BotCapabilityBootstrapMarker {
+ return {
+ async load() {
+ const value = await item.load();
+ return value === null ? null : parseMarker(value);
+ },
+ async store(next, expected) {
+ if (
+ (next.phase === "pending" && expected !== null) ||
+ (next.phase === "consumed" &&
+ (expected?.phase !== "pending" ||
+ expected.keyProof !== next.keyProof))
+ ) {
+ throw new BotCapabilityUnavailableError(
+ "Bot bootstrap marker transition is invalid.",
+ );
+ }
+ await item.store(
+ markerValue(next),
+ expected === null ? null : markerValue(expected),
+ );
+ },
+ };
+}
diff --git a/main/services/bot-capability-authority.ts b/main/services/bot-capability-authority.ts
new file mode 100644
index 00000000..7ac06c56
--- /dev/null
+++ b/main/services/bot-capability-authority.ts
@@ -0,0 +1,21 @@
+import * as keychain from "./bot-capability-keychain-anchor.js";
+import * as secretService from "./bot-capability-secret-service-anchor.js";
+import { BotCapabilityUnavailableError } from "./bot-capability-store-core.js";
+import type { BotCapabilityAuthorityItemOptions } from "./bot-capability-authority-item.js";
+export { botCapabilityAuthorityAccountForCanonicalRoot } from "./bot-capability-authority-item.js";
+export function createBotAuthorities(options: BotCapabilityAuthorityItemOptions, platform: NodeJS.Platform = process.platform) {
+ if (platform === "darwin") return {
+ anchor: keychain.createBotCapabilityKeychainAnchor(options),
+ bootstrapMarker: keychain.createBotCapabilityKeychainBootstrapMarker(options),
+ telegramAnchor: keychain.createTelegramBotBindingKeychainAnchor(options),
+ telegramBootstrapMarker: keychain.createTelegramBotBindingKeychainBootstrapMarker(options),
+ };
+ if (platform === "linux") return {
+ anchor: secretService.createBotCapabilitySecretServiceAnchor(options),
+ bootstrapMarker: secretService.createBotCapabilitySecretServiceBootstrapMarker(options),
+ telegramAnchor: secretService.createTelegramBotBindingSecretServiceAnchor(options),
+ telegramBootstrapMarker: secretService.createTelegramBotBindingSecretServiceBootstrapMarker(options),
+ };
+ const unavailable = async (): Promise => { throw new BotCapabilityUnavailableError("Bot rollback authority is unsupported on this platform."); };
+ return { anchor: { load: unavailable, store: unavailable }, bootstrapMarker: { load: unavailable, store: unavailable }, telegramAnchor: { load: unavailable, store: unavailable }, telegramBootstrapMarker: { load: unavailable, store: unavailable } };
+}
diff --git a/main/services/bot-capability-inventory-ports.test.ts b/main/services/bot-capability-inventory-ports.test.ts
index df59ffe4..653d335f 100644
--- a/main/services/bot-capability-inventory-ports.test.ts
+++ b/main/services/bot-capability-inventory-ports.test.ts
@@ -61,9 +61,12 @@ test("inventory ports project safe exact facts and conservative unavailable conn
credentialIncarnation: "b".repeat(43),
})),
},
- getSettings: async () => ({ exaEnabled: true, computerUseEnabled: false }),
+ // Simulate a stale preference copied from a supported host. Host policy
+ // must still keep Computer Use out of the effective Bot inventory.
+ getSettings: async () => ({ exaEnabled: true, computerUseEnabled: true }),
webSearchAvailability: async () => ({ ready: true }),
subagentsAvailable: () => true,
+ computerUseSupported: () => false,
shellFingerprint: HASH,
fullMacScopeFingerprint: HASH,
botHomeScopeFingerprint: HASH,
@@ -87,6 +90,7 @@ test("inventory ports project safe exact facts and conservative unavailable conn
assert.equal(skills[0]?.available, true);
assert.equal(other.find(({ kind }) => kind === "web")?.available, true);
assert.equal(other.find(({ kind }) => kind === "browser")?.available, false);
+ assert.equal(other.find(({ kind }) => kind === "computer_use")?.available, false);
assert.equal(other.find(({ kind }) => kind === "schedules")?.available, false);
assert.match(
other.find(({ kind }) => kind === "schedules")?.description ?? "",
diff --git a/main/services/bot-capability-inventory-ports.ts b/main/services/bot-capability-inventory-ports.ts
index fc10af5d..f097cafd 100644
--- a/main/services/bot-capability-inventory-ports.ts
+++ b/main/services/bot-capability-inventory-ports.ts
@@ -34,6 +34,8 @@ export interface BotCapabilityInventoryPortDependencies {
/** Main-owned Web Search readiness; credentials and route details stay private. */
webSearchAvailability(): Promise>;
subagentsAvailable(): boolean;
+ /** Host policy always narrows a persisted Computer Use preference. */
+ computerUseSupported?: () => boolean;
shellFingerprint?: string;
fullMacScopeFingerprint?: string;
botHomeScopeFingerprint?: string;
@@ -244,6 +246,7 @@ function ordinaryInventory(input: {
settings: AppSettings;
webSearchReady: boolean;
subagentsAvailable: boolean;
+ computerUseSupported: boolean;
}): BotOrdinaryCapabilityInventory[] {
const values: Array<{
kind: BotOrdinaryCapabilityInventory["kind"];
@@ -267,7 +270,9 @@ function ordinaryInventory(input: {
kind: "computer_use",
label: "Computer Use",
description: "Use the Mac visually through Aiden's existing attended controls.",
- available: input.settings.computerUseEnabled === true,
+ available:
+ input.computerUseSupported &&
+ input.settings.computerUseEnabled === true,
},
{
kind: "schedules",
@@ -429,6 +434,8 @@ export function createBotCapabilityInventoryPorts(
settings,
webSearchReady: webSearchAvailability.ready === true,
subagentsAvailable: dependencies.subagentsAvailable(),
+ computerUseSupported:
+ dependencies.computerUseSupported?.() === true,
});
},
};
diff --git a/main/services/bot-capability-keychain-anchor.ts b/main/services/bot-capability-keychain-anchor.ts
index a747f5b4..dc82f6b7 100644
--- a/main/services/bot-capability-keychain-anchor.ts
+++ b/main/services/bot-capability-keychain-anchor.ts
@@ -1,26 +1,12 @@
import { spawn } from "node:child_process";
-import { createHash } from "node:crypto";
-import * as path from "node:path";
-import type {
- BotCapabilityBootstrapMarker,
- BotCapabilityBootstrapMarkerState,
- BotCapabilityRollbackAnchor,
-} from "./bot-capability-state-checkpoint.js";
+import type { BotCapabilityRollbackAnchor, BotCapabilityBootstrapMarker } from "./bot-capability-state-checkpoint.js";
import { BotCapabilityUnavailableError } from "./bot-capability-store-core.js";
-
+import { ROLLBACK_SERVICE, BOOTSTRAP_SERVICE, TELEGRAM_BINDING_SERVICE, TELEGRAM_BINDING_BOOTSTRAP_SERVICE, validateValue, createAuthorityItem, createAuthorityBootstrapMarker, type BotCapabilityAuthorityItemOptions } from "./bot-capability-authority-item.js";
+export { botCapabilityAuthorityAccountForCanonicalRoot as botCapabilityKeychainAccountForCanonicalRoot } from "./bot-capability-authority-item.js";
const SECURITY = "/usr/bin/security";
-const ROLLBACK_SERVICE = "com.aiden.bot-capability.rollback-authority.v1";
-const BOOTSTRAP_SERVICE = "com.aiden.bot-capability.bootstrap-consumed.v1";
-const TELEGRAM_BINDING_SERVICE = "com.aiden.telegram-bot-binding.rollback-authority.v1";
-const TELEGRAM_BINDING_BOOTSTRAP_SERVICE =
- "com.aiden.telegram-bot-binding.bootstrap-consumed.v1";
-const MAX_VALUE_BYTES = 1_024;
const MAX_PROCESS_OUTPUT_BYTES = 4_096;
const PROCESS_TIMEOUT_MS = 5_000;
-const ACCOUNT_PREFIX = "user-data:";
-const MARKER_PATTERN = /^(pending|consumed):([a-f0-9]{64})$/u;
const SECURITY_INTERACTIVE_TOKEN = /^[A-Za-z0-9._:-]+$/u;
-
export interface BotCapabilitySecurityCommandResult {
exitCode: number | null;
stdout: string;
@@ -54,45 +40,6 @@ export function botCapabilitySecurityInteractiveWrite(
return `${args.slice(0, -1).join(" ")} -X ${hexValue}\n`;
}
-export function botCapabilityKeychainAccountForCanonicalRoot(
- root: string,
-): string {
- if (!path.isAbsolute(root) || path.resolve(root) === path.parse(root).root) {
- throw new BotCapabilityUnavailableError(
- "Bot rollback authority requires a canonical private user-data root.",
- );
- }
- return `${ACCOUNT_PREFIX}${createHash("sha256").update(path.resolve(root)).digest("hex")}`;
-}
-
-function validateAccount(value: string): string {
- if (
- !value.startsWith(ACCOUNT_PREFIX) ||
- value.length !== ACCOUNT_PREFIX.length + 64 ||
- !/^[a-f0-9]+$/u.test(value.slice(ACCOUNT_PREFIX.length))
- ) {
- throw new BotCapabilityUnavailableError(
- "Bot rollback authority account is invalid.",
- );
- }
- return value;
-}
-
-function validateValue(value: string): string {
- if (
- value.length === 0 ||
- Buffer.byteLength(value, "utf8") > MAX_VALUE_BYTES ||
- value.includes("\0") ||
- value.includes("\n") ||
- value.includes("\r")
- ) {
- throw new BotCapabilityUnavailableError(
- "Bot rollback authority value is invalid.",
- );
- }
- return value;
-}
-
const runSecurity: BotCapabilitySecurityCommand = (args, stdin) =>
new Promise((resolve, reject) => {
const interactiveWrite = stdin === undefined
@@ -126,6 +73,7 @@ const runSecurity: BotCapabilitySecurityCommand = (args, stdin) =>
child.stdout.on("data", (chunk: Buffer) => capture(stdout, chunk));
child.stderr.on("data", (chunk: Buffer) => capture(stderr, chunk));
child.once("error", finishError);
+ child.stdin.once("error", finishError);
child.once("close", (exitCode) => {
if (settled) return;
settled = true;
@@ -145,103 +93,24 @@ const runSecurity: BotCapabilitySecurityCommand = (args, stdin) =>
child.stdin.end(interactiveWrite);
});
-interface BotCapabilityKeychainItemOptions {
- account: string | (() => string | Promise);
+interface BotCapabilityKeychainItemOptions extends BotCapabilityAuthorityItemOptions {
command?: BotCapabilitySecurityCommand;
}
-
-function createKeychainItem(
- options: BotCapabilityKeychainItemOptions,
- service: string,
- label: string,
-): BotCapabilityRollbackAnchor {
+function createKeychainItem(options: BotCapabilityKeychainItemOptions, service: string, label: string): BotCapabilityRollbackAnchor {
const command = options.command ?? runSecurity;
- let accountPromise: Promise | undefined;
- const account = (): Promise => {
- accountPromise ??= Promise.resolve(
- typeof options.account === "function"
- ? options.account()
- : options.account,
- )
- .then(validateAccount)
- .catch((error) => {
- accountPromise = undefined;
- throw error;
- });
- return accountPromise;
- };
-
- const read = async (): Promise => {
- const accountValue = await account();
- let result: BotCapabilitySecurityCommandResult;
- try {
- result = await command([
- "find-generic-password",
- "-a",
- accountValue,
- "-s",
- service,
- "-w",
- ]);
- } catch {
- throw new BotCapabilityUnavailableError(
- `The macOS Keychain ${label} is unavailable.`,
- );
- }
- if (result.exitCode === 44 || /could not be found/iu.test(result.stderr))
- return null;
- if (result.exitCode !== 0) {
- throw new BotCapabilityUnavailableError(
- `The macOS Keychain ${label} is unavailable.`,
- );
- }
- return validateValue(result.stdout.replace(/\r?\n$/u, ""));
- };
-
- return {
- load: read,
-
- async store(value, expected): Promise {
- const safeValue = validateValue(value);
- const accountValue = await account();
- if ((await read()) !== expected) {
- throw new BotCapabilityUnavailableError(
- `${label} changed outside the active transaction.`,
- );
- }
- let result: BotCapabilitySecurityCommandResult;
- try {
- result = await command(
- [
- "add-generic-password",
- "-U",
- "-a",
- accountValue,
- "-s",
- service,
- "-w",
- ],
- safeValue,
- );
- } catch {
- throw new BotCapabilityUnavailableError(
- `The macOS Keychain ${label} could not be updated.`,
- );
- }
- if (result.exitCode !== 0) {
- throw new BotCapabilityUnavailableError(
- `The macOS Keychain ${label} could not be updated.`,
- );
- }
- if ((await read()) !== safeValue) {
- throw new BotCapabilityUnavailableError(
- `The macOS Keychain ${label} could not be verified.`,
- );
- }
+ return createAuthorityItem(options, {
+ async read(account) {
+ const result = await command(["find-generic-password", "-a", account, "-s", service, "-w"]);
+ if (result.exitCode === 44 || /could not be found/iu.test(result.stderr)) return null;
+ if (result.exitCode !== 0) throw new BotCapabilityUnavailableError(`The macOS Keychain ${label} is unavailable.`);
+ return result.stdout.replace(/\r?\n$/u, "");
},
- };
+ async write(account, value) {
+ const result = await command(["add-generic-password", "-U", "-a", account, "-s", service, "-w"], value);
+ if (result.exitCode !== 0) throw new BotCapabilityUnavailableError(`The macOS Keychain ${label} could not be updated.`);
+ },
+ }, label);
}
-
export function createBotCapabilityKeychainAnchor(
options: BotCapabilityKeychainItemOptions,
): BotCapabilityRollbackAnchor {
@@ -274,57 +143,6 @@ export function createTelegramBotBindingKeychainBootstrapMarker(
);
}
-function markerValue(state: BotCapabilityBootstrapMarkerState): string {
- if (
- (state.phase !== "pending" && state.phase !== "consumed") ||
- !/^[a-f0-9]{64}$/u.test(state.keyProof)
- ) {
- throw new BotCapabilityUnavailableError(
- "Bot bootstrap marker key proof is invalid.",
- );
- }
- return `${state.phase}:${state.keyProof}`;
-}
-
-function parseMarker(value: string): BotCapabilityBootstrapMarkerState {
- const match = MARKER_PATTERN.exec(value);
- if (!match) {
- throw new BotCapabilityUnavailableError("Bot bootstrap marker is invalid.");
- }
- return {
- phase: match[1] as BotCapabilityBootstrapMarkerState["phase"],
- keyProof: match[2]!,
- };
-}
-
-export function createBotCapabilityKeychainBootstrapMarker(
- options: BotCapabilityKeychainItemOptions,
-): BotCapabilityBootstrapMarker {
- const item = createKeychainItem(
- options,
- BOOTSTRAP_SERVICE,
- "Bot bootstrap marker",
- );
- return {
- async load() {
- const value = await item.load();
- return value === null ? null : parseMarker(value);
- },
- async store(next, expected) {
- if (
- (next.phase === "pending" && expected !== null) ||
- (next.phase === "consumed" &&
- (expected?.phase !== "pending" ||
- expected.keyProof !== next.keyProof))
- ) {
- throw new BotCapabilityUnavailableError(
- "Bot bootstrap marker transition is invalid.",
- );
- }
- await item.store(
- markerValue(next),
- expected === null ? null : markerValue(expected),
- );
- },
- };
+export function createBotCapabilityKeychainBootstrapMarker(options: BotCapabilityKeychainItemOptions): BotCapabilityBootstrapMarker {
+ return createAuthorityBootstrapMarker(createKeychainItem(options, BOOTSTRAP_SERVICE, "Bot bootstrap marker"));
}
diff --git a/main/services/bot-capability-secret-service-anchor.test.ts b/main/services/bot-capability-secret-service-anchor.test.ts
new file mode 100644
index 00000000..2240bebe
--- /dev/null
+++ b/main/services/bot-capability-secret-service-anchor.test.ts
@@ -0,0 +1,115 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import * as fs from "node:fs/promises";
+import * as os from "node:os";
+import * as path from "node:path";
+import { botCapabilityAuthorityAccountForCanonicalRoot, ROLLBACK_SERVICE } from "./bot-capability-authority-item.js";
+import { createBotAuthorities } from "./bot-capability-authority.js";
+import { createBotCapabilitySecretServiceAnchor, createBotCapabilitySecretServiceBootstrapMarker, createTelegramBotBindingSecretServiceAnchor, createTelegramBotBindingSecretServiceBootstrapMarker, createSecretServiceAuthorityCommand, resolveSecretServiceAuthorityHelper, secretServiceAuthorityEnvironment, type SecretServiceAuthorityCommand } from "./bot-capability-secret-service-anchor.js";
+const account = botCapabilityAuthorityAccountForCanonicalRoot("/tmp/aiden-test");
+const result = (exitCode: number, stdout = "") => ({ exitCode, stdout, stderr: "" });
+test("Linux authority preserves independent namespaces, stdin-only values and marker transitions", async () => {
+ const values = new Map();
+ const command: SecretServiceAuthorityCommand = async (args, stdin) => {
+ assert.equal(args.length, 3);
+ assert.equal(args[2], account);
+ if (args[0] === "store") { assert.ok(stdin); assert.ok(!args.includes(stdin)); values.set(args[1]!, stdin); return result(0); }
+ return values.has(args[1]!) ? result(0, values.get(args[1]!)) : result(4);
+ };
+ const options = { account, command };
+ const anchor = createBotCapabilitySecretServiceAnchor(options);
+ assert.equal(await anchor.load(), null);
+ await anchor.store("generation-one", null);
+ await assert.rejects(anchor.store("generation-two", null), /changed/);
+ await anchor.store("generation-two", "generation-one");
+ const marker = createBotCapabilitySecretServiceBootstrapMarker(options);
+ const pending = { phase: "pending" as const, keyProof: "a".repeat(64) };
+ await marker.store(pending, null);
+ await assert.rejects(marker.store(pending, pending), /transition/);
+ await marker.store({ ...pending, phase: "consumed" }, pending);
+ await createTelegramBotBindingSecretServiceAnchor(options).store("telegram-one", null);
+ await createTelegramBotBindingSecretServiceBootstrapMarker(options).store("telegram-bootstrap", null);
+ assert.equal(values.size, 4);
+});
+test("Linux authority rejects locked, duplicate, unavailable and invalid responses without diagnostics", async () => {
+ for (const code of [2, 3, 5, 6, 99]) {
+ const anchor = createBotCapabilitySecretServiceAnchor({ account, command: async () => ({ ...result(code), stderr: "private-secret" }) });
+ await assert.rejects(anchor.load(), (error: Error) => !error.message.includes("private-secret") && /unavailable/.test(error.message));
+ }
+ for (const value of ["", "x\n", "x\r", "x\0", "x".repeat(1025)]) {
+ await assert.rejects(createBotCapabilitySecretServiceAnchor({ account, command: async () => result(0, value) }).load());
+ }
+ const anchor = createBotCapabilitySecretServiceAnchor({ account, command: async (args) => args[0] === "store" ? result(0) : result(4) });
+ await assert.rejects(anchor.store("not-persisted", null), /verified/);
+ await assert.rejects(anchor.store("bad\ninput", null), /invalid/);
+});
+test("native helper runner bounds output/time and handles missing executables without revealing stdin", async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-authority-"));
+ try {
+ const helper = path.join(dir, "helper");
+ await fs.writeFile(helper, `#!${process.execPath}\nlet value = ''; process.stdin.on('data', chunk => value += chunk); process.stdin.on('end', () => { if (process.argv.length !== 5 || process.argv.includes(value)) process.exit(6); process.stdout.write(value); });\n`, { mode: 0o700 });
+ const echoed = await createSecretServiceAuthorityCommand(helper)(["store", ROLLBACK_SERVICE, account], "private-secret");
+ assert.equal(echoed.exitCode, 0);
+ assert.equal(echoed.stdout, "private-secret");
+ await fs.writeFile(helper, `#!${process.execPath}\nprocess.stdin.resume(); process.stdin.on('end', () => process.stdout.write('x'.repeat(5000)));\n`, { mode: 0o700 });
+ await assert.rejects(createSecretServiceAuthorityCommand(helper)(["store", ROLLBACK_SERVICE, account], "private-secret"), /unavailable/);
+ await fs.writeFile(helper, `#!${process.execPath}\nsetTimeout(() => {}, 10000);\n`, { mode: 0o700 });
+ await assert.rejects(createSecretServiceAuthorityCommand(helper, 30)(["lookup", ROLLBACK_SERVICE, account]), /unavailable/);
+ await assert.rejects(createSecretServiceAuthorityCommand(path.join(dir, "absent"))(["store", ROLLBACK_SERVICE, account], "private-secret"), /unavailable/);
+ const command = createSecretServiceAuthorityCommand(helper);
+ await assert.rejects(command(["store", "unknown", account], "secret"), /invalid/);
+ await assert.rejects(command(["store", ROLLBACK_SERVICE, "invalid"], "secret"), /invalid/);
+ await assert.rejects(command(["store", ROLLBACK_SERVICE, account]), /invalid/);
+ await assert.rejects(command(["lookup", ROLLBACK_SERVICE, account], "secret"), /invalid/);
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
+});
+test("helper resolution and unsupported platform preserve lazy fail-closed startup", async () => {
+ assert.equal(resolveSecretServiceAuthorityHelper({ cwd: "/workspace" }), "/workspace/build/native/aiden-secret-service-authority");
+ assert.equal(resolveSecretServiceAuthorityHelper({ cwd: "/workspace", resourcesPath: "/opt/aiden/resources" }), "/opt/aiden/Helpers/aiden-secret-service-authority");
+ assert.equal(resolveSecretServiceAuthorityHelper({ cwd: "/workspace", resourcesPath: "/opt/electron/resources", defaultApp: true }), "/workspace/build/native/aiden-secret-service-authority");
+ await assert.rejects(createBotAuthorities({ account }, "win32").anchor.load(), /unsupported/);
+});
+
+test("native authority helper inherits only validated desktop bus context", async () => {
+ const clean = secretServiceAuthorityEnvironment({
+ DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1000/bus",
+ XDG_RUNTIME_DIR: "/run/user/1000",
+ LD_PRELOAD: "private-loader", NODE_OPTIONS: "private-node-option", OPENAI_API_KEY: "private-key",
+ PATH: "/private/bin", LANG: "private-locale", HOME: "/private/home",
+ });
+ assert.deepEqual(clean, { PATH: "/usr/bin:/bin", LANG: "C.UTF-8", LC_ALL: "C.UTF-8", DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1000/bus", XDG_RUNTIME_DIR: "/run/user/1000" });
+ for (const bus of ["tcp:host=remote", "unix:path=/tmp/bus;tcp:host=remote", "unix:path=/tmp/invalid%xx", "unix:path=/tmp/bus\n"]) {
+ assert.equal(secretServiceAuthorityEnvironment({ DBUS_SESSION_BUS_ADDRESS: bus }).DBUS_SESSION_BUS_ADDRESS, undefined);
+ }
+ for (const dir of ["relative", "/run/../tmp", "/tmp/bad\n"]) {
+ assert.equal(secretServiceAuthorityEnvironment({ XDG_RUNTIME_DIR: dir }).XDG_RUNTIME_DIR, undefined);
+ }
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-authority-env-"));
+ const keys = ["LD_PRELOAD", "NODE_OPTIONS", "OPENAI_API_KEY", "DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"];
+ const previous = new Map(keys.map(key => [key, process.env[key]]));
+ try {
+ const helper = path.join(dir, "helper");
+ await fs.writeFile(helper, `#!${process.execPath}\nprocess.stdin.resume(); process.stdin.on('end', () => process.stdout.write(JSON.stringify(process.env)));\n`, { mode: 0o700 });
+ process.env.LD_PRELOAD = "private-loader";
+ process.env.NODE_OPTIONS = "--require=private-injection";
+ process.env.OPENAI_API_KEY = "private-key";
+ process.env.DBUS_SESSION_BUS_ADDRESS = "unix:path=/run/user/1000/bus";
+ process.env.XDG_RUNTIME_DIR = "/run/user/1000";
+ const response = await createSecretServiceAuthorityCommand(helper)(["lookup", ROLLBACK_SERVICE, account]);
+ assert.equal(response.exitCode, 0);
+ const inherited = JSON.parse(response.stdout);
+ // CoreFoundation initializes this value itself on macOS test hosts.
+ if (process.platform === "darwin") delete inherited.__CF_USER_TEXT_ENCODING;
+ // Node injects its active coverage directory into child processes even
+ // when spawn receives an explicit environment; this is test instrumentation.
+ if (process.env.NODE_V8_COVERAGE) {
+ assert.equal(inherited.NODE_V8_COVERAGE, process.env.NODE_V8_COVERAGE);
+ delete inherited.NODE_V8_COVERAGE;
+ }
+ assert.deepEqual(inherited, clean);
+ assert.equal(response.stderr, "");
+ } finally {
+ for (const [key, value] of previous) { if (value === undefined) delete process.env[key]; else process.env[key] = value; }
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+});
diff --git a/main/services/bot-capability-secret-service-anchor.ts b/main/services/bot-capability-secret-service-anchor.ts
new file mode 100644
index 00000000..6b9cdb1c
--- /dev/null
+++ b/main/services/bot-capability-secret-service-anchor.ts
@@ -0,0 +1,79 @@
+import { linuxDesktopBusEnvironment as secretServiceAuthorityEnvironment } from "./linux-desktop-bus-environment.js";
+import { spawn } from "node:child_process";
+import * as path from "node:path";
+import { BotCapabilityUnavailableError } from "./bot-capability-store-core.js";
+import { createAuthorityItem, createAuthorityBootstrapMarker, ROLLBACK_SERVICE, BOOTSTRAP_SERVICE, TELEGRAM_BINDING_SERVICE, TELEGRAM_BINDING_BOOTSTRAP_SERVICE, validateAccount, validateValue, type BotCapabilityAuthorityItemOptions } from "./bot-capability-authority-item.js";
+
+const SERVICES = new Set([ROLLBACK_SERVICE, BOOTSTRAP_SERVICE, TELEGRAM_BINDING_SERVICE, TELEGRAM_BINDING_BOOTSTRAP_SERVICE]);
+export interface AuthorityHelperRuntime { cwd: string; resourcesPath?: string; defaultApp?: boolean; }
+export function resolveSecretServiceAuthorityHelper(runtime: AuthorityHelperRuntime = {
+ cwd: process.cwd(), resourcesPath: process.resourcesPath, defaultApp: process.defaultApp,
+}): string {
+ return runtime.resourcesPath && !runtime.defaultApp
+ ? path.resolve(runtime.resourcesPath, "..", "Helpers", "aiden-secret-service-authority")
+ : path.resolve(runtime.cwd, "build", "native", "aiden-secret-service-authority");
+}
+export type SecretServiceAuthorityCommand = (args: readonly string[], stdin?: string) => Promise<{ exitCode: number | null; stdout: string; stderr: string }>;
+
+export { linuxDesktopBusEnvironment as secretServiceAuthorityEnvironment } from "./linux-desktop-bus-environment.js";
+
+/** Secrets never enter argv or diagnostics. No file-backed fallback is permitted. */
+export function createSecretServiceAuthorityCommand(helper = resolveSecretServiceAuthorityHelper(), timeoutMs = 5_000): SecretServiceAuthorityCommand {
+ return async (args, stdin) => {
+ if (args.length !== 3 || !["lookup", "store"].includes(args[0]!) || !SERVICES.has(args[1]!) ||
+ (args[0] === "store") !== (stdin !== undefined)) throw new BotCapabilityUnavailableError("Secret Service command is invalid.");
+ validateAccount(args[2]!);
+ if (stdin !== undefined) validateValue(stdin);
+ return new Promise((resolve, reject) => {
+ const child = spawn(helper, [...args], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true, env: secretServiceAuthorityEnvironment() });
+ const stdout: Buffer[] = [];
+ const stderr: Buffer[] = [];
+ let bytes = 0;
+ let settled = false;
+ const fail = (): void => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ child.kill("SIGKILL");
+ reject(new BotCapabilityUnavailableError("Secret Service helper is unavailable."));
+ };
+ const timer = setTimeout(fail, timeoutMs);
+ const capture = (target: Buffer[], chunk: Buffer): void => {
+ bytes += chunk.byteLength;
+ if (bytes > 4_096) { fail(); return; }
+ target.push(Buffer.from(chunk));
+ };
+ child.stdout.on("data", (chunk: Buffer) => capture(stdout, chunk));
+ child.stderr.on("data", (chunk: Buffer) => capture(stderr, chunk));
+ child.once("error", fail);
+ child.stdin.once("error", fail);
+ child.once("close", (exitCode) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ resolve({ exitCode, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") });
+ });
+ child.stdin.end(stdin);
+ });
+ };
+}
+interface Options extends BotCapabilityAuthorityItemOptions { command?: SecretServiceAuthorityCommand; }
+function item(options: Options, service: string, label: string) {
+ const command = options.command ?? createSecretServiceAuthorityCommand();
+ return createAuthorityItem(options, {
+ async read(account) {
+ const result = await command(["lookup", service, account]);
+ if (result.exitCode === 4 && result.stdout === "") return null;
+ if (result.exitCode !== 0) throw new BotCapabilityUnavailableError("Secret Service authority is unavailable.");
+ return result.stdout;
+ },
+ async write(account, value) {
+ const result = await command(["store", service, account], value);
+ if (result.exitCode !== 0 || result.stdout !== "") throw new BotCapabilityUnavailableError("Secret Service authority could not be updated.");
+ },
+ }, label);
+}
+export const createBotCapabilitySecretServiceAnchor = (options: Options) => item(options, ROLLBACK_SERVICE, "Bot rollback authority");
+export const createBotCapabilitySecretServiceBootstrapMarker = (options: Options) => createAuthorityBootstrapMarker(item(options, BOOTSTRAP_SERVICE, "Bot bootstrap marker"));
+export const createTelegramBotBindingSecretServiceAnchor = (options: Options) => item(options, TELEGRAM_BINDING_SERVICE, "Telegram Bot binding rollback authority");
+export const createTelegramBotBindingSecretServiceBootstrapMarker = (options: Options) => item(options, TELEGRAM_BINDING_BOOTSTRAP_SERVICE, "Telegram Bot binding bootstrap marker");
diff --git a/main/services/bot-capability-services-main.ts b/main/services/bot-capability-services-main.ts
index 3dcb867d..d438a60b 100644
--- a/main/services/bot-capability-services-main.ts
+++ b/main/services/bot-capability-services-main.ts
@@ -10,10 +10,9 @@ import {
} from "./bot-capability-credential-signatures.js";
import { createBotCapabilityOpaqueKeyStore } from "./bot-capability-key-store.js";
import {
- botCapabilityKeychainAccountForCanonicalRoot,
- createBotCapabilityKeychainAnchor,
- createBotCapabilityKeychainBootstrapMarker,
-} from "./bot-capability-keychain-anchor.js";
+ botCapabilityAuthorityAccountForCanonicalRoot,
+ createBotAuthorities,
+} from "./bot-capability-authority.js";
import { createBotCapabilityMigrationSeal } from "./bot-capability-migration-seal.js";
import { createBotCapabilityStore } from "./bot-capability-store.js";
import { createBotCapabilityStateCheckpoint } from "./bot-capability-state-checkpoint.js";
@@ -39,6 +38,7 @@ import {
import { BotSkillContentWatcher } from "./bot-skill-content-watcher.js";
import { skillRegistry } from "./skill-registry-main.js";
import { webSearchService } from "./web-search-main.js";
+import { hostPlatformCapabilities } from "./host-platform-capabilities.js";
export const BOT_SERVICE_DIRECTORY = "bot-service";
@@ -86,26 +86,23 @@ export async function resolveBotRuntimeSkills(botId: string) {
return skills;
}
-let capabilityKeychainAccountPromise: Promise | undefined;
-const capabilityKeychainAccount = (): Promise => {
- capabilityKeychainAccountPromise ??= fs
+let capabilityAuthorityAccountPromise: Promise | undefined;
+const capabilityAuthorityAccount = (): Promise => {
+ capabilityAuthorityAccountPromise ??= fs
.realpath(app.getPath("userData"))
- .then(botCapabilityKeychainAccountForCanonicalRoot)
+ .then(botCapabilityAuthorityAccountForCanonicalRoot)
.catch((error) => {
- capabilityKeychainAccountPromise = undefined;
+ capabilityAuthorityAccountPromise = undefined;
throw error;
});
- return capabilityKeychainAccountPromise;
+ return capabilityAuthorityAccountPromise;
};
+const capabilityAuthorities = createBotAuthorities({ account: capabilityAuthorityAccount });
const capabilityStateCheckpoint = createBotCapabilityStateCheckpoint({
root: botServiceRoot,
keyStore: opaqueKeyStore,
- anchor: createBotCapabilityKeychainAnchor({
- account: capabilityKeychainAccount,
- }),
- bootstrapMarker: createBotCapabilityKeychainBootstrapMarker({
- account: capabilityKeychainAccount,
- }),
+ anchor: capabilityAuthorities.anchor,
+ bootstrapMarker: capabilityAuthorities.bootstrapMarker,
inspectInitialBootstrap: async () => {
const [bots, chats] = await Promise.all([botStore.list(true), chatStore.list()]);
const botIds = new Set(bots.map(({ id }) => id));
@@ -206,6 +203,7 @@ export const botCapabilityCatalog = createBotCapabilityCatalogMainService(
return { ready: availability.ready };
},
subagentsAvailable: () => subagentsEnabled(),
+ computerUseSupported: () => hostPlatformCapabilities().computerUse,
}),
{
onRuntimeSnapshot: (botId, snapshot) => {
diff --git a/main/services/bot-skill-content-watcher.test.ts b/main/services/bot-skill-content-watcher.test.ts
index 6262b698..105f6ebf 100644
--- a/main/services/bot-skill-content-watcher.test.ts
+++ b/main/services/bot-skill-content-watcher.test.ts
@@ -3,12 +3,19 @@ import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import test from "node:test";
-import {
- botRuntimeInventoryLeases,
-} from "./bot-runtime-inventory-lease.js";
+import { botRuntimeInventoryLeases } from "./bot-runtime-inventory-lease.js";
import { BotSkillContentWatcher } from "./bot-skill-content-watcher.js";
import { SkillRegistry } from "./skill-registry.js";
+const WATCHER_EVENT_TIMEOUT_MS = 5_000;
+
+const waitForWatcherBaseline = async (): Promise => {
+ // Darwin may deliver the directory's already-queued creation notification
+ // immediately after watch registration. Drain it before asserting which
+ // subsequent filesystem operation caused the watcher notification.
+ await new Promise((resolve) => setTimeout(resolve, 75));
+};
+
test("editing an admitted discovered skill aborts the live Bot inventory lease", async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-bot-skill-watch-"));
t.after(() => fs.rm(root, { recursive: true, force: true }));
@@ -20,16 +27,21 @@ test("editing an admitted discovered skill aborts the live Bot inventory lease",
const watcher = new BotSkillContentWatcher();
t.after(() => watcher.dispose());
await watcher.watchSkillFiles([skillFile]);
+ await waitForWatcherBaseline();
const lease = botRuntimeInventoryLeases.acquire();
const aborted = new Promise((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Skill watcher did not invalidate live Bot authority.")),
- 5_000,
+ WATCHER_EVENT_TIMEOUT_MS,
+ );
+ lease.signal.addEventListener(
+ "abort",
+ () => {
+ clearTimeout(timeout);
+ resolve();
+ },
+ { once: true },
);
- lease.signal.addEventListener("abort", () => {
- clearTimeout(timeout);
- resolve();
- }, { once: true });
});
await fs.writeFile(skillFile, "---\nname: Skill\n---\nAfter\n", "utf8");
await aborted;
@@ -44,14 +56,12 @@ test("watcher ignores unrelated files beside a skill", async (t) => {
const skillFile = path.join(root, "SKILL.md");
await fs.writeFile(skillFile, "Skill", "utf8");
let changes = 0;
- const watcher = new BotSkillContentWatcher(() => { changes += 1; });
+ const watcher = new BotSkillContentWatcher(() => {
+ changes += 1;
+ });
t.after(() => watcher.dispose());
await watcher.watchSkillFiles([skillFile]);
- // Darwin may deliver the directory's already-queued creation notification
- // immediately after watch registration. That event predates the behavior
- // under test, so establish a quiet baseline before creating the unrelated
- // sibling.
- await new Promise((resolve) => setTimeout(resolve, 75));
+ await waitForWatcherBaseline();
changes = 0;
await fs.writeFile(path.join(root, "notes.txt"), "Unrelated", "utf8");
@@ -76,14 +86,16 @@ test("a watched edit invalidates a warm runtime skill snapshot immediately", asy
const registry = new SkillRegistry({
getWorkspace: async () => workspace,
listConfigured: async () => [],
- discover: async () => [{
- id: `workspace:${skillFile}`,
- name: "Watched",
- description: "Watched skill",
- instructions: await fs.readFile(skillFile, "utf8"),
- source: "workspace" as const,
- path: skillFile,
- }],
+ discover: async () => [
+ {
+ id: `workspace:${skillFile}`,
+ name: "Watched",
+ description: "Watched skill",
+ instructions: await fs.readFile(skillFile, "utf8"),
+ source: "workspace" as const,
+ path: skillFile,
+ },
+ ],
invocationKey: new Uint8Array(32).fill(9),
cacheTtlMs: 5_000,
});
@@ -94,7 +106,7 @@ test("a watched edit invalidates a warm runtime skill snapshot immediately", asy
const changed = new Promise((resolve, reject) => {
changeTimeout = setTimeout(
() => reject(new Error("Skill watcher did not invalidate the warm Bot snapshot.")),
- 1_000,
+ WATCHER_EVENT_TIMEOUT_MS,
);
resolveChanged = () => {
clearTimeout(changeTimeout);
@@ -108,6 +120,7 @@ test("a watched edit invalidates a warm runtime skill snapshot immediately", asy
});
t.after(() => watcher.dispose());
await watcher.watchSkillFiles([skillFile]);
+ await waitForWatcherBaseline();
await fs.writeFile(skillFile, "After", "utf8");
await changed;
diff --git a/main/services/browser/service.ts b/main/services/browser/service.ts
index 5326475e..0ccb16d9 100644
--- a/main/services/browser/service.ts
+++ b/main/services/browser/service.ts
@@ -765,6 +765,7 @@ export class BrowserService {
workspace.state.tabs.push(state);
if (show) this.activateTab(tab);
view.setBounds({ x: 0, y: 0, width: state.viewport.width, height: state.viewport.height });
+ this.hide(tab);
this.observe(tab);
view.webContents.setZoomFactor(state.zoom);
this.emit(workspaceId);
@@ -800,18 +801,38 @@ export class BrowserService {
}
tab.attached = undefined;
}
+ private hide(tab: LiveTab): void {
+ tab.visible = false;
+ tab.state.visible = false;
+ tab.view.setVisible(false);
+ if (process.platform !== "linux") {
+ this.detach(tab);
+ return;
+ }
+ // Linux capturePage needs a native host even for inactive tabs and overlays.
+ // An invisible child keeps its capture surface without receiving user input.
+ if (tab.attached && !tab.attached.isDestroyed()) return;
+ this.detach(tab);
+ let window: BrowserWindow;
+ try {
+ window = this.mainWindow(tab.state.workspaceId);
+ } catch {
+ // Closing the owning window must not turn a hide request into a new window.
+ return;
+ }
+ window.contentView.addChildView(tab.view);
+ tab.attached = window;
+ }
private present(tab: LiveTab, visible: boolean, bounds?: BrowserBounds): void {
- tab.visible = visible;
- tab.state.visible = visible;
if (bounds) tab.bounds = boundedBounds(bounds);
+ if (!visible || !tab.bounds) {
+ this.hide(tab);
+ return;
+ }
this.detach(tab);
- if (!visible || !tab.bounds) return;
const window = this.mainWindow(tab.state.workspaceId);
for (const candidate of this.tabs.values())
- if (candidate !== tab && candidate.attached === window) {
- this.detach(candidate);
- candidate.visible = false;
- }
+ if (candidate !== tab && candidate.attached === window) this.hide(candidate);
const scale = window.webContents.getZoomFactor();
const area = window.getContentBounds();
const b = tab.bounds;
@@ -820,8 +841,11 @@ export class BrowserService {
const width = Math.max(1, Math.min(Math.round(b.width * scale), area.width - x));
const height = Math.max(1, Math.min(Math.round(b.height * scale), area.height - y));
tab.view.setBounds({ x, y, width, height });
+ tab.view.setVisible(true);
window.contentView.addChildView(tab.view);
tab.attached = window;
+ tab.visible = true;
+ tab.state.visible = true;
void this.applyEmulation(tab).catch(() => {});
}
private close(tab: LiveTab): void {
@@ -1303,10 +1327,8 @@ export class BrowserService {
private floating(tab: LiveTab, enabled: boolean, publish = true): void {
if (tab.state.floating === enabled) return;
// The renderer supplies bounds for either its sidebar slot or draggable chat overlay.
- // Keep the same sandboxed guest and detach until the destination slot is measured.
- this.detach(tab);
- tab.visible = false;
- tab.state.visible = false;
+ // Keep the same sandboxed guest hidden until the destination slot is measured.
+ this.hide(tab);
tab.floatingViewport =
enabled && tab.state.viewport.mode === "fill"
? { ...tab.state.viewport, mode: "responsive" }
diff --git a/main/services/chat-title.ts b/main/services/chat-title.ts
index 6348ef4a..036c57de 100644
--- a/main/services/chat-title.ts
+++ b/main/services/chat-title.ts
@@ -15,6 +15,7 @@ import {
import { resolveChatTitleRoute } from "./chat-title-routing.js";
import { configStore } from "./config-store.js";
import { foundationModelsConnection } from "./foundation-models-connection.js";
+import { hostPlatformCapabilities } from "./host-platform-capabilities.js";
import { runtimeSupportsImages } from "./generation-runtime.js";
import { resolveModelRuntime } from "./model-runtime.js";
import {
@@ -204,7 +205,10 @@ async function generateFirstTurnTitle(input: {
const settings = await configStore.getSettings();
const titleProviderId = settings.chatTitleProviderId ?? "automatic";
const foundationModelsStatus =
- titleProviderId === "chat-model" ? null : await foundationModelsConnection.status();
+ titleProviderId === "chat-model" ||
+ !hostPlatformCapabilities().appleFoundationModels
+ ? null
+ : await foundationModelsConnection.status();
const route = resolveChatTitleRoute(titleProviderId, foundationModelsStatus);
if (route === "seed-only") return;
@@ -234,6 +238,9 @@ async function generateFirstTurnTitle(input: {
}
async function generateFoundationModelsRename(chatId: string): Promise {
+ if (!hostPlatformCapabilities().appleFoundationModels) {
+ throw new Error("Apple Foundation Models are not available on this platform.");
+ }
const backgroundTitle = inFlight.get(chatId);
if (backgroundTitle) await backgroundTitle;
diff --git a/main/services/computer-use/binary.ts b/main/services/computer-use/binary.ts
index 0e2346d9..40d4ea5d 100644
--- a/main/services/computer-use/binary.ts
+++ b/main/services/computer-use/binary.ts
@@ -203,7 +203,7 @@ export async function resolveCuaDriverInstallation(
if (options.platform !== "darwin") {
throw new CuaDriverError(
"unsupported_platform",
- "Aiden Computer Use currently supports macOS only.",
+ "Aiden Computer Use is not available on this platform.",
);
}
const brokerAppPath = options.isPackaged
diff --git a/main/services/computer-use/platform.test.ts b/main/services/computer-use/platform.test.ts
new file mode 100644
index 00000000..2ee5de8c
--- /dev/null
+++ b/main/services/computer-use/platform.test.ts
@@ -0,0 +1,23 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { computerUseSupported, unsupportedComputerUseStatus } from "./platform.js";
+
+test("Computer Use is exposed only on macOS", () => {
+ assert.equal(computerUseSupported("darwin"), true);
+ assert.equal(computerUseSupported("linux"), false);
+ assert.equal(computerUseSupported("win32"), false);
+});
+
+test("the Linux fallback fails closed without suggesting macOS permissions", () => {
+ assert.deepEqual(unsupportedComputerUseStatus(), {
+ enabled: false,
+ beta: true,
+ state: "unsupported",
+ detail: "Computer Use is not included on this platform.",
+ ready: false,
+ available: false,
+ retryable: false,
+ canRequestPermissions: false,
+ permissions: { accessibility: null, screenRecording: null },
+ });
+});
diff --git a/main/services/computer-use/platform.ts b/main/services/computer-use/platform.ts
new file mode 100644
index 00000000..2b5a80c5
--- /dev/null
+++ b/main/services/computer-use/platform.ts
@@ -0,0 +1,19 @@
+import type { ComputerUseStatus } from "../types.js";
+
+export function computerUseSupported(platform: NodeJS.Platform = process.platform): boolean {
+ return platform === "darwin";
+}
+
+export function unsupportedComputerUseStatus(): ComputerUseStatus {
+ return {
+ enabled: false,
+ beta: true,
+ state: "unsupported",
+ detail: "Computer Use is not included on this platform.",
+ ready: false,
+ available: false,
+ retryable: false,
+ canRequestPermissions: false,
+ permissions: { accessibility: null, screenRecording: null },
+ };
+}
diff --git a/main/services/computer-use/settings.ts b/main/services/computer-use/settings.ts
index 5070e57e..fe903c8d 100644
--- a/main/services/computer-use/settings.ts
+++ b/main/services/computer-use/settings.ts
@@ -2,10 +2,15 @@ import { configStore } from "../config-store.js";
import { llmClient } from "../llm-client.js";
import { computerUseStatus } from "./status.js";
import { ComputerUseSettingsCoordinator } from "./settings-core.js";
+import { computerUseSupported } from "./platform.js";
export const computerUseSettings = new ComputerUseSettingsCoordinator({
- readPersisted: async () => (await configStore.getSettings()).computerUseEnabled === true,
+ readPersisted: async () =>
+ computerUseSupported() && (await configStore.getSettings()).computerUseEnabled === true,
persist: async (enabled, isCurrent) => {
+ if (enabled && !computerUseSupported()) {
+ throw new Error("Computer Use is not available on this platform.");
+ }
await configStore.setSettings({ computerUseEnabled: enabled }, isCurrent);
},
setRuntimeEnabled: (enabled) => computerUseStatus.setRuntimeEnabled(enabled),
diff --git a/main/services/computer-use/status.ts b/main/services/computer-use/status.ts
index a4a01c0d..36901ea4 100644
--- a/main/services/computer-use/status.ts
+++ b/main/services/computer-use/status.ts
@@ -1,8 +1,10 @@
import { configStore } from "../config-store.js";
import { createCuaDriverHost } from "./runtime.js";
import { ComputerUseStatusService } from "./status-core.js";
+import { computerUseSupported } from "./platform.js";
export const computerUseStatus = new ComputerUseStatusService({
- isEnabled: async () => (await configStore.getSettings()).computerUseEnabled === true,
+ isEnabled: async () =>
+ computerUseSupported() && (await configStore.getSettings()).computerUseEnabled === true,
createHost: createCuaDriverHost,
});
diff --git a/main/services/dictation-coordinator.test.ts b/main/services/dictation-coordinator.test.ts
index 630b1975..87d58c32 100644
--- a/main/services/dictation-coordinator.test.ts
+++ b/main/services/dictation-coordinator.test.ts
@@ -376,3 +376,31 @@ test("dictation broadcasts the explicit Gemini retry-consent stage", async () =>
assert.equal(last?.state, "fallback-consent");
assert.equal(last?.operationId, operationId);
});
+
+test("desktop release during cold startup is latched before recorder readiness", async () => {
+ const shown = deferred();
+ let release!: () => void;
+ const subject = harness({ isHoldToTalk: () => true, showPill: () => shown.promise,
+ startReleaseWatch: (up) => { release = up; return () => {}; } });
+ const pressed = subject.coordinator.press();
+ await new Promise((resolve) => setImmediate(resolve));
+ release(); shown.resolve(true); await pressed;
+ await subject.coordinator.ready();
+ assert.equal(subject.coordinator.currentStage, "transcribing"); subject.coordinator.dispose();
+});
+test("desktop release from a prior operation cannot stop a new recording", async () => {
+ const releases: Array<() => void> = [];
+ const subject = harness({ isHoldToTalk: () => true,
+ startReleaseWatch: (up) => { releases.push(up); return () => {}; } });
+ await subject.coordinator.ready(); await subject.coordinator.press();
+ await subject.coordinator.cancel(); await subject.coordinator.press();
+ releases[0](); await subject.coordinator.ready();
+ assert.equal(subject.coordinator.currentStage, "recording"); subject.coordinator.dispose();
+});
+test("desktop session failure during startup stops at first recorder readiness", async () => {
+ let fail!: () => void;
+ const subject = harness({ isHoldToTalk: () => true,
+ startReleaseWatch: (_up, failed) => { fail = failed; return () => {}; } });
+ await subject.coordinator.press(); fail(); await subject.coordinator.ready();
+ assert.equal(subject.coordinator.currentStage, "transcribing"); subject.coordinator.dispose();
+});
diff --git a/main/services/dictation-coordinator.ts b/main/services/dictation-coordinator.ts
index 2f2f75c1..fa3b5ea8 100644
--- a/main/services/dictation-coordinator.ts
+++ b/main/services/dictation-coordinator.ts
@@ -18,6 +18,8 @@ export interface DictationCoordinatorDeps {
/** Optional polish after STT; must return the original text on failure. */
cleanupTranscript?: (text: string) => Promise;
shouldCleanup?: () => boolean | Promise;
+ /** Desktop-owned release subscription; no global key polling. */
+ startReleaseWatch?: (onRelease: () => void, onFailed: () => void) => (() => void) | null;
getHoldKeyCode?: () => number | null | Promise;
startHoldWatch?: (
keyCode: number,
@@ -121,7 +123,23 @@ export class DictationCoordinator {
private beginHoldWatch(): void {
this.endHoldWatch();
- if (!this.holdToTalk || this.holdKeyCode === null || !this.deps.startHoldWatch) return;
+ if (!this.holdToTalk) return;
+ const operationId = this.operationId;
+ if (this.deps.startReleaseWatch) {
+ try {
+ const stop = this.deps.startReleaseWatch(
+ () => { void this.release(operationId); },
+ () => { void this.release(operationId); },
+ );
+ this.stopHoldWatch = stop;
+ this.holdWatchActive = typeof stop === "function";
+ } catch (error) {
+ this.deps.logError("Could not subscribe to desktop shortcut release.", error);
+ this.holdWatchActive = false;
+ }
+ return;
+ }
+ if (this.holdKeyCode === null || !this.deps.startHoldWatch) return;
try {
const stop = this.deps.startHoldWatch(
this.holdKeyCode,
@@ -194,10 +212,12 @@ export class DictationCoordinator {
this.pendingRelease = false;
this.operationSequence += 1;
this.operationId = `${(this.deps.now ?? Date.now)()}-${this.operationSequence}`;
+ if (this.deps.startReleaseWatch) this.beginHoldWatch();
try {
const created = await this.deps.showPill();
if (created) this.pillReady = false;
} catch (error) {
+ this.endHoldWatch();
this.stage = "idle";
this.operationId = null;
this.deps.logError("Could not show the dictation pill.", error);
@@ -206,7 +226,7 @@ export class DictationCoordinator {
if (this.stage === "starting" && this.pillReady) {
this.stage = "recording";
this.deps.broadcast({ state: "recording", operationId: this.operationId ?? undefined });
- this.beginHoldWatch();
+ if (!this.deps.startReleaseWatch) this.beginHoldWatch();
if (this.pendingRelease) this.stopIfRecording();
}
return;
@@ -244,8 +264,9 @@ export class DictationCoordinator {
}
/** Hold-to-talk key-up, with a short grace so OS repeats do not cut capture. */
- release(): Promise {
+ release(expectedOperationId?: string | null): Promise {
return this.enqueue(async () => {
+ if (expectedOperationId !== undefined && expectedOperationId !== this.operationId) return;
if (!this.holdToTalk) return;
if (this.stage === "starting") {
this.pendingRelease = true;
@@ -280,7 +301,7 @@ export class DictationCoordinator {
if (this.stage === "starting") {
this.stage = "recording";
this.deps.broadcast({ state: "recording", operationId: this.operationId ?? undefined });
- this.beginHoldWatch();
+ if (!this.deps.startReleaseWatch) this.beginHoldWatch();
if (this.pendingRelease) this.stopIfRecording();
}
});
diff --git a/main/services/dictation-hold-settings.test.ts b/main/services/dictation-hold-settings.test.ts
new file mode 100644
index 00000000..b9f4d680
--- /dev/null
+++ b/main/services/dictation-hold-settings.test.ts
@@ -0,0 +1,70 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { DictationHoldSettingsTransaction } from "./dictation-hold-settings.js";
+
+test("a newer toggle choice prevents delayed portal approval from persisting hold", async () => {
+ let approve!: () => void;
+ let saved = false;
+ let active = false;
+ let cancelled = false;
+ const transaction = new DictationHoldSettingsTransaction({ active: () => active, bind: () => new Promise(resolve => { approve = () => { if (!cancelled) active = true; resolve(); }; }), disable: async () => { cancelled = true; active = false; } });
+ const hold = transaction.apply(true, async () => { saved = true; });
+ const rejected = assert.rejects(hold, /changed during setup/u);
+ await transaction.apply(false, async () => { saved = false; });
+ approve();
+ await rejected;
+ assert.equal(saved, false);
+ assert.equal(active, false);
+});
+
+test("a superseded bind that becomes active after toggle cleanup is disabled", async () => {
+ let finishBind!: () => void;
+ let active = false;
+ let disables = 0;
+ const transaction = new DictationHoldSettingsTransaction({
+ active: () => active,
+ bind: () => new Promise((resolve) => { finishBind = () => { active = true; resolve(); }; }),
+ disable: async () => { disables += 1; active = false; },
+ });
+ const hold = transaction.apply(true, async () => {});
+ const rejected = assert.rejects(hold, /changed during setup/u);
+ await transaction.apply(false, async () => {});
+ finishBind();
+ await rejected;
+ assert.equal(active, false);
+ assert.equal(disables, 2);
+});
+
+test("failed persistence closes newly acquired portal authority", async () => {
+ let active = false;
+ const transaction = new DictationHoldSettingsTransaction({ active: () => active, bind: async () => { active = true; }, disable: async () => { active = false; } });
+ await assert.rejects(transaction.apply(true, async () => { throw new Error("disk error"); }), /disk error/u);
+ assert.equal(active, false);
+});
+
+test("an older toggle save cannot close a newer hold session", async () => {
+ let finishSave!: () => void;
+ let active = false;
+ const transaction = new DictationHoldSettingsTransaction({ active: () => active, bind: async () => { active = true; }, disable: async () => { active = false; } });
+ const toggle = transaction.apply(false, () => new Promise(resolve => { finishSave = resolve; }));
+ await transaction.apply(true, async () => {});
+ finishSave();
+ await toggle;
+ assert.equal(active, true);
+});
+
+
+test("a stale request delayed inside persistence cannot overwrite the newer preference", async () => {
+ let finishOld!: () => void;
+ let saved = true;
+ let active = true;
+ const transaction = new DictationHoldSettingsTransaction({ active: () => active, bind: async () => { active = true; }, disable: async () => { active = false; } });
+ const old = transaction.apply(false, (isCurrent) => new Promise(resolve => {
+ finishOld = () => { if (isCurrent()) saved = false; resolve(); };
+ }));
+ await transaction.apply(true, async (isCurrent) => { if (isCurrent()) saved = true; });
+ finishOld();
+ await old;
+ assert.equal(saved, true);
+ assert.equal(active, true);
+});
diff --git a/main/services/dictation-hold-settings.ts b/main/services/dictation-hold-settings.ts
new file mode 100644
index 00000000..6775c270
--- /dev/null
+++ b/main/services/dictation-hold-settings.ts
@@ -0,0 +1,28 @@
+/** Fence an explicit desktop bind against newer Settings mutations. */
+export class DictationHoldSettingsTransaction {
+ private revision = 0;
+ private latestHold = false;
+ constructor(private readonly portal: {
+ active(): boolean;
+ bind(): Promise;
+ disable(): Promise;
+ }) {}
+
+ async apply(hold: boolean, persist: (isCurrent: () => boolean) => Promise): Promise {
+ const revision = ++this.revision;
+ this.latestHold = hold;
+ const newlyBound = hold && !this.portal.active();
+ if (newlyBound) await this.portal.bind();
+ if (revision !== this.revision) {
+ if (newlyBound && !this.latestHold && this.portal.active()) await this.portal.disable();
+ throw new Error("Dictation shortcut settings changed during setup.");
+ }
+ let result: T;
+ try { result = await persist(() => revision === this.revision); } catch (error) {
+ if (newlyBound && revision === this.revision) await this.portal.disable();
+ throw error;
+ }
+ if (!hold && revision === this.revision) await this.portal.disable();
+ return result;
+ }
+}
diff --git a/main/services/dictation-platform.test.ts b/main/services/dictation-platform.test.ts
new file mode 100644
index 00000000..778f1883
--- /dev/null
+++ b/main/services/dictation-platform.test.ts
@@ -0,0 +1,17 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { dictationPlatformBehavior } from "./dictation-platform.js";
+
+test("Linux dictation is toggle-only clipboard delivery", () => {
+ assert.deepEqual(dictationPlatformBehavior("linux"), {
+ accessibilityPaste: false,
+ holdToTalk: false,
+ });
+});
+test("macOS dictation can use attended paste and hold-to-talk", () => {
+ assert.deepEqual(dictationPlatformBehavior("darwin"), {
+ accessibilityPaste: true,
+ holdToTalk: true,
+ });
+});
diff --git a/main/services/dictation-platform.ts b/main/services/dictation-platform.ts
new file mode 100644
index 00000000..3f4ec5f7
--- /dev/null
+++ b/main/services/dictation-platform.ts
@@ -0,0 +1,15 @@
+import { hostPlatformCapabilities } from "./host-platform-capabilities.js";
+
+export interface DictationPlatformBehavior {
+ accessibilityPaste: boolean;
+ holdToTalk: boolean;
+}
+export function dictationPlatformBehavior(
+ platform: NodeJS.Platform = process.platform,
+): DictationPlatformBehavior {
+ const host = hostPlatformCapabilities(platform);
+ return {
+ accessibilityPaste: host.accessibilityPaste,
+ holdToTalk: host.dictationHoldToTalk,
+ };
+}
diff --git a/main/services/dictation.ts b/main/services/dictation.ts
index ca9d1b2a..2160392c 100644
--- a/main/services/dictation.ts
+++ b/main/services/dictation.ts
@@ -11,47 +11,78 @@ import { cleanupDictationTranscript } from "./dictation-cleanup.js";
import { shouldAcceptDictationPress } from "./dictation-hotkey.js";
import { watchMacKeyUntilUp } from "./dictation-key-state.js";
import { acceleratorPrimaryMacKeyCode } from "./dictation-keycode.js";
+import { dictationPlatformBehavior } from "./dictation-platform.js";
import { pasteTranscript, runAtomicMacPaste, type PasteDeps } from "./dictation-paste.js";
import { DictationCoordinator } from "./dictation-coordinator.js";
+import { activeLinuxDictationHoldShortcut, initLinuxDictationSessionLost, subscribeLinuxDictationRelease } from "./shortcut.js";
+
let lastPressAt = 0;
function livePasteDeps(): PasteDeps {
+ const behavior = dictationPlatformBehavior();
return {
writeClipboard: (text) => clipboard.writeText(text),
// Delivery must never steal focus with a native permission prompt. Users
// grant paste access explicitly from Settings; otherwise we copy safely.
- isAccessibilityTrusted: () => systemPreferences.isTrustedAccessibilityClient(false),
- pasteWithPreservedClipboard: runAtomicMacPaste,
+ isAccessibilityTrusted: () =>
+ behavior.accessibilityPaste &&
+ systemPreferences.isTrustedAccessibilityClient(false),
+ pasteWithPreservedClipboard: behavior.accessibilityPaste
+ ? runAtomicMacPaste
+ : async () => false,
log: (message, error) => logger.warn("dictation", message, error),
};
}
+async function deliverTranscript(text: string) {
+ if (!dictationPlatformBehavior().accessibilityPaste) {
+ clipboard.writeText(text);
+ return {
+ outcome: "copied" as const,
+ reason: "paste-unavailable" as const,
+ message: "Copied — automatic paste is not available on this system.",
+ };
+ }
+ return pasteTranscript(text, livePasteDeps());
+}
+
const coordinator = new DictationCoordinator({
showPill,
hidePill,
destroyPill,
broadcast: (payload) => ipcMain.broadcast("dictation:state", payload),
- paste: (text) => pasteTranscript(text, livePasteDeps()),
+ paste: deliverTranscript,
setTimer: (callback, delayMs) => setTimeout(callback, delayMs),
clearTimer: (timer) => clearTimeout(timer),
logError: (message, error) => logger.error("dictation", message, error),
- isHoldToTalk: async () => (await configStore.getSettings()).dictationHoldToTalk === true,
+ isHoldToTalk: async () =>
+ activeLinuxDictationHoldShortcut() ||
+ (dictationPlatformBehavior().holdToTalk &&
+ (await configStore.getSettings()).dictationHoldToTalk === true),
shouldCleanup: async () => (await configStore.getSettings()).dictationCleanup === true,
cleanupTranscript: cleanupDictationTranscript,
+ ...(process.platform === "linux" ? { startReleaseWatch: subscribeLinuxDictationRelease } : {}),
getHoldKeyCode: async () => {
+ if (!dictationPlatformBehavior().holdToTalk) return null;
const settings = await configStore.getSettings();
const binding = effectiveBindings(settings.keybindings, settings)["dictation.toggle"];
return acceleratorPrimaryMacKeyCode(binding);
},
startHoldWatch: (keyCode, onRelease, onFailed) =>
- watchMacKeyUntilUp(keyCode, onRelease, { onFailed }),
+ dictationPlatformBehavior().holdToTalk
+ ? watchMacKeyUntilUp(keyCode, onRelease, { onFailed })
+ : null,
});
+// A portal can disappear before the queued press has installed its release watcher.
+// Queue cancellation behind that press so no recording survives lost ownership.
+initLinuxDictationSessionLost(() => { void coordinator.cancel(); });
+
/** Hotkey callback (fire-and-forget). Debounced against OS key chatter. */
export function toggleDictation(): void {
const now = Date.now();
- if (!shouldAcceptDictationPress(lastPressAt, now)) return;
+ if (!activeLinuxDictationHoldShortcut() && !shouldAcceptDictationPress(lastPressAt, now)) return;
lastPressAt = now;
void coordinator.press();
}
diff --git a/main/services/external-editors.test.ts b/main/services/external-editors.test.ts
index 81adcb83..13056517 100644
--- a/main/services/external-editors.test.ts
+++ b/main/services/external-editors.test.ts
@@ -4,6 +4,8 @@ import {
buildOpenApplicationArguments,
launchApplicationBundle,
openFolderInExternalEditor,
+ linuxExecutableSearchPaths,
+ resolveInstalledLinuxEditors,
resolveInstalledEditorApplications,
type OpenFolderInEditorDependencies,
type ResolvedExternalEditor,
@@ -13,7 +15,7 @@ const cursor: ResolvedExternalEditor = {
id: "cursor",
label: "Cursor",
appPath: "/Applications/Cursor.app",
- bundleId: "com.todesktop.230313mzl4w4u92",
+ launch: { kind: "bundle", bundleId: "com.todesktop.230313mzl4w4u92" },
iconDataUrl: "data:image/png;base64,icon",
};
@@ -95,25 +97,27 @@ test("rejects missing and non-directory workspace folders", async () => {
test("launches with fixed open arguments and never interprets the folder as shell syntax", async () => {
const folderPath = "/tmp/workspace; touch should-not-exist";
- assert.deepEqual(buildOpenApplicationArguments(cursor.bundleId, folderPath), [
+ assert.equal(cursor.launch.kind, "bundle");
+ if (cursor.launch.kind !== "bundle") throw new Error("Expected a macOS bundle fixture.");
+ assert.deepEqual(buildOpenApplicationArguments(cursor.launch.bundleId, folderPath), [
"-b",
- cursor.bundleId,
+ cursor.launch.bundleId,
folderPath,
]);
let invocation: { file: string; args: readonly string[] } | undefined;
- await launchApplicationBundle(cursor.bundleId, folderPath, async (file, args) => {
+ await launchApplicationBundle(cursor.launch.bundleId, folderPath, async (file, args) => {
invocation = { file, args };
});
assert.deepEqual(invocation, {
file: "/usr/bin/open",
- args: ["-b", cursor.bundleId, folderPath],
+ args: ["-b", cursor.launch.bundleId, folderPath],
});
});
test("refreshes availability before launching the selected editor", async () => {
let forcedRefresh = false;
- let launched: { bundleId: string; folderPath: string } | undefined;
+ let launched: { editorId: string; folderPath: string } | undefined;
await openFolderInExternalEditor(
"/tmp/workspace",
"cursor",
@@ -122,18 +126,57 @@ test("refreshes availability before launching the selected editor", async () =>
forcedRefresh = forceRefresh;
return [cursor];
},
- launchApplication: async (bundleId, folderPath) => {
- launched = { bundleId, folderPath };
+ launchApplication: async (editor, folderPath) => {
+ launched = { editorId: editor.id, folderPath };
},
}),
);
assert.equal(forcedRefresh, true);
assert.deepEqual(launched, {
- bundleId: cursor.bundleId,
+ editorId: cursor.id,
folderPath: "/tmp/workspace",
});
});
+test("Linux editor lookup includes distro, Snap, user, and Toolbox command locations", () => {
+ assert.deepEqual(linuxExecutableSearchPaths("/custom/bin:/usr/bin", "/home/aiden"), [
+ "/custom/bin",
+ "/usr/bin",
+ "/usr/local/bin",
+ "/snap/bin",
+ "/home/aiden/.local/bin",
+ "/home/aiden/.local/share/JetBrains/Toolbox/scripts",
+ ]);
+});
+
+test("Linux editor lookup recognizes common Flatpak application IDs", async () => {
+ const definitions = [
+ {
+ id: "vscode",
+ label: "VS Code",
+ bundleIds: [],
+ applicationNames: [],
+ priority: 1,
+ },
+ ];
+ const resolved = await resolveInstalledLinuxEditors(definitions, [], {
+ executablePath: "/usr/bin/flatpak",
+ applicationIds: new Set(["com.visualstudio.code"]),
+ });
+ assert.deepEqual(resolved, [
+ {
+ id: "vscode",
+ label: "VS Code",
+ appPath: "/usr/bin/flatpak",
+ launch: {
+ kind: "flatpak",
+ executablePath: "/usr/bin/flatpak",
+ applicationId: "com.visualstudio.code",
+ },
+ },
+ ]);
+});
+
test("rejects an editor that disappeared after discovery", async () => {
await assert.rejects(
openFolderInExternalEditor(
diff --git a/main/services/external-editors.ts b/main/services/external-editors.ts
index 033f601b..a463fff4 100644
--- a/main/services/external-editors.ts
+++ b/main/services/external-editors.ts
@@ -1,4 +1,5 @@
-import { execFile } from "node:child_process";
+import { execFile, spawn } from "node:child_process";
+import { constants as fsConstants } from "node:fs";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
@@ -24,7 +25,11 @@ export interface ApplicationCandidate {
export interface ResolvedExternalEditor extends ExternalEditor {
appPath: string;
- bundleId: string;
+ launch:
+ | { kind: "bundle"; bundleId: string }
+ | { kind: "executable"; executablePath: string }
+ | { kind: "flatpak"; executablePath: string; applicationId: string }
+ | { kind: "file-manager" };
}
export const EXTERNAL_EDITOR_DEFINITIONS = [
@@ -238,6 +243,13 @@ export const EXTERNAL_EDITOR_DEFINITIONS = [
applicationNames: ["Finder"],
priority: Number.MAX_SAFE_INTEGER,
},
+ {
+ id: "file-manager",
+ label: "Files",
+ bundleIds: [],
+ applicationNames: [],
+ priority: Number.MAX_SAFE_INTEGER,
+ },
] as const satisfies readonly ExternalEditorDefinition[];
const FINDER_APP_PATH = "/System/Library/CoreServices/Finder.app";
@@ -249,6 +261,53 @@ const APPLICATION_ROOTS = [
path.join(os.homedir(), "Applications"),
] as const;
+const LINUX_EXECUTABLES: Readonly> = {
+ cursor: ["cursor"],
+ vscode: ["code"],
+ "vscode-insiders": ["code-insiders"],
+ vscodium: ["codium"],
+ zed: ["zed"],
+ windsurf: ["windsurf"],
+ kiro: ["kiro"],
+ trae: ["trae"],
+ "android-studio": ["studio", "android-studio"],
+ "intellij-idea": ["idea", "idea.sh"],
+ clion: ["clion", "clion.sh"],
+ datagrip: ["datagrip", "datagrip.sh"],
+ dataspell: ["dataspell", "dataspell.sh"],
+ goland: ["goland", "goland.sh"],
+ phpstorm: ["phpstorm", "phpstorm.sh"],
+ pycharm: ["pycharm", "pycharm.sh"],
+ rider: ["rider", "rider.sh"],
+ rubymine: ["rubymine", "rubymine.sh"],
+ rustrover: ["rustrover", "rustrover.sh"],
+ webstorm: ["webstorm", "webstorm.sh"],
+ "sublime-text": ["subl", "sublime_text"],
+ opencode: ["opencode"],
+};
+
+const LINUX_FLATPAKS: Readonly> = {
+ vscode: ["com.visualstudio.code"],
+ "vscode-insiders": ["com.visualstudio.code.insiders"],
+ vscodium: ["com.vscodium.codium"],
+ zed: ["dev.zed.Zed"],
+ "android-studio": ["com.google.AndroidStudio"],
+ "intellij-idea": ["com.jetbrains.IntelliJ-IDEA-Community", "com.jetbrains.IntelliJ-IDEA-Ultimate"],
+ clion: ["com.jetbrains.CLion"],
+ datagrip: ["com.jetbrains.DataGrip"],
+ phpstorm: ["com.jetbrains.PhpStorm"],
+ pycharm: ["com.jetbrains.PyCharm-Community", "com.jetbrains.PyCharm-Professional"],
+ rider: ["com.jetbrains.Rider"],
+ rubymine: ["com.jetbrains.RubyMine"],
+ webstorm: ["com.jetbrains.WebStorm"],
+ "sublime-text": ["com.sublimetext.three"],
+};
+
+export interface LinuxFlatpakInstallation {
+ executablePath: string;
+ applicationIds: ReadonlySet;
+}
+
let cachedEditors: { expiresAt: number; value: ResolvedExternalEditor[] } | null = null;
let discoveryInFlight: Promise | null = null;
@@ -261,6 +320,21 @@ function runFile(file: string, args: readonly string[]): Promise {
});
}
+function launchDetached(file: string, args: readonly string[]): Promise {
+ return new Promise((resolve, reject) => {
+ const child = spawn(file, [...args], {
+ detached: true,
+ stdio: "ignore",
+ windowsHide: true,
+ });
+ child.once("error", reject);
+ child.once("spawn", () => {
+ child.unref();
+ resolve();
+ });
+ });
+}
+
function normalize(value: string | undefined): string {
return value?.trim().toLocaleLowerCase("en-US") ?? "";
}
@@ -292,7 +366,7 @@ export function resolveInstalledEditorApplications(
];
return definitions
- .filter((definition) => definition.id !== "finder")
+ .filter((definition) => definition.id !== "finder" && definition.id !== "file-manager")
.flatMap((definition) => {
const matches = uniqueCandidates
.map((candidate) => ({ candidate, rank: candidateRank(definition, candidate) }))
@@ -311,7 +385,7 @@ export function resolveInstalledEditorApplications(
id: definition.id,
label: definition.label,
appPath: selected.appPath,
- bundleId: selected.bundleId,
+ launch: { kind: "bundle" as const, bundleId: selected.bundleId },
},
];
})
@@ -332,7 +406,7 @@ export function buildExternalEditorSpotlightQuery(
definitions: readonly ExternalEditorDefinition[] = EXTERNAL_EDITOR_DEFINITIONS,
): string {
const clauses = definitions
- .filter((definition) => definition.id !== "finder")
+ .filter((definition) => definition.id !== "finder" && definition.id !== "file-manager")
.flatMap((definition) => [
...definition.bundleIds.map(
(bundleId) => `kMDItemCFBundleIdentifier == "${escapeSpotlightValue(bundleId)}"cd`,
@@ -370,7 +444,7 @@ async function readBundleIdentifier(appPath: string): Promise {
const definitions = EXTERNAL_EDITOR_DEFINITIONS.filter(
- (definition) => definition.id !== "finder",
+ (definition) => definition.id !== "finder" && definition.id !== "file-manager",
);
const directPaths = definitions.flatMap((definition) =>
definition.applicationNames.flatMap((name) =>
@@ -406,6 +480,90 @@ async function locateApplicationCandidates(): Promise {
);
}
+export function linuxExecutableSearchPaths(
+ pathValue: string | undefined = process.env.PATH,
+ homeDirectory: string = os.homedir(),
+): string[] {
+ return [
+ ...(pathValue?.split(path.delimiter) ?? []),
+ "/usr/local/bin",
+ "/usr/bin",
+ "/snap/bin",
+ path.join(homeDirectory, ".local", "bin"),
+ path.join(homeDirectory, ".local", "share", "JetBrains", "Toolbox", "scripts"),
+ ].filter((entry, index, values) => Boolean(entry) && values.indexOf(entry) === index);
+}
+
+export async function resolveInstalledLinuxEditors(
+ definitions: readonly ExternalEditorDefinition[] = EXTERNAL_EDITOR_DEFINITIONS,
+ searchPaths: readonly string[] = linuxExecutableSearchPaths(),
+ flatpak?: LinuxFlatpakInstallation,
+): Promise>> {
+ const resolved = await Promise.all(
+ definitions
+ .filter((definition) => LINUX_EXECUTABLES[definition.id])
+ .map(async (definition) => {
+ for (const executable of LINUX_EXECUTABLES[definition.id] ?? []) {
+ for (const root of searchPaths) {
+ const executablePath = path.join(root, executable);
+ try {
+ await fs.access(executablePath, fsConstants.X_OK);
+ return {
+ id: definition.id,
+ label: definition.label,
+ appPath: executablePath,
+ launch: { kind: "executable" as const, executablePath },
+ };
+ } catch {
+ // Continue through deterministic PATH candidates.
+ }
+ }
+ }
+ const applicationId = (LINUX_FLATPAKS[definition.id] ?? []).find((candidate) =>
+ flatpak?.applicationIds.has(candidate),
+ );
+ if (applicationId && flatpak) {
+ return {
+ id: definition.id,
+ label: definition.label,
+ appPath: flatpak.executablePath,
+ launch: {
+ kind: "flatpak" as const,
+ executablePath: flatpak.executablePath,
+ applicationId,
+ },
+ };
+ }
+ return null;
+ }),
+ );
+ return resolved.filter((editor): editor is NonNullable => editor !== null);
+}
+
+async function locateLinuxFlatpak(
+ searchPaths: readonly string[],
+): Promise {
+ for (const root of searchPaths) {
+ const executablePath = path.join(root, "flatpak");
+ try {
+ await fs.access(executablePath, fsConstants.X_OK);
+ const output = await runFile(executablePath, ["list", "--app", "--columns=application"]);
+ return {
+ executablePath,
+ applicationIds: new Set(
+ output
+ .split("\n")
+ .map((entry) => entry.trim())
+ .filter(Boolean),
+ ),
+ };
+ } catch {
+ // Continue to the next deterministic command location.
+ }
+ }
+ return undefined;
+}
+
async function loadNativeIcon(appPath: string): Promise {
try {
const { app, nativeImage } = await import("electron");
@@ -423,21 +581,40 @@ async function loadNativeIcon(appPath: string): Promise {
}
async function discoverExternalEditors(): Promise {
- const resolved = resolveInstalledEditorApplications(await locateApplicationCandidates());
+ const resolved =
+ process.platform === "linux"
+ ? await (async () => {
+ const searchPaths = linuxExecutableSearchPaths();
+ return resolveInstalledLinuxEditors(
+ EXTERNAL_EDITOR_DEFINITIONS,
+ searchPaths,
+ await locateLinuxFlatpak(searchPaths),
+ );
+ })()
+ : resolveInstalledEditorApplications(await locateApplicationCandidates());
const withIcons = await Promise.all(
resolved.map(async (editor) => ({
...editor,
iconDataUrl: await loadNativeIcon(editor.appPath),
})),
);
- const finder: ResolvedExternalEditor = {
- id: "finder",
- label: "Finder",
- appPath: FINDER_APP_PATH,
- bundleId: "com.apple.finder",
- iconDataUrl: await loadNativeIcon(FINDER_APP_PATH),
- };
- return [...withIcons, finder];
+ const fileManager: ResolvedExternalEditor =
+ process.platform === "linux"
+ ? {
+ id: "file-manager",
+ label: "Files",
+ appPath: "",
+ launch: { kind: "file-manager" },
+ iconDataUrl: "",
+ }
+ : {
+ id: "finder",
+ label: "Finder",
+ appPath: FINDER_APP_PATH,
+ launch: { kind: "file-manager" },
+ iconDataUrl: await loadNativeIcon(FINDER_APP_PATH),
+ };
+ return [...withIcons, fileManager];
}
async function resolvedExternalEditors(forceRefresh = false): Promise {
@@ -493,7 +670,7 @@ export interface OpenFolderInEditorDependencies {
stat: (folderPath: string) => Promise<{ isDirectory(): boolean }>;
editors: (forceRefresh: boolean) => Promise;
openPath: (folderPath: string) => Promise;
- launchApplication: (bundleId: string, folderPath: string) => Promise;
+ launchApplication: (editor: ResolvedExternalEditor, folderPath: string) => Promise;
}
const defaultOpenDependencies: OpenFolderInEditorDependencies = {
@@ -503,7 +680,25 @@ const defaultOpenDependencies: OpenFolderInEditorDependencies = {
const { shell } = await import("electron");
return shell.openPath(folderPath);
},
- launchApplication: launchApplicationBundle,
+ launchApplication: async (editor, folderPath) => {
+ if (editor.launch.kind === "bundle") {
+ await launchApplicationBundle(editor.launch.bundleId, folderPath);
+ return;
+ }
+ if (editor.launch.kind === "executable") {
+ await launchDetached(editor.launch.executablePath, [folderPath]);
+ return;
+ }
+ if (editor.launch.kind === "flatpak") {
+ await launchDetached(editor.launch.executablePath, [
+ "run",
+ editor.launch.applicationId,
+ folderPath,
+ ]);
+ return;
+ }
+ throw new Error("The selected application cannot be launched directly.");
+ },
};
export async function openFolderInExternalEditor(
@@ -525,14 +720,14 @@ export async function openFolderInExternalEditor(
const editor = (await dependencies.editors(true)).find((candidate) => candidate.id === editorId);
if (!editor) throw new Error(`${definition.label} is no longer installed.`);
- if (editor.id === "finder") {
+ if (editor.launch.kind === "file-manager") {
const error = await dependencies.openPath(folderPath);
- if (error) throw new Error(`Could not open workspace in Finder: ${error}`);
+ if (error) throw new Error(`Could not open workspace in ${editor.label}: ${error}`);
return;
}
try {
- await dependencies.launchApplication(editor.bundleId, folderPath);
+ await dependencies.launchApplication(editor, folderPath);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`Could not open workspace in ${editor.label}: ${detail}`);
diff --git a/main/services/generation-timeline.ts b/main/services/generation-timeline.ts
index 5a36dc84..f3c9e714 100644
--- a/main/services/generation-timeline.ts
+++ b/main/services/generation-timeline.ts
@@ -228,7 +228,7 @@ export function safeToolDescriptor(toolName: string, args: unknown): SafeToolDes
case "schedule_task":
return { label: "Schedule task", detail: safeDetail(values.action) };
case "computer_use":
- return { label: "Use Mac", detail: safeDetail(values.action) };
+ return { label: "Use computer", detail: safeDetail(values.action) };
case "vcc_recall":
return { label: "Recall chat history" };
case "compact_context":
diff --git a/main/services/host-platform-capabilities.test.ts b/main/services/host-platform-capabilities.test.ts
new file mode 100644
index 00000000..6a5f2e59
--- /dev/null
+++ b/main/services/host-platform-capabilities.test.ts
@@ -0,0 +1,37 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { hostPlatformCapabilities } from "./host-platform-capabilities.js";
+
+test("Darwin exposes Apple-owned host integrations", () => {
+ assert.deepEqual(hostPlatformCapabilities("darwin"), {
+ platform: "darwin",
+ bots: true,
+ computerUse: true,
+ appleFoundationModels: true,
+ accessibilityPaste: true,
+ dictationHoldToTalk: true,
+ dockIcon: true,
+ nativeShare: true,
+ });
+});
+test("Linux exposes Bots while keeping Apple-owned host integrations disabled", () => {
+ assert.deepEqual(hostPlatformCapabilities("linux"), {
+ platform: "linux",
+ bots: true,
+ computerUse: false,
+ appleFoundationModels: false,
+ accessibilityPaste: false,
+ dictationHoldToTalk: false,
+ dockIcon: false,
+ nativeShare: false,
+ });
+});
+
+test("unsupported hosts cannot widen native capabilities", () => {
+ const capabilities = hostPlatformCapabilities("win32");
+ assert.equal(capabilities.platform, "other");
+ assert.equal(capabilities.bots, false);
+ assert.equal(capabilities.computerUse, false);
+ assert.equal(capabilities.dictationHoldToTalk, false);
+});
diff --git a/main/services/host-platform-capabilities.ts b/main/services/host-platform-capabilities.ts
new file mode 100644
index 00000000..936f14fa
--- /dev/null
+++ b/main/services/host-platform-capabilities.ts
@@ -0,0 +1,30 @@
+export interface HostPlatformCapabilities {
+ platform: "darwin" | "linux" | "other";
+ bots: boolean;
+ computerUse: boolean;
+ appleFoundationModels: boolean;
+ accessibilityPaste: boolean;
+ dictationHoldToTalk: boolean;
+ dockIcon: boolean;
+ nativeShare: boolean;
+}
+/**
+ * Main-owned host capability policy. Persisted settings and renderer state may
+ * narrow these values, but they can never widen them.
+ */
+export function hostPlatformCapabilities(
+ platform: NodeJS.Platform = process.platform,
+): HostPlatformCapabilities {
+ const darwin = platform === "darwin";
+ return {
+ platform:
+ platform === "darwin" || platform === "linux" ? platform : "other",
+ bots: darwin || platform === "linux",
+ computerUse: darwin,
+ appleFoundationModels: darwin,
+ accessibilityPaste: darwin,
+ dictationHoldToTalk: darwin,
+ dockIcon: darwin,
+ nativeShare: darwin,
+ };
+}
diff --git a/main/services/linux-desktop-bus-environment.ts b/main/services/linux-desktop-bus-environment.ts
new file mode 100644
index 00000000..d970b353
--- /dev/null
+++ b/main/services/linux-desktop-bus-environment.ts
@@ -0,0 +1,19 @@
+import * as path from "node:path";
+
+/** Preserve only the local desktop bus address, never ambient credentials or loader hooks. */
+export function linuxDesktopBusEnvironment(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
+ const env: NodeJS.ProcessEnv = { PATH: "/usr/bin:/bin", LANG: "C.UTF-8", LC_ALL: "C.UTF-8" };
+ const runtimeDir = source.XDG_RUNTIME_DIR;
+ if (runtimeDir && runtimeDir.length <= 4096 && path.isAbsolute(runtimeDir) &&
+ Array.from(runtimeDir).every((character) => character.charCodeAt(0) >= 32 && character.charCodeAt(0) !== 127) && path.normalize(runtimeDir) === runtimeDir) {
+ env.XDG_RUNTIME_DIR = runtimeDir;
+ }
+ const bus = source.DBUS_SESSION_BUS_ADDRESS;
+ // Secret Service is a local Unix session-bus service. Reject remote transports,
+ // multiple addresses and malformed escaping rather than passing them to libdbus.
+ if (bus && bus.length <= 4096 &&
+ /^unix:(?:path=\/|abstract=)[A-Za-z0-9_./-]*(?:%[a-fA-F0-9]{2}[A-Za-z0-9_./-]*)*(?:,guid=[a-fA-F0-9]{32})?$/u.test(bus)) {
+ env.DBUS_SESSION_BUS_ADDRESS = bus;
+ }
+ return env;
+}
diff --git a/main/services/linux-dictation-portal.test.ts b/main/services/linux-dictation-portal.test.ts
new file mode 100644
index 00000000..42bd4793
--- /dev/null
+++ b/main/services/linux-dictation-portal.test.ts
@@ -0,0 +1,83 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { EventEmitter } from "node:events";
+import { PassThrough } from "node:stream";
+import type { ChildProcess } from "node:child_process";
+import { LinuxDictationPortal, linuxDictationDesktopEntryAvailable } from "./linux-dictation-portal.js";
+
+function child() {
+ const result = Object.assign(new EventEmitter(), {
+ stdout: new PassThrough(), stdin: new PassThrough(), exitCode: null, killed: false,
+ kill() { this.killed = true; return true; },
+ });
+ return result;
+}
+function setup(timeout = 1000) {
+ const events: string[] = [];
+ const children: ReturnType[] = [];
+ const portal = new LinuxDictationPortal({ activated: () => events.push("down"), deactivated: () => events.push("up"), lost: () => events.push("lost") }, () => {
+ const process = child(); children.push(process); return process as unknown as ChildProcess;
+ }, timeout);
+ const send = (type: string, index = children.length - 1) => children[index].stdout.write(JSON.stringify({ type, triggerDescription: "Ctrl+Space" }) + "\n");
+ return { portal, events, children, send };
+}
+test("portal binds explicitly and deduplicates down/up edges", async () => {
+ const h = setup(); assert.equal(h.children.length, 0);
+ const bound = h.portal.bind(); h.send("bound");
+ assert.equal((await bound).triggerDescription, "Ctrl+Space");
+ h.send("activated"); h.send("activated"); h.send("deactivated"); h.send("deactivated");
+ assert.deepEqual(h.events, ["down", "up"]); h.portal.close();
+});
+test("portal accepts an empty desktop trigger description as unknown display text", async () => {
+ const h = setup();
+ const bound = h.portal.bind();
+ h.children[0].stdout.write('{"type":"bound","triggerDescription":" "}\n');
+ assert.equal((await bound).triggerDescription, null);
+ assert.equal(h.portal.active, true);
+ h.portal.close();
+});
+test("portal setup availability requires discoverable installed desktop metadata", () => {
+ const visited: string[] = [];
+ const exists = (candidate: string) => { visited.push(candidate); return candidate.startsWith("/home/aiden/"); };
+ assert.equal(linuxDictationDesktopEntryAvailable("/home/aiden", exists), true);
+ assert.equal(visited[visited.length - 1], "/home/aiden/.local/share/applications/com.sambitcreate.aiden-agent.desktop");
+ assert.equal(linuxDictationDesktopEntryAvailable(undefined, () => false), false);
+});
+test("cancelled setup cannot activate from delayed stale process messages", async () => {
+ const h = setup(); const first = h.portal.bind();
+ const rejected = assert.rejects(first, /cancelled/); h.portal.close(); await rejected;
+ const second = h.portal.bind(); h.send("bound", 0); h.send("activated", 0);
+ assert.equal(h.portal.active, false); h.send("bound"); await second;
+ h.children[0].emit("exit", 1); assert.equal(h.portal.active, true);
+ assert.deepEqual(h.events, []); h.portal.close();
+});
+test("session loss fires once and ignores all following events", async () => {
+ const h = setup(); const bound = h.portal.bind(); h.send("bound"); await bound;
+ h.send("activated"); h.children[0].emit("exit", 1); h.send("deactivated"); h.children[0].emit("error", new Error());
+ assert.deepEqual(h.events, ["down", "lost"]); assert.equal(h.portal.active, false);
+});
+test("unbound events and oversized protocol messages fail closed", async () => {
+ for (const input of ['{"type":"activated"}\n', 'x'.repeat(4097)]) {
+ const h = setup(); const bound = h.portal.bind(); const rejection = assert.rejects(bound);
+ h.children[0].stdout.write(input); await rejection;
+ assert.equal(h.portal.active, false); assert.deepEqual(h.events, []);
+ }
+});
+test("permission request timeout kills its owner", async () => {
+ const h = setup(5); await assert.rejects(h.portal.bind(), /timed out/);
+ assert.equal(h.children[0].killed, true); assert.equal(h.portal.active, false);
+});
+
+test("stdin failure ends bound authority without an unhandled stream error", async () => {
+ const h = setup(); const bound = h.portal.bind(); h.send("bound"); await bound;
+ h.children[0].stdin.emit("error", new Error("broken pipe"));
+ assert.equal(h.portal.active, false); assert.deepEqual(h.events, ["lost"]);
+});
+test("closing a helper that ignores graceful shutdown escalates to SIGKILL", async () => {
+ const h = setup(); const bound = h.portal.bind(); h.send("bound"); await bound;
+ const signals: Array = [];
+ h.children[0].kill = (signal?: string) => { signals.push(signal); return true; };
+ h.portal.close();
+ await new Promise((resolve) => setTimeout(resolve, 1_050));
+ assert.deepEqual(signals, [undefined, "SIGKILL"]);
+});
diff --git a/main/services/linux-dictation-portal.ts b/main/services/linux-dictation-portal.ts
new file mode 100644
index 00000000..a177fae9
--- /dev/null
+++ b/main/services/linux-dictation-portal.ts
@@ -0,0 +1,113 @@
+import { spawn, type ChildProcess } from "node:child_process";
+import { existsSync } from "node:fs";
+import path from "node:path";
+import { linuxDesktopBusEnvironment } from "./linux-desktop-bus-environment.js";
+
+export function resolveDictationPortalHelper(): string {
+ return process.resourcesPath && !process.defaultApp
+ ? path.resolve(process.resourcesPath, "..", "Helpers", "aiden-global-shortcuts-portal")
+ : path.resolve(process.cwd(), "build/native/aiden-global-shortcuts-portal");
+}
+
+export interface DictationPortalCallbacks {
+ activated(): void;
+ deactivated(): void;
+ lost(): void;
+}
+
+const DESKTOP_ENTRY = "com.sambitcreate.aiden-agent.desktop";
+
+export function linuxDictationDesktopEntryAvailable(
+ home = process.env.HOME,
+ exists: (candidate: string) => boolean = existsSync,
+): boolean {
+ const candidates = [
+ `/usr/local/share/applications/${DESKTOP_ENTRY}`,
+ `/usr/share/applications/${DESKTOP_ENTRY}`,
+ ];
+ if (home && path.isAbsolute(home)) {
+ candidates.push(path.join(home, ".local", "share", "applications", DESKTOP_ENTRY));
+ }
+ return candidates.some(exists);
+}
+
+/** A fresh process owns one explicit desktop permission request and session. */
+export class LinuxDictationPortal {
+ private generation = 0;
+ private child: ChildProcess | null = null;
+ private cancelPending: (() => void) | null = null;
+ private bound = false;
+ private pressed = false;
+ constructor(
+ private readonly callbacks: DictationPortalCallbacks,
+ private readonly spawnHelper: () => ChildProcess = () => spawn(resolveDictationPortalHelper(), ["bind"], { stdio: ["pipe", "pipe", "ignore"], env: linuxDesktopBusEnvironment() }),
+ private readonly timeoutMs = 120_000,
+ ) {}
+ get active(): boolean { return this.bound; }
+
+ bind(): Promise<{ triggerDescription: string | null }> {
+ this.close();
+ const generation = ++this.generation;
+ return new Promise((resolve, reject) => {
+ let buffer = "";
+ let settled = false;
+ let timer: ReturnType;
+ const fail = (message: string) => {
+ if (generation !== this.generation) return;
+ const wasBound = this.bound;
+ if (!settled) { settled = true; reject(new Error(message)); }
+ this.close();
+ if (wasBound) this.callbacks.lost();
+ };
+ this.cancelPending = () => {
+ clearTimeout(timer);
+ if (!settled) { settled = true; reject(new Error("Desktop shortcut setup was cancelled.")); }
+ };
+ timer = setTimeout(() => fail("Desktop shortcut setup timed out."), this.timeoutMs);
+ let child: ChildProcess;
+ try { child = this.spawnHelper(); } catch { fail("Desktop shortcuts are unavailable."); return; }
+ this.child = child;
+ child.stdout?.setEncoding("utf8");
+ child.stdout?.on("data", (chunk: string) => {
+ if (generation !== this.generation) return;
+ buffer += chunk;
+ if (buffer.length > 4096) { fail("Invalid desktop shortcut response."); return; }
+ let newline: number;
+ while ((newline = buffer.indexOf("\n")) >= 0) {
+ const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
+ let event: { type?: unknown; triggerDescription?: unknown };
+ try { event = JSON.parse(line); } catch { fail("Invalid desktop shortcut response."); return; }
+ if (!event || typeof event !== "object") { fail("Invalid desktop shortcut response."); return; }
+ if (event.type === "bound" && !this.bound) {
+ if (typeof event.triggerDescription !== "string" || event.triggerDescription.length > 256) { fail("Desktop shortcut did not report its assigned trigger."); return; }
+ const triggerDescription = event.triggerDescription.trim() || null;
+ this.bound = true; settled = true; clearTimeout(timer);
+ resolve({ triggerDescription });
+ } else if (event.type === "activated" && this.bound) {
+ if (!this.pressed) { this.pressed = true; this.callbacks.activated(); }
+ } else if (event.type === "deactivated" && this.bound) {
+ if (this.pressed) { this.pressed = false; this.callbacks.deactivated(); }
+ } else { fail("Desktop shortcut session ended or became unavailable."); return; }
+ }
+ });
+ child.stdin?.on("error", () => fail("Desktop shortcut session closed."));
+ child.once("error", () => fail("Desktop shortcuts are unavailable."));
+ child.once("exit", () => fail("Desktop shortcut session closed."));
+ });
+ }
+ close(): void {
+ ++this.generation;
+ this.bound = false; this.pressed = false;
+ this.cancelPending?.(); this.cancelPending = null;
+ const child = this.child; this.child = null;
+ if (child) {
+ child.stdin?.end();
+ if (child.exitCode === null && !child.killed) child.kill();
+ if (child.exitCode === null) {
+ const escalation = setTimeout(() => { if (child.exitCode === null) child.kill("SIGKILL"); }, 1_000);
+ escalation.unref();
+ child.once("exit", () => clearTimeout(escalation));
+ }
+ }
+ }
+}
diff --git a/main/services/llm-client.ts b/main/services/llm-client.ts
index 93a89eb3..ae9d7e9e 100644
--- a/main/services/llm-client.ts
+++ b/main/services/llm-client.ts
@@ -69,6 +69,7 @@ import {
type BotRuntimeApprovedRoot,
} from "./bot-runtime-authority-main.js";
import type { BotRuntimeAuthorityAdmission } from "./bot-runtime-authority.js";
+import { hostPlatformCapabilities } from "./host-platform-capabilities.js";
import {
botManagedWorkspace,
resolveBotRuntimeMcpConnectionIdentities,
@@ -159,6 +160,7 @@ import { createInMemoryPiSession } from "./pi-session-repository-port.js";
import type { PiSessionPort } from "./pi-session-port.js";
import { createComputerUseController } from "./computer-use/runtime.js";
import { computerUseStatus } from "./computer-use/status.js";
+import { computerUseSupported } from "./computer-use/platform.js";
import { GenerationTimelineProjector, safeToolIssueDetails } from "./generation-timeline.js";
import { advisorRuntime } from "./advisor-runtime-main.js";
import { ADVISOR_TOOL_NAME } from "./advisor-runtime.js";
@@ -743,6 +745,7 @@ async function prepareGeneration(
);
let computerUse: ComputerUseController | undefined;
if (
+ computerUseSupported() &&
options.allowComputerUse !== false &&
(!botContext || botHasOrdinaryCapability(botContext, "computer_use")) &&
settings.computerUseEnabled === true &&
@@ -1493,6 +1496,9 @@ export const llmClient = {
}
authoritativeChat = chat;
authoritativeMode = authoritativeChatGenerationMode(chat.workspaceId, params.mode);
+ if (chat.botId && !hostPlatformCapabilities().bots) {
+ throw new Error("Bot chats are not available on this platform.");
+ }
authoritativeBot = await resolveBotForGeneration(chat, authoritativeMode, (botId) =>
botStore.get(botId),
);
diff --git a/main/services/mcp-oauth-store.ts b/main/services/mcp-oauth-store.ts
index 2244709d..32e4f98a 100644
--- a/main/services/mcp-oauth-store.ts
+++ b/main/services/mcp-oauth-store.ts
@@ -6,7 +6,8 @@
import * as fs from "fs/promises";
import * as path from "path";
import { randomUUID } from "node:crypto";
-import { app, safeStorage, logger } from "../platform.js";
+import { app, logger } from "../platform.js";
+import { secureStorage } from "./secure-storage.js";
import { parseMcpOAuthSession, type McpOAuthSession } from "./mcp-oauth-session.js";
import {
deleteSecretKeyEntry,
@@ -129,7 +130,7 @@ export const mcpOAuthStore = {
if (!b64) return {};
let session: McpOAuthSession;
try {
- const json = await safeStorage.decryptString(Buffer.from(b64, "base64"));
+ const json = secureStorage.decryptString(Buffer.from(b64, "base64"));
session = parseMcpOAuthSession(JSON.parse(json));
} catch (error) {
logger.error("mcp-oauth", `Failed to decrypt OAuth session for ${serverId}`, error);
@@ -145,10 +146,10 @@ export const mcpOAuthStore = {
session: McpOAuthSession,
isCurrent: MutationGuard = () => true,
): Promise {
- if (!(await safeStorage.isEncryptionAvailable())) {
- throw new Error("Secure storage is unavailable on this system; cannot save the sign-in.");
+ if (!secureStorage.isEncryptionAvailable()) {
+ throw new Error(`${secureStorage.unavailableMessage()} Cannot save the sign-in.`);
}
- const encrypted = await safeStorage.encryptString(JSON.stringify(session));
+ const encrypted = secureStorage.encryptString(JSON.stringify(session));
await mutate(async () => {
assertMutationCurrent(isCurrent);
const map = await readMap();
diff --git a/main/services/models-dev-live-app-policy.test.ts b/main/services/models-dev-live-app-policy.test.ts
new file mode 100644
index 00000000..c206cbed
--- /dev/null
+++ b/main/services/models-dev-live-app-policy.test.ts
@@ -0,0 +1,18 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+test("models.dev refresh is confined to the explicit foreground catalog action", () => {
+ const providers = readFileSync(new URL("../handlers/providers.ts", import.meta.url), "utf8");
+ const catalog = readFileSync(new URL("./models-catalog.ts", import.meta.url), "utf8");
+ const startup = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
+ const actionStart = providers.indexOf('ipcMain.handle("providers:updateCatalogs"');
+ const actionEnd = providers.indexOf('ipcMain.handle(', actionStart + 1);
+ const foreground = providers.slice(actionStart, actionEnd);
+ assert.ok(actionStart >= 0 && actionEnd > actionStart);
+ assert.match(foreground, /providerAuthOwner\(event\)/u);
+ assert.match(foreground, /modelsDevCacheRuntime\.refresh\(\)/u);
+ assert.doesNotMatch(providers.slice(0, actionStart) + providers.slice(actionEnd), /modelsDevCacheRuntime\.refresh/u);
+ assert.match(catalog, /modelsDevCacheRuntime\.catalog\(bundled\)/u);
+ assert.doesNotMatch(catalog + startup, /modelsDevCacheRuntime\.refresh|fetchModelsDevCatalog/u);
+});
diff --git a/main/services/native-menu-command-contract.test.ts b/main/services/native-menu-command-contract.test.ts
index 07865972..4d1957ee 100644
--- a/main/services/native-menu-command-contract.test.ts
+++ b/main/services/native-menu-command-contract.test.ts
@@ -2,14 +2,32 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { COMMANDS } from "../../renderer/shared/keybindings";
+import { applicationMenuTemplate } from "./application-menu-core";
test("catalog native-menu ownership exactly matches derived Electron accelerators", () => {
- const main = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
- const menuCommandIds = [
- ...main.matchAll(/accelerator:\s*command\("([^"]+)"\)/gu),
- ]
- .map((match) => match[1])
- .sort();
+ const delivered = new Set();
+ const menu = applicationMenuTemplate({
+ platform: "darwin",
+ appName: "Aiden Agent",
+ bindings: Object.fromEntries(COMMANDS.map((command) => [command.id, command.defaultBinding])),
+ actions: {
+ checkForUpdates() {},
+ deliverCommand(commandId) {
+ delivered.add(commandId);
+ },
+ reload() {},
+ },
+ });
+ const invokeItems = (items: typeof menu): void => {
+ for (const item of items) {
+ if (typeof item.click === "function") {
+ item.click({} as never, {} as never, {} as never);
+ }
+ if (Array.isArray(item.submenu)) invokeItems(item.submenu);
+ }
+ };
+ invokeItems(menu);
+ const menuCommandIds = [...delivered].sort();
const catalogCommandIds = COMMANDS.filter((command) => command.nativeMenu)
.map((command) => command.id)
.sort();
@@ -57,3 +75,11 @@ test("startup persists semantic V1 repair before runtime registration can fail",
const apply = shortcut.indexOf("return applyNow({ ...settings, keybindings })", start);
assert.ok(start >= 0 && persist > start && apply > persist);
});
+
+test("portal dictation stays owned until a replacement shortcut registers", () => {
+ const shortcut = readFileSync(new URL("./shortcut.ts", import.meta.url), "utf8");
+ const reconcile = shortcut.indexOf("const result = await reconcileGlobalShortcuts");
+ const failure = shortcut.indexOf("if (!result.ok && result.failedCommandId)", reconcile);
+ const close = shortcut.indexOf("if (releaseLinuxPortal)", failure);
+ assert.ok(reconcile >= 0 && failure > reconcile && close > failure);
+});
diff --git a/main/services/pi-credential-store.ts b/main/services/pi-credential-store.ts
index 19d5bb09..2801c8f9 100644
--- a/main/services/pi-credential-store.ts
+++ b/main/services/pi-credential-store.ts
@@ -1,7 +1,8 @@
import * as path from "path";
-import { app, logger, safeStorage } from "../platform.js";
+import { app, logger } from "../platform.js";
import { EncryptedPiCredentialStore } from "./pi-credential-store-core.js";
import { invalidateBotRuntimeInventoryAuthority } from "./bot-runtime-inventory-lease.js";
+import { secureStorage } from "./secure-storage.js";
const FILE = "pi-provider-credentials.json";
@@ -9,9 +10,9 @@ const FILE = "pi-provider-credentials.json";
export const piCredentialStore = new EncryptedPiCredentialStore({
filePath: () => path.join(app.getPath("userData"), FILE),
cipher: {
- isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(),
- encryptString: (value) => safeStorage.encryptString(value),
- decryptString: (value) => safeStorage.decryptString(value),
+ isEncryptionAvailable: () => secureStorage.isEncryptionAvailable(),
+ encryptString: (value) => secureStorage.encryptString(value),
+ decryptString: (value) => secureStorage.decryptString(value),
},
onDurabilityWarning: (error) => {
logger.warn("pi-credential-store", "Credentials were saved without a directory sync.", {
diff --git a/main/services/profile-share-files.test.ts b/main/services/profile-share-files.test.ts
index 6026be8b..10e07745 100644
--- a/main/services/profile-share-files.test.ts
+++ b/main/services/profile-share-files.test.ts
@@ -9,6 +9,7 @@ import {
PROFILE_SHARE_DIRECTORY_PREFIX,
PROFILE_SHARE_FILE_NAME,
PROFILE_SHARE_STALE_AGE_MS,
+ writeProfileShareExport,
} from "./profile-share-files.js";
async function withTemporaryRoot(run: (root: string) => Promise): Promise {
@@ -57,3 +58,20 @@ test("removes only stale, inactive Aiden share directories", async () => {
await fs.stat(unrelated);
});
});
+
+test("writes a private Linux export without following symbolic links", async () => {
+ await withTemporaryRoot(async (root) => {
+ const target = path.join(root, "profile.png");
+ await fs.writeFile(target, "old", { mode: 0o644 });
+ await writeProfileShareExport(target, Buffer.from("image"));
+ assert.deepEqual(await fs.readFile(target), Buffer.from("image"));
+ assert.equal((await fs.stat(target)).mode & 0o777, 0o600);
+
+ const protectedTarget = path.join(root, "protected.png");
+ const link = path.join(root, "link.png");
+ await fs.writeFile(protectedTarget, "keep");
+ await fs.symlink(protectedTarget, link);
+ await assert.rejects(writeProfileShareExport(link, Buffer.from("replace")));
+ assert.equal(await fs.readFile(protectedTarget, "utf8"), "keep");
+ });
+});
diff --git a/main/services/profile-share-files.ts b/main/services/profile-share-files.ts
index da072288..d48deffb 100644
--- a/main/services/profile-share-files.ts
+++ b/main/services/profile-share-files.ts
@@ -1,4 +1,5 @@
import * as fs from "node:fs/promises";
+import { constants as fsConstants } from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
@@ -31,6 +32,25 @@ export async function removeProfileShareDirectory(directory: string): Promise {
+ const handle = await fs.open(
+ filePath,
+ fsConstants.O_WRONLY |
+ fsConstants.O_CREAT |
+ fsConstants.O_TRUNC |
+ fsConstants.O_NOFOLLOW,
+ 0o600,
+ );
+ try {
+ // Opening an existing path does not apply the mode argument. Normalize it
+ // before writing so a user-selected export never inherits public bits.
+ await handle.chmod(0o600);
+ await handle.writeFile(image);
+ } finally {
+ await handle.close();
+ }
+}
+
export async function cleanupStaleProfileShareDirectories(options?: {
temporaryRoot?: string;
activeDirectories?: ReadonlySet;
diff --git a/main/services/profile-share.ts b/main/services/profile-share.ts
index ddbd4066..1a4b5266 100644
--- a/main/services/profile-share.ts
+++ b/main/services/profile-share.ts
@@ -1,4 +1,4 @@
-import { BrowserWindow, ShareMenu, logger, nativeImage } from "../platform.js";
+import { BrowserWindow, ShareMenu, dialog, logger, nativeImage } from "../platform.js";
import {
decodeProfileSharePng,
MAX_SHARE_IMAGE_BYTES,
@@ -8,7 +8,9 @@ import {
import {
cleanupStaleProfileShareDirectories,
createProfileShareFile,
+ PROFILE_SHARE_FILE_NAME,
removeProfileShareDirectory,
+ writeProfileShareExport,
} from "./profile-share-files.js";
const SHARE_FILE_RETENTION_MS = 5 * 60 * 1_000;
@@ -77,19 +79,27 @@ function canonicalProfileSharePng(dataUrl: unknown): Buffer {
export async function shareProfilePng(
dataUrl: unknown,
parent: BrowserWindow | null,
-): Promise {
- if (process.platform !== "darwin") {
- throw new Error("The native profile share sheet is available on macOS.");
- }
+): Promise {
if (!parent || parent.isDestroyed()) {
throw new Error("The profile window is no longer available for sharing.");
}
+ const image = canonicalProfileSharePng(dataUrl);
+ if (process.platform !== "darwin") {
+ const result = await dialog.showSaveDialog(parent, {
+ title: "Save profile snapshot",
+ defaultPath: PROFILE_SHARE_FILE_NAME,
+ buttonLabel: "Save",
+ filters: [{ name: "PNG image", extensions: ["png"] }],
+ });
+ if (result.canceled || !result.filePath) return false;
+ await writeProfileShareExport(result.filePath, image);
+ return true;
+ }
if (activeShareSessions.size > 0) {
throw new Error("Close the current share menu before opening another one.");
}
await beginStaleCleanup();
- const image = canonicalProfileSharePng(dataUrl);
const { directory, filePath } = await createProfileShareFile(image);
ownedShareDirectories.add(directory);
let session: ShareSession | null = null;
@@ -112,6 +122,7 @@ export async function shareProfilePng(
scheduleCleanup(session, SHARE_FILE_RETENTION_MS);
},
});
+ return true;
} catch (error) {
if (session) {
clearTimeout(session.timer);
diff --git a/main/services/provider-credential-rotation.ts b/main/services/provider-credential-rotation.ts
index bde8ac62..50f49be8 100644
--- a/main/services/provider-credential-rotation.ts
+++ b/main/services/provider-credential-rotation.ts
@@ -10,7 +10,10 @@ import {
serializePendingProviderCredentialRotation,
type PendingProviderCredentialRotationV1,
} from "./provider-credential-rotation-core.js";
-import { sameProviderConnection } from "./provider-key-policy.js";
+import {
+ providerTransitionNeedsCredentialAccess,
+ sameProviderConnection,
+} from "./provider-key-policy.js";
import { secrets } from "./secrets.js";
import { mutatePortableConfigAndSync } from "./portable-credential-snapshot.js";
import type { StoredProvider } from "./types.js";
@@ -83,8 +86,13 @@ export function saveProviderWithCredentialRotation(
return mutatePortableConfigAndSync(() =>
serialized(async () => {
if (!isCurrent()) throw new Error("The renderer document is no longer active.");
- await reconcilePendingProviderCredentialRotationNow();
const previous = await configStore.getProvider(provider.id);
+ if (!providerTransitionNeedsCredentialAccess(previous, provider)) {
+ // A fresh Linux desktop may intentionally have no keyring session.
+ // Keyless local providers neither read nor write the secret backend.
+ return configStore.saveProvider(provider, isCurrent);
+ }
+ await reconcilePendingProviderCredentialRotationNow();
const connectionChanged = Boolean(previous && !sameProviderConnection(previous, provider));
const hasStoredKey = await secrets.hasKey(provider.id);
const { previousKey, mismatched } = providerCredentialState(
@@ -221,16 +229,27 @@ export function reconcileExternalProviderCredentialChanges(
current: StoredProvider[],
): Promise {
return serialized(async () => {
+ const previousById = new Map(previous.map((provider) => [provider.id, provider]));
+ const currentById = new Map(current.map((provider) => [provider.id, provider]));
+ const transitions = [...new Set([...previousById.keys(), ...currentById.keys()])]
+ .map((providerId) => ({
+ providerId,
+ before: previousById.get(providerId),
+ after: currentById.get(providerId),
+ }))
+ .filter(({ before, after }) =>
+ after ? !sameProviderConnection(before, after) : Boolean(before),
+ )
+ .filter(({ before, after }) =>
+ providerTransitionNeedsCredentialAccess(before, after),
+ );
+ if (transitions.length === 0) return;
+
// The watcher already selected `current` from one authoritative reload.
// Pending recovery must use that cached projection rather than consuming a
// second disk edit behind the transition the watcher is about to commit.
await reconcilePendingProviderCredentialRotationNow(false, false);
- const previousById = new Map(previous.map((provider) => [provider.id, provider]));
- const currentById = new Map(current.map((provider) => [provider.id, provider]));
- for (const providerId of new Set([...previousById.keys(), ...currentById.keys()])) {
- const before = previousById.get(providerId);
- const after = currentById.get(providerId);
- if (before && after && sameProviderConnection(before, after)) continue;
+ for (const { providerId, after } of transitions) {
// External config writes cannot participate in the encrypted-store queue.
// Preserve the exact bound key in a bounded quarantine slot instead of
// irreversibly deleting it from a potentially stale before/after pair.
diff --git a/main/services/provider-key-policy.test.ts b/main/services/provider-key-policy.test.ts
index 40d236a5..74e030a7 100644
--- a/main/services/provider-key-policy.test.ts
+++ b/main/services/provider-key-policy.test.ts
@@ -1,6 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { canUseStoredProviderKey, sameProviderConnection } from "./provider-key-policy.js";
+import {
+ canUseStoredProviderKey,
+ providerTransitionNeedsCredentialAccess,
+ sameProviderConnection,
+} from "./provider-key-policy.js";
const saved = {
id: "openai",
@@ -24,3 +28,18 @@ test("only reuses a saved key for the same provider connection", () => {
assert.equal(canUseStoredProviderKey(saved, { ...saved, needsKey: false }), false);
assert.equal(canUseStoredProviderKey(null, saved), false);
});
+
+test("keyless provider changes do not require a desktop secret store", () => {
+ const keyless = { ...saved, needsKey: false };
+ assert.equal(providerTransitionNeedsCredentialAccess(undefined, keyless), false);
+ assert.equal(
+ providerTransitionNeedsCredentialAccess(keyless, {
+ ...keyless,
+ baseUrl: "http://127.0.0.1:1234/v1",
+ }),
+ false,
+ );
+ assert.equal(providerTransitionNeedsCredentialAccess(keyless, undefined), false);
+ assert.equal(providerTransitionNeedsCredentialAccess(saved, keyless), true);
+ assert.equal(providerTransitionNeedsCredentialAccess(keyless, saved), true);
+});
diff --git a/main/services/provider-key-policy.ts b/main/services/provider-key-policy.ts
index 6e167b90..0cb46f36 100644
--- a/main/services/provider-key-policy.ts
+++ b/main/services/provider-key-policy.ts
@@ -28,3 +28,11 @@ export function canUseStoredProviderKey(
): boolean {
return draft.needsKey && sameProviderConnection(saved, draft);
}
+
+/** Keyless-to-keyless changes cannot expose, bind, or rotate a provider secret. */
+export function providerTransitionNeedsCredentialAccess(
+ previous: ProviderConnection | null | undefined,
+ current: ProviderConnection | null | undefined,
+): boolean {
+ return previous?.needsKey === true || current?.needsKey === true;
+}
diff --git a/main/services/renderer-readiness-core.test.ts b/main/services/renderer-readiness-core.test.ts
index 69fb0717..5930ea7b 100644
--- a/main/services/renderer-readiness-core.test.ts
+++ b/main/services/renderer-readiness-core.test.ts
@@ -32,10 +32,10 @@ test("main invalidates readiness and reloads after the renderer process exits",
const main = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
assert.match(
main,
- /webContents\.on\(\s*"render-process-gone",\s*\(\) => \{\s*rendererReadiness\.reset\(\)/u,
+ /webContents\.on\(\s*"render-process-gone",\s*\([^)]*\) => \{[\s\S]{0,800}?rendererReadiness\.reset\(\)/u,
);
assert.match(
main,
- /const recovery = mainWindowLoads\.replace\(createdWindow\.loadURL\(mainWindowUrl\)\)/u,
+ /const recovery = mainWindowLoads\.replace\(\s*createdWindow\.loadURL\(mainWindowUrl\),?\s*\)/u,
);
});
diff --git a/main/services/schedule-tool.ts b/main/services/schedule-tool.ts
index 50e91041..6701d774 100644
--- a/main/services/schedule-tool.ts
+++ b/main/services/schedule-tool.ts
@@ -1411,7 +1411,7 @@ export function createAssistantEditAutomationTool(
),
notify: Type.Optional(
Type.Boolean({
- description: "Replacement macOS notification preference.",
+ description: "Replacement desktop notification preference.",
}),
),
},
@@ -1516,7 +1516,7 @@ export function createScheduleTaskTool(
),
notify: Type.Optional(
Type.Boolean({
- description: "Show a macOS notification after non-silent runs.",
+ description: "Show a desktop notification after non-silent runs.",
}),
),
},
@@ -1648,7 +1648,7 @@ export function createScheduleTaskTool(
),
notify: Type.Optional(
Type.Boolean({
- description: "Show a macOS notification after non-silent runs.",
+ description: "Show a desktop notification after non-silent runs.",
}),
),
}),
diff --git a/main/services/secrets.ts b/main/services/secrets.ts
index 92926148..1a5ea1e8 100644
--- a/main/services/secrets.ts
+++ b/main/services/secrets.ts
@@ -4,7 +4,8 @@
import * as fs from "fs/promises";
import * as path from "path";
import { randomUUID } from "node:crypto";
-import { app, safeStorage, logger } from "../platform.js";
+import { app, logger } from "../platform.js";
+import { secureStorage } from "./secure-storage.js";
import {
bindSecretEntryIfUnbound,
deleteSecretKeyEntry,
@@ -97,17 +98,17 @@ async function getKeyStrict(providerId: string): Promise {
await mutationTail;
const b64 = secretKeyEntry(await readMap(), providerId);
if (!b64) return null;
- return safeStorage.decryptString(Buffer.from(b64, "base64"));
+ return secureStorage.decryptString(Buffer.from(b64, "base64"));
}
async function encryptValue(value: string): Promise {
- const encrypted = await safeStorage.encryptString(value);
+ const encrypted = await secureStorage.encryptString(value);
return Buffer.from(encrypted).toString("base64");
}
async function decryptedEntry(map: KeyMap, id: string): Promise {
const b64 = secretKeyEntry(map, id);
- return b64 ? safeStorage.decryptString(Buffer.from(b64, "base64")) : null;
+ return b64 ? secureStorage.decryptString(Buffer.from(b64, "base64")) : null;
}
function setKeyWithBound(
@@ -121,12 +122,12 @@ function setKeyWithBound(
if (key.length > maxLength) {
throw new Error(`Encrypted values cannot exceed ${maxLength} characters.`);
}
- if (!(await safeStorage.isEncryptionAvailable())) {
- throw new Error("Secure storage is unavailable on this system; cannot save the API key.");
+ if (!secureStorage.isEncryptionAvailable()) {
+ throw new Error(`${secureStorage.unavailableMessage()} Cannot save the API key.`);
}
const map = await readMap();
assertMutationCurrent(isCurrent);
- const encrypted = await safeStorage.encryptString(key);
+ const encrypted = await secureStorage.encryptString(key);
assertMutationCurrent(isCurrent);
setSecretKeyEntry(map, providerId, Buffer.from(encrypted).toString("base64"));
await writeMap(map, isCurrent);
@@ -216,8 +217,8 @@ export const secrets = {
return serialized(async () => {
assertMutationCurrent(isCurrent);
assertProviderCredentialLength(key);
- if (!(await safeStorage.isEncryptionAvailable())) {
- throw new Error("Secure storage is unavailable on this system; cannot save the API key.");
+ if (!secureStorage.isEncryptionAvailable()) {
+ throw new Error(`${secureStorage.unavailableMessage()} Cannot save the API key.`);
}
const map = await readMap();
assertMutationCurrent(isCurrent);
diff --git a/main/services/secure-storage-core.test.ts b/main/services/secure-storage-core.test.ts
new file mode 100644
index 00000000..064ef73a
--- /dev/null
+++ b/main/services/secure-storage-core.test.ts
@@ -0,0 +1,27 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ secureStorageIsSafe,
+ secureStorageUnavailableMessage,
+} from "./secure-storage-core.js";
+
+test("Linux secure storage fails closed for Electron basic_text and unknown backends", () => {
+ assert.equal(secureStorageIsSafe("linux", true, "basic_text"), false);
+ assert.equal(secureStorageIsSafe("linux", true, "unknown"), false);
+ assert.equal(secureStorageIsSafe("linux", true, "future_backend"), false);
+ assert.equal(secureStorageIsSafe("linux", true, undefined), false);
+ assert.equal(secureStorageIsSafe("linux", false, "gnome_libsecret"), false);
+});
+
+test("Linux accepts desktop keyring-backed encryption", () => {
+ for (const backend of ["gnome_libsecret", "kwallet", "kwallet5", "kwallet6"]) {
+ assert.equal(secureStorageIsSafe("linux", true, backend), true);
+ }
+});
+
+test("non-Linux platforms retain the operating-system encryption decision", () => {
+ assert.equal(secureStorageIsSafe("darwin", true), true);
+ assert.equal(secureStorageIsSafe("darwin", false), false);
+ assert.match(secureStorageUnavailableMessage("linux"), /GNOME Keyring.*KWallet/u);
+});
diff --git a/main/services/secure-storage-core.ts b/main/services/secure-storage-core.ts
new file mode 100644
index 00000000..f12e02b6
--- /dev/null
+++ b/main/services/secure-storage-core.ts
@@ -0,0 +1,31 @@
+export type LinuxSecureStorageBackend =
+ | "basic_text"
+ | "gnome_libsecret"
+ | "kwallet"
+ | "kwallet5"
+ | "kwallet6"
+ | "unknown"
+ | string;
+
+export function secureStorageIsSafe(
+ platform: NodeJS.Platform,
+ encryptionAvailable: boolean,
+ backend?: LinuxSecureStorageBackend,
+): boolean {
+ if (!encryptionAvailable) return false;
+ if (platform !== "linux") return true;
+ // Fail closed for future/unknown values. Electron's documented encrypted
+ // Linux backends are all desktop-keyring implementations.
+ return (
+ backend === "gnome_libsecret" ||
+ backend === "kwallet" ||
+ backend === "kwallet5" ||
+ backend === "kwallet6"
+ );
+}
+
+export function secureStorageUnavailableMessage(platform: NodeJS.Platform): string {
+ return platform === "linux"
+ ? "Secure storage is unavailable. Start or unlock GNOME Keyring, KWallet, or another Secret Service provider, then restart Aiden."
+ : "Secure storage is unavailable on this system.";
+}
diff --git a/main/services/secure-storage.ts b/main/services/secure-storage.ts
new file mode 100644
index 00000000..83b8ac24
--- /dev/null
+++ b/main/services/secure-storage.ts
@@ -0,0 +1,45 @@
+import { safeStorage } from "../platform.js";
+import {
+ secureStorageIsSafe,
+ secureStorageUnavailableMessage,
+ type LinuxSecureStorageBackend,
+} from "./secure-storage-core.js";
+
+function selectedBackend(): LinuxSecureStorageBackend | undefined {
+ if (process.platform !== "linux") return undefined;
+ try {
+ return safeStorage.getSelectedStorageBackend();
+ } catch {
+ return "unknown";
+ }
+}
+
+function assertAvailable(): void {
+ if (!secureStorage.isEncryptionAvailable()) {
+ throw new Error(secureStorageUnavailableMessage(process.platform));
+ }
+}
+
+export const secureStorage = {
+ unavailableMessage(): string {
+ return secureStorageUnavailableMessage(process.platform);
+ },
+
+ isEncryptionAvailable(): boolean {
+ return secureStorageIsSafe(
+ process.platform,
+ safeStorage.isEncryptionAvailable(),
+ selectedBackend(),
+ );
+ },
+
+ encryptString(value: string): Buffer {
+ assertAvailable();
+ return safeStorage.encryptString(value);
+ },
+
+ decryptString(value: Buffer): string {
+ assertAvailable();
+ return safeStorage.decryptString(value);
+ },
+};
diff --git a/main/services/share-image-tool.ts b/main/services/share-image-tool.ts
index bf8743e5..530decd7 100644
--- a/main/services/share-image-tool.ts
+++ b/main/services/share-image-tool.ts
@@ -121,7 +121,7 @@ export function createShareImageTool(dependencies: ShareImageToolDependencies):
name: SHARE_IMAGE_TOOL_NAME,
label: "Share Image",
description:
- "Attach a PNG or JPEG file from this Mac to your response so the user can receive it in Aiden on Mac, iPhone, or iPad. This is an outbound sharing tool: it cannot inspect an image or add one to model input. Never call it to view, understand, or analyze an image. Images attached by the user are already supplied in the conversation when the selected model supports vision. Use this tool only when the user asks to see or receive a local image file. Relative paths start at the active workspace; absolute paths are accepted after user approval.",
+ "Attach a PNG or JPEG file from this computer to your response so the user can receive it in Aiden on desktop, iPhone, or iPad. This is an outbound sharing tool: it cannot inspect an image or add one to model input. Never call it to view, understand, or analyze an image. Images attached by the user are already supplied in the conversation when the selected model supports vision. Use this tool only when the user asks to see or receive a local image file. Relative paths start at the active workspace; absolute paths are accepted after user approval.",
parameters: Type.Object({
path: Type.String({ description: "Workspace-relative or absolute path to the PNG or JPEG." }),
}),
diff --git a/main/services/shortcut-registration-core.test.ts b/main/services/shortcut-registration-core.test.ts
index d099cb4a..02de095e 100644
--- a/main/services/shortcut-registration-core.test.ts
+++ b/main/services/shortcut-registration-core.test.ts
@@ -3,6 +3,8 @@ import test from "node:test";
import type { CommandId } from "../../renderer/shared/keybindings";
import {
reconcileGlobalShortcuts,
+ excludePortalDictationShortcut,
+ canBindPortalDictationShortcut,
type RegisteredGlobalShortcut,
type ShortcutRegistrationPort,
} from "./shortcut-registration-core";
@@ -97,3 +99,32 @@ test("recorder suspension releases every owned shortcut and restores them afterw
assert.equal(restored.ok, true);
assert.deepEqual([...active].sort(), ["Command+Alt+A", "Command+Alt+Space"]);
});
+
+test("an unrelated settings reconciliation preserves exclusive portal dictation ownership", async () => {
+ const { active, port } = fakePort();
+ active.add("Command+Alt+Space");
+ const current = new Map([
+ ["composer.focus", registered("composer.focus", "Command+Alt+Space")],
+ ]);
+ const desired = [
+ registered("composer.focus", "Command+Alt+Space"),
+ registered("dictation.toggle", "Command+Shift+D"),
+ registered("assistant.open", "Command+Alt+A"),
+ ];
+ const portalOwned = await reconcileGlobalShortcuts(port, current, excludePortalDictationShortcut(desired, true));
+ assert.equal(portalOwned.ok, true);
+ assert.equal(portalOwned.registered.has("dictation.toggle"), false);
+ assert.deepEqual([...active].sort(), ["Command+Alt+A", "Command+Alt+Space"]);
+ const fallback = await reconcileGlobalShortcuts(port, portalOwned.registered, excludePortalDictationShortcut(desired, false));
+ assert.equal(fallback.ok, true);
+ assert.equal(active.has("Command+Shift+D"), true);
+});
+
+test("portal setup cannot bypass disabled runtime policy, disabled binding, or chord recording", () => {
+ assert.equal(canBindPortalDictationShortcut(false, "Command+Shift+D", false), false);
+ assert.equal(canBindPortalDictationShortcut(true, null, false), false);
+ assert.equal(canBindPortalDictationShortcut(true, undefined, false), false);
+ assert.equal(canBindPortalDictationShortcut(true, "", false), false);
+ assert.equal(canBindPortalDictationShortcut(true, "Command+Shift+D", true), false);
+ assert.equal(canBindPortalDictationShortcut(true, "Command+Shift+D", false), true);
+});
diff --git a/main/services/shortcut-registration-core.ts b/main/services/shortcut-registration-core.ts
index cb7b219a..6d2d66ea 100644
--- a/main/services/shortcut-registration-core.ts
+++ b/main/services/shortcut-registration-core.ts
@@ -89,3 +89,21 @@ export async function reconcileGlobalShortcuts(
return { ok: true, registered: next };
}
+
+/** Electron must not claim the dictation trigger while its portal session owns it. */
+export function excludePortalDictationShortcut(
+ desired: readonly DesiredGlobalShortcut[],
+ portalActive: boolean,
+): DesiredGlobalShortcut[] {
+ return desired.map((shortcut) => portalActive && shortcut.commandId === "dictation.toggle"
+ ? { ...shortcut, accelerator: null }
+ : shortcut);
+}
+
+export function canBindPortalDictationShortcut(
+ globalShortcutsEnabled: boolean,
+ dictationBinding: string | null | undefined,
+ recordingSuspended: boolean,
+): boolean {
+ return globalShortcutsEnabled && typeof dictationBinding === "string" && dictationBinding.length > 0 && !recordingSuspended;
+}
diff --git a/main/services/shortcut.ts b/main/services/shortcut.ts
index 4e1a9a19..a1378864 100644
--- a/main/services/shortcut.ts
+++ b/main/services/shortcut.ts
@@ -1,7 +1,9 @@
// Transactional global shortcut manager. The renderer/shared command catalog is
// authoritative for defaults, validation, display, menu accelerators, and IPC.
-import { globalShortcut, logger } from "../platform.js";
+import { existsSync } from "node:fs";
+import { LinuxDictationPortal, linuxDictationDesktopEntryAvailable, resolveDictationPortalHelper } from "./linux-dictation-portal.js";
+import { globalShortcut, ipcMain, logger } from "../platform.js";
import { configStore } from "./config-store.js";
import { DataStoreCorruptWriteError } from "./data-store.js";
import { currentRuntimeProfile } from "../runtime-profile.js";
@@ -11,7 +13,8 @@ import {
KeybindingValidationError,
effectiveBindings,
migrateLegacyKeybindings,
- prettyAccelerator,
+ prettyAcceleratorForPlatform,
+ electronAcceleratorForPlatform,
shouldPersistCanonicalKeybindings,
validateEffectiveBindings,
type CommandId,
@@ -20,6 +23,8 @@ import {
} from "../../renderer/shared/keybindings.js";
import {
reconcileGlobalShortcuts,
+ excludePortalDictationShortcut,
+ canBindPortalDictationShortcut,
type RegisteredGlobalShortcut,
} from "./shortcut-registration-core.js";
import {
@@ -36,6 +41,17 @@ let recordingSuspended = false;
let lastAppliedSettings: AppSettings | null = null;
const transactions = createShortcutTransactionQueue();
+function nativeAccelerator(accelerator: string): string {
+ return electronAcceleratorForPlatform(accelerator, process.platform);
+}
+
+function displayAccelerator(accelerator: string | null | undefined): string {
+ return prettyAcceleratorForPlatform(
+ accelerator,
+ process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : "other",
+ );
+}
+
function logRollbackFailure(error: unknown): void {
if (error instanceof ShortcutPersistenceRollbackError) {
logger.error("shortcut", "Shortcut persistence and runtime rollback both failed.", error);
@@ -84,6 +100,9 @@ function globalStatuses(
if (!binding || !globalShortcutsEnabled) {
return { commandId: definition.id, binding, state: "disabled" };
}
+ if (definition.id === "dictation.toggle" && activeLinuxDictationHoldShortcut()) {
+ return { commandId: definition.id, binding: null, state: "active", message: `Desktop shortcut: ${linuxHoldTriggerDescription ?? "configured by your desktop"}` };
+ }
const active = registered.get(definition.id)?.accelerator === binding;
return {
commandId: definition.id,
@@ -93,7 +112,7 @@ function globalStatuses(
? {
message:
lastUnavailable.get(definition.id) ??
- `${prettyAccelerator(binding)} is not registered.`,
+ `${displayAccelerator(binding)} is not registered.`,
}
: {}),
};
@@ -115,8 +134,12 @@ async function applyNow(settings: AppSettings): Promise {
const canonicalSettings = { ...settings, keybindings: overrides };
const bindings = effectiveBindings(overrides);
validateEffectiveBindings(bindings);
+ const previousBinding = lastAppliedSettings ? effectiveBindings(canonicalKeybindings(lastAppliedSettings))["dictation.toggle"] : null;
+ const releaseLinuxPortal = activeLinuxDictationHoldShortcut() &&
+ (!currentRuntimeProfile().globalShortcutsEnabled || recordingSuspended ||
+ !bindings["dictation.toggle"] || bindings["dictation.toggle"] !== previousBinding);
- const desired = COMMANDS.filter((definition) => definition.global).map((definition) => ({
+ const desired = excludePortalDictationShortcut(COMMANDS.filter((definition) => definition.global).map((definition) => ({
commandId: definition.id,
accelerator:
currentRuntimeProfile().globalShortcutsEnabled &&
@@ -125,13 +148,13 @@ async function applyNow(settings: AppSettings): Promise {
? bindings[definition.id]
: null,
handler: handlers.get(definition.id) ?? (() => undefined),
- }));
+ })), activeLinuxDictationHoldShortcut() && !releaseLinuxPortal);
const result = await reconcileGlobalShortcuts(
{
register: async (accelerator, handler) => {
try {
- const ok = await globalShortcut.register(accelerator, handler);
+ const ok = await globalShortcut.register(nativeAccelerator(accelerator), handler);
if (!ok) {
logger.warn(
"shortcut",
@@ -149,7 +172,7 @@ async function applyNow(settings: AppSettings): Promise {
return false;
}
},
- unregister: (accelerator) => globalShortcut.unregister(accelerator),
+ unregister: (accelerator) => globalShortcut.unregister(nativeAccelerator(accelerator)),
},
registered,
desired,
@@ -157,11 +180,17 @@ async function applyNow(settings: AppSettings): Promise {
registered = result.registered;
if (!result.ok && result.failedCommandId) {
const message = result.rollbackFailed
- ? `${prettyAccelerator(result.failedAccelerator)} could not be registered, and macOS did not restore every previous shortcut.`
- : `${prettyAccelerator(result.failedAccelerator)} is unavailable. Another app may be using it.`;
+ ? `${displayAccelerator(result.failedAccelerator)} could not be registered, and the system did not restore every previous shortcut.`
+ : `${displayAccelerator(result.failedAccelerator)} is unavailable. Another app may be using it.`;
lastUnavailable = new Map([[result.failedCommandId, message]]);
throw new KeybindingValidationError(message, "registration", result.failedCommandId);
}
+ if (releaseLinuxPortal) {
+ linuxDictationPortal.close();
+ linuxHoldTriggerDescription = null;
+ notifyLinuxDictationLoss();
+ announceLinuxDictationShortcut();
+ }
lastUnavailable.clear();
onBindingsChanged?.(canonicalSettings, !recordingSuspended);
lastAppliedSettings = canonicalSettings;
@@ -254,7 +283,7 @@ export async function applyShortcutFromSettings(): Promise {
const needsMigration = shouldPersistCanonicalKeybindings(settings.keybindings, keybindings);
// Semantic V1 repair is durable configuration normalization, not a user
// shortcut transaction. Persist it even when an unrelated global chord
- // is currently owned by another macOS app and runtime registration fails.
+ // is currently owned by another operating-system app and runtime registration fails.
if (needsMigration) {
try {
await configStore.setSettings({ keybindings });
@@ -275,9 +304,12 @@ export async function applyShortcutFromSettings(): Promise {
}
export function disposeShortcut(): void {
+ linuxDictationPortal.close();
+ linuxHoldTriggerDescription = null;
+ linuxReleaseListeners.clear();
for (const item of registered.values()) {
try {
- globalShortcut.unregister(item.accelerator);
+ globalShortcut.unregister(nativeAccelerator(item.accelerator));
} catch {
// App teardown must continue even when Electron has already disposed.
}
@@ -287,3 +319,92 @@ export function disposeShortcut(): void {
recordingSuspended = false;
lastAppliedSettings = null;
}
+
+let linuxSessionLost: (() => void) | null = null;
+export function initLinuxDictationSessionLost(handler: () => void): void { linuxSessionLost = handler; }
+function notifyLinuxDictationLoss(): void {
+ for (const listener of [...linuxReleaseListeners]) listener.failed();
+ linuxSessionLost?.();
+}
+
+let linuxHoldTriggerDescription: string | null = null;
+let linuxPortalPressed = false;
+const linuxReleaseListeners = new Set<{ release: () => void; failed: () => void }>();
+const linuxDictationPortal = new LinuxDictationPortal({
+ activated: () => {
+ linuxPortalPressed = true;
+ handlers.get("dictation.toggle")?.();
+ },
+ deactivated: () => {
+ linuxPortalPressed = false;
+ for (const listener of [...linuxReleaseListeners]) listener.release();
+ },
+ lost: () => {
+ linuxPortalPressed = false;
+ linuxHoldTriggerDescription = null;
+ notifyLinuxDictationLoss();
+ void applyShortcutFromSettings().catch((error) => logger.warn("shortcut", "Could not restore dictation toggle shortcut.", error));
+ announceLinuxDictationShortcut();
+ },
+});
+
+function announceLinuxDictationShortcut(): void {
+ if (lastAppliedSettings) ipcMain.broadcast("shortcut:changed", shortcutSnapshot(lastAppliedSettings));
+}
+export function linuxDictationHoldSetupAvailable(): boolean {
+ const binding = lastAppliedSettings ? effectiveBindings(canonicalKeybindings(lastAppliedSettings))["dictation.toggle"] : null;
+ return process.platform === "linux" &&
+ canBindPortalDictationShortcut(currentRuntimeProfile().globalShortcutsEnabled, binding, recordingSuspended) &&
+ existsSync(resolveDictationPortalHelper()) &&
+ linuxDictationDesktopEntryAvailable();
+}
+export function activeLinuxDictationHoldShortcut(): boolean {
+ return process.platform === "linux" && linuxDictationPortal.active;
+}
+export function linuxDictationHoldTriggerDescription(): string | null {
+ return linuxHoldTriggerDescription;
+}
+export function subscribeLinuxDictationRelease(release: () => void, failed: () => void): (() => void) | null {
+ if (!activeLinuxDictationHoldShortcut()) return null;
+ const listener = { release, failed };
+ linuxReleaseListeners.add(listener);
+ // A quick release may precede asynchronous settings and cold pill startup.
+ if (!linuxPortalPressed) queueMicrotask(() => { if (linuxReleaseListeners.has(listener)) release(); });
+ return () => { linuxReleaseListeners.delete(listener); };
+}
+
+/** Explicit user action only. Startup restores the ordinary toggle, never prompts. */
+export function bindLinuxDictationHoldShortcut(): Promise<{ triggerDescription: string | null }> {
+ return transactions.run(async () => {
+ if (process.platform !== "linux") throw new Error("Desktop portal shortcuts require Linux.");
+ const settings = await configStore.getSettings();
+ const binding = effectiveBindings(canonicalKeybindings(settings))["dictation.toggle"];
+ if (!canBindPortalDictationShortcut(currentRuntimeProfile().globalShortcutsEnabled, binding, recordingSuspended)) {
+ throw new Error("Enable the dictation global shortcut before setting up hold-to-talk.");
+ }
+ if (activeLinuxDictationHoldShortcut()) return { triggerDescription: linuxHoldTriggerDescription! };
+ const previous = registered.get("dictation.toggle");
+ if (previous) {
+ globalShortcut.unregister(nativeAccelerator(previous.accelerator));
+ registered.delete("dictation.toggle");
+ }
+ try {
+ const result = await linuxDictationPortal.bind();
+ linuxHoldTriggerDescription = result.triggerDescription;
+ announceLinuxDictationShortcut();
+ return result;
+ } catch (error) {
+ if (lastAppliedSettings) await applyNow(lastAppliedSettings).catch(() => undefined);
+ throw error;
+ }
+ });
+}
+export function disableLinuxDictationHoldShortcut(): Promise {
+ // Cancel a pending portal prompt before entering the registration queue.
+ linuxDictationPortal.close();
+ linuxPortalPressed = false;
+ linuxHoldTriggerDescription = null;
+ notifyLinuxDictationLoss();
+ announceLinuxDictationShortcut();
+ return transactions.run(async () => { if (lastAppliedSettings) await applyNow(lastAppliedSettings); });
+}
diff --git a/main/services/subagents/role-catalog.ts b/main/services/subagents/role-catalog.ts
index 590d03d5..bae7eedc 100644
--- a/main/services/subagents/role-catalog.ts
+++ b/main/services/subagents/role-catalog.ts
@@ -71,7 +71,7 @@ export function subagentRoleSystemPrompt(
: []),
...(shell
? [
- "You have exact run_command access with full macOS-user host execution authority. Every command pauses for attended Allow once approval.",
+ "You have exact run_command access with full host-user execution authority. Every command pauses for attended Allow once approval.",
"The minimal environment reduces ambient secrets only. This is not an OS sandbox, there is no rollback, commands may use arbitrary network access, and deliberately detached processes may survive cancellation.",
]
: []),
diff --git a/main/services/subagents/subagent-run-store-io.test.ts b/main/services/subagents/subagent-run-store-io.test.ts
new file mode 100644
index 00000000..74cb3cc8
--- /dev/null
+++ b/main/services/subagents/subagent-run-store-io.test.ts
@@ -0,0 +1,52 @@
+import assert from "node:assert/strict";
+import { mkdtemp, rm } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import { createNativeSubagentRunStoreStorage, isSubagentRunStoreGeneration } from "./subagent-run-store-io.js";
+
+const repositoryRoot = path.resolve(import.meta.dirname, "../../..");
+const binary = path.join(repositoryRoot, "build", "native", "aiden-subagent-run-store");
+
+test("native run-store adapter accepts the platform generation and round-trips data", async (t) => {
+ if (process.platform !== "darwin" && process.platform !== "linux") {
+ t.skip("The native run-store helper is supported only on macOS and Linux.");
+ return;
+ }
+ const parent = await mkdtemp(path.join(os.tmpdir(), "aiden-run-store-io-"));
+ const storage = createNativeSubagentRunStoreStorage(path.join(parent, "store"), binary);
+ t.after(async () => {
+ await storage.close();
+ await rm(parent, { recursive: true, force: true });
+ });
+
+ assert.deepEqual(await storage.read(), {
+ status: "missing",
+ contents: undefined,
+ generation: "missing",
+ });
+ const first = await storage.write("missing", '{"revision":1}');
+ assert.match(
+ first,
+ process.platform === "linux"
+ ? /^[0-9a-f]+(?:-[0-9a-f]+){6}$/u
+ : /^[0-9a-f]+(?:-[0-9a-f]+){8}$/u,
+ );
+ assert.deepEqual(await storage.read(), {
+ status: "data",
+ contents: Buffer.from('{"revision":1}'),
+ generation: first,
+ });
+ const second = await storage.write(first, '{"revision":2}');
+ assert.notEqual(second, first);
+ await storage.syncDirectory();
+});
+
+test("native generation admits exactly Linux and macOS field shapes", () => {
+ assert.equal(isSubagentRunStoreGeneration("missing"), true);
+ for (let fields = 1; fields <= 12; fields++) {
+ assert.equal(isSubagentRunStoreGeneration(Array.from({ length: fields }, () => "a1").join("-")), fields === 7 || fields === 9);
+ }
+ for (const value of [undefined, null, 1, "", "A-1-1-1-1-1-1", "1-1-1-1-1-1-1\n", "1--1-1-1-1-1-1"]) assert.equal(isSubagentRunStoreGeneration(value), false);
+});
diff --git a/main/services/subagents/subagent-run-store-io.ts b/main/services/subagents/subagent-run-store-io.ts
index 7922eea3..8d13f832 100644
--- a/main/services/subagents/subagent-run-store-io.ts
+++ b/main/services/subagents/subagent-run-store-io.ts
@@ -54,8 +54,14 @@ function defaultStorageBinary(): string {
return path.resolve(process.cwd(), "build", "native", "aiden-subagent-run-store");
}
-function safeGeneration(value: string): boolean {
- return value === "missing" || /^[0-9a-f]+(?:-[0-9a-f]+){8}$/u.test(value);
+export function isSubagentRunStoreGeneration(value: unknown): value is SubagentRunStoreGeneration {
+ // macOS includes birth-time seconds/nanoseconds in addition to the seven
+ // identity fields available from Linux stat. Accept only those two exact
+ // native wire shapes; an intermediate field count is never canonical.
+ return (
+ value === "missing" ||
+ (typeof value === "string" && /^[0-9a-f]+(?:(?:-[0-9a-f]+){6}|(?:-[0-9a-f]+){8})$/u.test(value))
+ );
}
class NativeSubagentRunStoreStorage implements SubagentRunStoreStorage {
@@ -237,11 +243,13 @@ class NativeSubagentRunStoreStorage implements SubagentRunStoreStorage {
}
if (response.startsWith("oversize ")) {
const generation = response.slice(9);
- if (!safeGeneration(generation)) throw new SubagentRunStoreStorageError("io_failed");
+ if (!isSubagentRunStoreGeneration(generation)) throw new SubagentRunStoreStorageError("io_failed");
return { status: "oversized", contents: undefined, generation };
}
- const match = /^data ([0-9a-f]+(?:-[0-9a-f]+){8}) ([A-Za-z0-9+/]*={0,2})$/u.exec(response);
- if (!match) throw new SubagentRunStoreStorageError("io_failed");
+ const match = /^data (\S+) ([A-Za-z0-9+/]*={0,2})$/u.exec(response);
+ if (!match || !isSubagentRunStoreGeneration(match[1])) {
+ throw new SubagentRunStoreStorageError("io_failed");
+ }
return {
status: "data",
generation: match[1],
@@ -250,12 +258,14 @@ class NativeSubagentRunStoreStorage implements SubagentRunStoreStorage {
}
async write(expected: SubagentRunStoreGeneration, contents: string): Promise {
- if (!safeGeneration(expected)) throw new SubagentRunStoreStorageError("invalid_input");
+ if (!isSubagentRunStoreGeneration(expected)) throw new SubagentRunStoreStorageError("invalid_input");
const response = await this.request(
`write ${expected} ${Buffer.from(contents, "utf8").toString("base64")}`,
);
- const match = /^ok ([0-9a-f]+(?:-[0-9a-f]+){8})$/u.exec(response);
- if (!match) throw new SubagentRunStoreStorageError("io_failed");
+ const match = /^ok (\S+)$/u.exec(response);
+ if (!match || !isSubagentRunStoreGeneration(match[1])) {
+ throw new SubagentRunStoreStorageError("io_failed");
+ }
return match[1];
}
diff --git a/main/services/subagents/subagent-run-store-production.test.ts b/main/services/subagents/subagent-run-store-production.test.ts
index 31cfd410..fb199a61 100644
--- a/main/services/subagents/subagent-run-store-production.test.ts
+++ b/main/services/subagents/subagent-run-store-production.test.ts
@@ -12,7 +12,7 @@ interface MemoryFile {
counter: number;
}
-function memoryStorageFactory(files: Map) {
+function memoryStorageFactory(files: Map, generationFields = 9) {
return (directory: string): SubagentRunStoreStorage => {
const file = files.get(directory) ?? { generation: "missing", counter: 0 };
files.set(directory, file);
@@ -37,7 +37,7 @@ function memoryStorageFactory(files: Map) {
async write(expected, contents) {
if (expected !== file.generation) throw new Error("destination changed");
file.counter += 1;
- file.generation = Array.from({ length: 9 }, () => file.counter.toString(16)).join("-");
+ file.generation = Array.from({ length: generationFields }, () => file.counter.toString(16)).join("-");
file.contents = Buffer.from(contents, "utf8");
return file.generation;
},
@@ -95,3 +95,25 @@ test("production V2 startup migrates once and never falls back after canonical c
v2File!.contents = Buffer.from("{corrupt", "utf8");
await assert.rejects(store.get("run-any"), /unreadable evidence/u);
});
+
+for (const generationFields of [7, 9]) {
+ test(`production deletion advances and reloads the ${generationFields}-field native checkpoint`, async () => {
+ const files = new Map();
+ const options = {
+ resolveUserDataDirectory: async () => "/private/aiden-user-data",
+ storageFactory: memoryStorageFactory(files, generationFields),
+ now: () => 100,
+ };
+ const store = createProductionSubagentRunStore(options);
+ await store.initialize();
+ await store.deleteChat("legacy-empty");
+ assert.deepEqual(await store.pendingChatDeletions(), ["legacy-empty"]);
+ const restarted = createProductionSubagentRunStore(options);
+ await restarted.initialize();
+ assert.deepEqual(await restarted.pendingChatDeletions(), ["legacy-empty"]);
+ await restarted.completeChatDeletion("legacy-empty");
+ const afterCompletion = createProductionSubagentRunStore(options);
+ await afterCompletion.initialize();
+ assert.deepEqual(await afterCompletion.pendingChatDeletions(), []);
+ });
+}
diff --git a/main/services/subagents/subagent-run-store-v2-core.ts b/main/services/subagents/subagent-run-store-v2-core.ts
index d8b897cb..9aa8d601 100644
--- a/main/services/subagents/subagent-run-store-v2-core.ts
+++ b/main/services/subagents/subagent-run-store-v2-core.ts
@@ -20,6 +20,7 @@ import {
} from "./subagent-run-store-core.js";
import {
createNativeSubagentRunStoreStorage,
+ isSubagentRunStoreGeneration as safeGeneration,
SubagentRunStoreStorageError,
type SubagentRunStoreGeneration,
type SubagentRunStoreStorage,
@@ -125,9 +126,7 @@ function positiveInteger(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) >= 1;
}
-function safeGeneration(value: unknown): value is SubagentRunStoreGeneration {
- return value === "missing" || (typeof value === "string" && /^[0-9a-f]+(?:-[0-9a-f]+){8}$/u.test(value));
-}
+
function parseMigration(value: unknown): SubagentRunMigrationV2 | undefined {
if (
diff --git a/main/services/subagents/subagent-run-store-v2-migration.ts b/main/services/subagents/subagent-run-store-v2-migration.ts
index ed3a0dd4..446781be 100644
--- a/main/services/subagents/subagent-run-store-v2-migration.ts
+++ b/main/services/subagents/subagent-run-store-v2-migration.ts
@@ -1,3 +1,4 @@
+import { isSubagentRunStoreGeneration as safeGeneration } from "./subagent-run-store-io.js";
import { createHash } from "node:crypto";
import { TextDecoder } from "node:util";
import {
@@ -80,12 +81,7 @@ function exactKeys(value: Record, keys: readonly string[]): boo
return actual.length === keys.length && actual.every((key) => keys.includes(key));
}
-function safeGeneration(value: unknown): value is SubagentRunStoreGeneration {
- return (
- value === "missing" ||
- (typeof value === "string" && /^[0-9a-f]+(?:-[0-9a-f]+){8}$/u.test(value))
- );
-}
+
function safeTimestamp(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
diff --git a/main/services/subagents/subagent-shell-runner-io.test.ts b/main/services/subagents/subagent-shell-runner-io.test.ts
index 29d6cac5..a494219f 100644
--- a/main/services/subagents/subagent-shell-runner-io.test.ts
+++ b/main/services/subagents/subagent-shell-runner-io.test.ts
@@ -49,6 +49,14 @@ test("resolves packaged and development helper locations", () => {
resolveSubagentShellRunnerBinary({ defaultApp: true, cwd: "/workspace" }),
"/workspace/build/native/aiden-subagent-shell-runner",
);
+ assert.equal(
+ resolveSubagentShellRunnerBinary({
+ defaultApp: false,
+ resourcesPath: "/opt/Aiden Agent/resources",
+ cwd: "/workspace",
+ }),
+ "/opt/Aiden Agent/Helpers/aiden-subagent-shell-runner",
+ );
});
test("command exists only in the framed control payload, never helper argv or environment", async () => {
@@ -112,7 +120,7 @@ test("protocol rejects hostile commands and response spoofing", () => {
});
test("native runner returns zero, nonzero, signal, and no-output outcomes", async (t) => {
- if (process.platform !== "darwin") return;
+ if (process.platform !== "darwin" && process.platform !== "linux") return;
assert.deepEqual(await run(t, "printf hello"), {
outcome: "exited",
exitCode: 0,
@@ -132,12 +140,20 @@ test("native runner returns zero, nonzero, signal, and no-output outcomes", asyn
});
test("native runner uses a secret-free fixed environment and private 0700 directories", async (t) => {
- if (process.platform !== "darwin") return;
+ if (process.platform !== "darwin" && process.platform !== "linux") return;
process.env.AIDEN_PHASE5D_SECRET = "must-not-cross";
t.after(() => delete process.env.AIDEN_PHASE5D_SECRET);
+ const modeCommand =
+ process.platform === "linux"
+ ? "stat -c '%a' \"$HOME\" \"$TMPDIR\" \"$XDG_CONFIG_HOME\""
+ : "stat -f '%Lp' \"$HOME\" \"$TMPDIR\" \"$XDG_CONFIG_HOME\"";
const result = await run(
t,
- 'printf \'%s\\n\' "${AIDEN_PHASE5D_SECRET-unset}" "$PATH" "$LANG"; stat -f \'%Lp\' "$HOME" "$TMPDIR" "$XDG_CONFIG_HOME"; test ! -t 0',
+ [
+ 'printf \'%s\\n\' "${AIDEN_PHASE5D_SECRET-unset}" "$PATH" "$LANG"',
+ modeCommand,
+ "test ! -t 0",
+ ].join("; "),
);
assert.equal(result.outcome, "exited");
assert.match(result.stdout, /^unset\n\/usr\/bin:\/bin:\/usr\/sbin:\/sbin\nC\n700\n700\n700\n$/u);
@@ -163,7 +179,7 @@ test("native runner uses a secret-free fixed environment and private 0700 direct
});
test("timeout, cancellation, output floods, and held pipes clean the occupied group", async (t) => {
- if (process.platform !== "darwin") return;
+ if (process.platform !== "darwin" && process.platform !== "linux") return;
assert.equal((await run(t, "sleep 30", 30)).outcome, "timed_out");
assert.equal(
(await run(t, "/usr/bin/yes x & /usr/bin/yes y >&2 & wait", 2_000)).outcome,
@@ -191,7 +207,7 @@ test("timeout, cancellation, output floods, and held pipes clean the occupied gr
});
test("workspace identity drift is rejected before shell execution", async (t) => {
- if (process.platform !== "darwin") return;
+ if (process.platform !== "darwin" && process.platform !== "linux") return;
const rootPath = await workspace(t);
const root = await pinSubagentShellWorkspaceRoot(rootPath);
root.inode = (BigInt(root.inode) + 1n).toString();
@@ -211,7 +227,7 @@ test("workspace identity drift is rejected before shell execution", async (t) =>
});
test("a deliberate setsid double-fork proves the documented containment limit and self-cleans", async (t) => {
- if (process.platform !== "darwin") return;
+ if (process.platform !== "darwin" && process.platform !== "linux") return;
const rootPath = await workspace(t);
const marker = path.join(rootPath, "detached.pid");
const fixture = path.join(
diff --git a/main/services/subagents/subagent-shell-runner-io.ts b/main/services/subagents/subagent-shell-runner-io.ts
index d6278b60..992d4602 100644
--- a/main/services/subagents/subagent-shell-runner-io.ts
+++ b/main/services/subagents/subagent-shell-runner-io.ts
@@ -241,6 +241,11 @@ export async function runSubagentShellProductionInert(input: {
helperErrorBytes += chunk.length;
if (helperErrorBytes > 16 * 1024) child.kill("SIGKILL");
});
+ // A helper that rejects its pinned root may close stdin before this process
+ // finishes the small control write. Its exit status remains authoritative;
+ // contain the resulting stream EPIPE so it cannot escape as an uncaught
+ // exception ahead of the verified close outcome.
+ child.stdin.on("error", () => undefined);
const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
(resolve, reject) => {
child.once("error", reject);
diff --git a/main/services/subagents/subagent-shell.test.ts b/main/services/subagents/subagent-shell.test.ts
index 267742db..5817fb1e 100644
--- a/main/services/subagents/subagent-shell.test.ts
+++ b/main/services/subagents/subagent-shell.test.ts
@@ -12,6 +12,7 @@ import { createSubagentAuthorityV2, type SubagentAuthorityV2 } from "./authority
import {
createSubagentShellBrokerV2,
createSubagentShellTool,
+ subagentShellProfile,
type SubagentShellBrokerV2Input,
} from "./subagent-shell.js";
import { subagentWorkspaceRevisionV2 } from "./subagent-workspace-write.js";
@@ -169,7 +170,20 @@ test("shell tool is exact and inert until the main-owned broker wraps it", () =>
const created = createSubagentShellTool();
assert.equal(created.tool.name, "run_command");
assert.deepEqual(created.binding, { toolName: "run_command" });
- assert.match(created.tool.description, /full macOS-user host authority/u);
+ assert.match(created.tool.description, /full host-user authority/u);
+});
+
+test("shell approvals identify the platform-correct native interpreter", () => {
+ assert.deepEqual(subagentShellProfile("darwin"), {
+ executable: "/bin/zsh",
+ arguments: ["-f", "-c"],
+ display: "/bin/zsh -f -c",
+ });
+ assert.deepEqual(subagentShellProfile("linux"), {
+ executable: "/bin/sh",
+ arguments: ["-c"],
+ display: "/bin/sh -c",
+ });
});
test("exact multiline approval is durable before one helper dispatch", async (t) => {
diff --git a/main/services/subagents/subagent-shell.ts b/main/services/subagents/subagent-shell.ts
index c6dd93e0..ccf8aeb0 100644
--- a/main/services/subagents/subagent-shell.ts
+++ b/main/services/subagents/subagent-shell.ts
@@ -7,7 +7,10 @@ import type {
BeforeToolCallContext,
BeforeToolCallResult,
} from "@earendil-works/pi-agent-core";
-import type { SubagentShellApprovalDetails } from "../../../renderer/shared/assistant.js";
+import type {
+ SubagentShellApprovalDetails,
+ SubagentShellApprovalShell,
+} from "../../../renderer/shared/assistant.js";
import type { ToolApprovalPrompt } from "../tool-approval.js";
import type { Workspace } from "../types.js";
import {
@@ -91,6 +94,21 @@ export interface SubagentShellBrokerV2Input {
registry?: WorkspaceOperationRegistry;
now?: () => number;
randomUUID?: () => string;
+ platform?: NodeJS.Platform;
+}
+
+export interface SubagentShellProfile {
+ executable: string;
+ arguments: readonly string[];
+ display: SubagentShellApprovalShell;
+}
+
+export function subagentShellProfile(
+ platform: NodeJS.Platform = process.platform,
+): SubagentShellProfile {
+ return platform === "darwin"
+ ? { executable: "/bin/zsh", arguments: ["-f", "-c"], display: "/bin/zsh -f -c" }
+ : { executable: "/bin/sh", arguments: ["-c"], display: "/bin/sh -c" };
}
function blocked(reason: string): BeforeToolCallResult {
@@ -170,6 +188,7 @@ function effectDigest(input: {
childId: string;
toolCallId: string;
expiresAt: number;
+ shell: SubagentShellProfile;
}): string {
return fieldsDigest(
"aiden-subagent-shell-effect-v2",
@@ -177,9 +196,8 @@ function effectDigest(input: {
input.root.path,
input.root.device,
input.root.inode,
- "/bin/zsh",
- "-f",
- "-c",
+ input.shell.executable,
+ ...input.shell.arguments,
"aiden-subagent",
"minimal-private-0700-v1",
"stdin=/dev/null",
@@ -231,7 +249,7 @@ export function createSubagentShellTool(): {
name: SUBAGENT_RUN_COMMAND_TOOL_NAME,
label: "Run approved host command",
description:
- "Run one exact command with full macOS-user host authority after attended Allow once approval. Minimal environment only; no OS sandbox or rollback.",
+ "Run one exact command with full host-user authority after attended Allow once approval. Minimal environment only; no OS sandbox or rollback.",
parameters: Type.Object(
{
command: Type.String({
@@ -267,6 +285,7 @@ export function createSubagentShellBrokerV2(
const allocate = input.randomUUID ?? randomUUID;
const registry = input.registry ?? workspaceOperationRegistry;
const runShell = input.runShell ?? runSubagentShellProductionInert;
+ const shell = subagentShellProfile(input.platform);
const pending = new Map();
const active = new Set();
let shuttingDown = false;
@@ -338,6 +357,7 @@ export function createSubagentShellBrokerV2(
childId: input.childId,
toolCallId: context.toolCall.id,
expiresAt,
+ shell,
});
const authorityDigest = subagentAuthorityDigestV2(authority);
const ledgerInput: PrepareSubagentApprovalV2Input = {
@@ -395,7 +415,7 @@ export function createSubagentShellBrokerV2(
childLabel: input.childLabel,
command,
initialCwd: root.path,
- shell: "/bin/zsh -f -c",
+ shell: shell.display,
argumentDigestPrefix: argumentDigest.slice(0, DIGEST_PREFIX),
rootDigestPrefix: rootDigest.slice(0, DIGEST_PREFIX),
effectDigestPrefix: calculatedEffectDigest.slice(0, DIGEST_PREFIX),
diff --git a/main/services/telegram/telegram-bot-binding-platform.test.ts b/main/services/telegram/telegram-bot-binding-platform.test.ts
new file mode 100644
index 00000000..47ce8825
--- /dev/null
+++ b/main/services/telegram/telegram-bot-binding-platform.test.ts
@@ -0,0 +1,40 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+import {
+ createUnavailableTelegramBotBindingStore,
+ TelegramBotBindingsUnsupportedError,
+} from "./telegram-bot-binding-platform.js";
+
+test("unsupported Bot bindings leave ordinary Telegram routing healthy and unbound", async () => {
+ const store = createUnavailableTelegramBotBindingStore();
+ await store.assertHealthy();
+ assert.deepEqual(await store.list(), []);
+ assert.equal(await store.get("bot"), null);
+ assert.equal(await store.resolve("default", 42), null);
+ assert.equal(await store.unbindProfile("default"), 0);
+});
+test("unsupported Bot binding mutations fail closed", async () => {
+ const store = createUnavailableTelegramBotBindingStore();
+ await assert.rejects(
+ store.bind({
+ botId: "bot",
+ profile: "default",
+ chatId: 42,
+ ownerUserId: 42,
+ workspaceId: "workspace",
+ backingWorkspaceId: "bot-workspace",
+ }),
+ TelegramBotBindingsUnsupportedError,
+ );
+ await assert.rejects(store.unbind("bot"), TelegramBotBindingsUnsupportedError);
+});
+
+test("ordinary Telegram cleanup cannot activate Bot notice storage on Linux", () => {
+ const source = readFileSync(new URL("./telegram-service.ts", import.meta.url), "utf8");
+ assert.match(
+ source,
+ /revokeTelegramBotNoticeForCurrentOwner[\s\S]*?if \(!hostPlatformCapabilities\(\)\.bots\) return;[\s\S]*?botApplicationService\.revokeNoticeAudience/u,
+ );
+});
diff --git a/main/services/telegram/telegram-bot-binding-platform.ts b/main/services/telegram/telegram-bot-binding-platform.ts
new file mode 100644
index 00000000..392b36dd
--- /dev/null
+++ b/main/services/telegram/telegram-bot-binding-platform.ts
@@ -0,0 +1,32 @@
+import type {
+ TelegramBotBindingStore,
+} from "./telegram-bot-binding-store.js";
+
+export class TelegramBotBindingsUnsupportedError extends Error {
+ readonly name = "TelegramBotBindingsUnsupportedError";
+
+ constructor() {
+ super("Bots are not available on this platform.");
+ }
+}
+/**
+ * Linux can continue ordinary Telegram routing without pretending to provide
+ * the independently checkpointed Bot-binding authority used on macOS.
+ */
+export function createUnavailableTelegramBotBindingStore(): TelegramBotBindingStore {
+ const unavailable = async (): Promise => {
+ throw new TelegramBotBindingsUnsupportedError();
+ };
+ return Object.freeze({
+ assertHealthy: async () => undefined,
+ list: async () => [],
+ get: async () => null,
+ resolve: async () => null,
+ resolveExact: async () => null,
+ bind: unavailable,
+ unbind: unavailable,
+ // Ordinary Telegram profile deletion/reset uses this reduction-only seam.
+ // With no readable or writable Bot bindings on this host, zero is exact.
+ unbindProfile: async () => 0,
+ });
+}
diff --git a/main/services/telegram/telegram-bot-bindings.ts b/main/services/telegram/telegram-bot-bindings.ts
index cb9f3d66..863653ac 100644
--- a/main/services/telegram/telegram-bot-bindings.ts
+++ b/main/services/telegram/telegram-bot-bindings.ts
@@ -1,17 +1,18 @@
import * as fs from "node:fs/promises";
import { app } from "../../platform.js";
import {
- botCapabilityKeychainAccountForCanonicalRoot,
- createTelegramBotBindingKeychainAnchor,
- createTelegramBotBindingKeychainBootstrapMarker,
-} from "../bot-capability-keychain-anchor.js";
+ botCapabilityAuthorityAccountForCanonicalRoot,
+ createBotAuthorities,
+} from "../bot-capability-authority.js";
import { createTelegramBotBindingStore } from "./telegram-bot-binding-store.js";
import { createTelegramBotBindingAuthorityNarrower } from "./telegram-bot-binding-authority.js";
+import { createUnavailableTelegramBotBindingStore } from "./telegram-bot-binding-platform.js";
+import { hostPlatformCapabilities } from "../host-platform-capabilities.js";
let accountPromise: Promise | undefined;
const account = (): Promise => {
accountPromise ??= fs.realpath(app.getPath("userData"))
- .then(botCapabilityKeychainAccountForCanonicalRoot)
+ .then(botCapabilityAuthorityAccountForCanonicalRoot)
.catch((error) => {
accountPromise = undefined;
throw error;
@@ -19,14 +20,18 @@ const account = (): Promise => {
return accountPromise;
};
+const authorities = hostPlatformCapabilities().bots ? createBotAuthorities({ account }) : null;
+
/** Main-owned durable registry shared by Telegram routing and Bots IPC. */
-export const telegramBotBindings = createTelegramBotBindingStore({
- root: () => app.getPath("userData"),
- authority: {
- head: createTelegramBotBindingKeychainAnchor({ account }),
- bootstrap: createTelegramBotBindingKeychainBootstrapMarker({ account }),
- },
-});
+export const telegramBotBindings = hostPlatformCapabilities().bots
+ ? createTelegramBotBindingStore({
+ root: () => app.getPath("userData"),
+ authority: {
+ head: authorities!.telegramAnchor,
+ bootstrap: authorities!.telegramBootstrapMarker,
+ },
+ })
+ : createUnavailableTelegramBotBindingStore();
/** Reduction-only companion; widening remains behind Bot application admission. */
export const telegramBotBindingAuthority =
diff --git a/main/services/telegram/telegram-service.ts b/main/services/telegram/telegram-service.ts
index 765b7439..185016dc 100644
--- a/main/services/telegram/telegram-service.ts
+++ b/main/services/telegram/telegram-service.ts
@@ -77,6 +77,7 @@ import {
} from "./telegram-bot-bindings.js";
import { createTelegramBotBindingValidator } from "./telegram-bot-binding-validation.js";
import { telegramProfileMutationFence } from "./telegram-profile-mutation-fence.js";
+import { hostPlatformCapabilities } from "../host-platform-capabilities.js";
export const TELEGRAM_PROVIDER_ID = "telegram";
let profileSettingsMutation = Promise.resolve();
@@ -88,6 +89,7 @@ async function getProfileSettings(profile: string) {
async function revokeTelegramBotNoticeForCurrentOwner(
profile: string,
): Promise {
+ if (!hostPlatformCapabilities().bots) return;
const ownerUserId = (await getProfileSettings(profile)).telegramAllowedUserId;
if (ownerUserId === undefined) return;
await botApplicationService.revokeNoticeAudience(
diff --git a/main/services/terminal-spawn-helper.ts b/main/services/terminal-spawn-helper.ts
index f0d15080..9032c1e7 100644
--- a/main/services/terminal-spawn-helper.ts
+++ b/main/services/terminal-spawn-helper.ts
@@ -25,12 +25,17 @@ export async function resolveNodePtySpawnHelperPaths(
packageDir: string,
readDirectory: (directory: string) => Promise = fs.readdir,
): Promise {
- const prebuildsDir = path.join(resolveNodePtyDiskPackageDir(packageDir), "prebuilds");
+ const diskPackageDir = resolveNodePtyDiskPackageDir(packageDir);
+ const compiledHelper = path.join(diskPackageDir, "build", "Release", "spawn-helper");
+ const prebuildsDir = path.join(diskPackageDir, "prebuilds");
let entries: readonly string[];
try {
entries = await readDirectory(prebuildsDir);
} catch {
- return [];
+ return [compiledHelper];
}
- return entries.map((entry) => path.join(prebuildsDir, entry, "spawn-helper"));
+ return [
+ compiledHelper,
+ ...entries.map((entry) => path.join(prebuildsDir, entry, "spawn-helper")),
+ ];
}
diff --git a/main/services/terminal.test.ts b/main/services/terminal.test.ts
index d28bf532..fa5155e7 100644
--- a/main/services/terminal.test.ts
+++ b/main/services/terminal.test.ts
@@ -309,6 +309,18 @@ test("production spawn-helper discovery reads only the unpacked ASAR directory",
assert.deepEqual(reads, [unpackedPrebuilds]);
assert.deepEqual(helpers, [
+ path.join(
+ "/Applications",
+ "Aiden Agent.app",
+ "Contents",
+ "Resources",
+ "app.asar.unpacked",
+ "node_modules",
+ "node-pty",
+ "build",
+ "Release",
+ "spawn-helper",
+ ),
path.join(unpackedPrebuilds, "darwin-arm64", "spawn-helper"),
path.join(unpackedPrebuilds, "darwin-x64", "spawn-helper"),
]);
@@ -333,7 +345,19 @@ test("spawn-helper discovery handles node_modules.asar and absent prebuilds", as
throw new Error("missing");
});
- assert.deepEqual(helpers, []);
+ assert.deepEqual(helpers, [
+ path.join(
+ "/Applications",
+ "Aiden Agent.app",
+ "Contents",
+ "Resources",
+ "node_modules.asar.unpacked",
+ "node-pty",
+ "build",
+ "Release",
+ "spawn-helper",
+ ),
+ ]);
assert.deepEqual(reads, [
path.join(
"/Applications",
diff --git a/main/services/terminal.ts b/main/services/terminal.ts
index 4d728676..7cfb79b2 100644
--- a/main/services/terminal.ts
+++ b/main/services/terminal.ts
@@ -53,7 +53,7 @@ export interface TerminalServiceOptions {
spawnPty?: typeof spawn;
/**
* Ordered shell candidates. The first that exists and is executable wins.
- * Exposed for tests; production resolves `$SHELL` then the macOS defaults.
+ * Exposed for tests; production resolves `$SHELL` then platform defaults.
*/
shellCandidates?: () => string[];
/** Test seam for candidate executability checks. */
@@ -100,12 +100,16 @@ function clamp(value: unknown, min: number, max: number, fallback: number): numb
* being handed to node-pty: a stale `$SHELL` pointing at a removed Homebrew
* install would otherwise surface as an opaque `posix_spawnp failed.`.
*/
-function defaultShellCandidates(): string[] {
+export function defaultShellCandidates(
+ platform: NodeJS.Platform = process.platform,
+ environment: NodeJS.ProcessEnv = process.env,
+): string[] {
const candidates: string[] = [];
- const shell = process.env.SHELL;
+ const shell = environment.SHELL;
if (shell && path.isAbsolute(shell)) candidates.push(shell);
- candidates.push("/bin/zsh", "/bin/bash", "/bin/sh");
- // De-duplicate while preserving order (e.g. SHELL=/bin/zsh).
+ if (platform === "darwin") candidates.push("/bin/zsh", "/bin/bash", "/bin/sh");
+ else candidates.push("/bin/bash", "/bin/sh", "/bin/zsh");
+ // De-duplicate while preserving order (e.g. SHELL=/bin/bash).
return [...new Set(candidates)];
}
@@ -207,7 +211,7 @@ async function trySpawnShell(
? lastError
: "unknown error";
throw new Error(
- `Could not launch any shell (tried ${attempted}). Last failure: ${causeMessage}. Set $SHELL to an installed shell or reinstall macOS.`,
+ `Could not launch any shell (tried ${attempted}). Last failure: ${causeMessage}. Set $SHELL to an installed executable shell.`,
);
}
@@ -285,9 +289,9 @@ export class TerminalService {
const executableCandidates = candidates.filter(shellIsExecutable);
if (executableCandidates.length === 0) {
throw new Error(
- `No executable shell found on this Mac (checked ${candidates
+ `No executable shell found on this system (checked ${candidates
.map((candidate) => JSON.stringify(candidate))
- .join(", ")}). Set $SHELL to an installed shell, or reinstall macOS.`,
+ .join(", ")}). Set $SHELL to an installed executable shell.`,
);
}
const { pty, shell: resolvedShell, preferredShellSkipped } = await trySpawnShell(
@@ -464,8 +468,8 @@ export class TerminalService {
}
}
- // node-pty's macOS helper can be restored without its execute bit by npm's
- // prebuilt archive, and `posix_spawn` of a non-executable file is exactly
+ // node-pty's helper can be restored without its execute bit by a prebuilt
+ // archive, and `posix_spawn` of a non-executable file is exactly
// what surfaces to users as `posix_spawnp failed.`. Guard every helper that
// node-pty may load: chmod if needed, then verify (never assume). A failure
// here must be descriptive so the user can fix it, not opaque.
@@ -483,12 +487,10 @@ export class TerminalService {
/**
* Resolve every `spawn-helper` node-pty may load on this machine.
*
- * node-pty 1.1.0 loads the helper from `prebuilds/-/spawn-helper`
- * via `utils.loadNativeModule`, and in a packaged Electron app the same file
- * lives under `app.asar.unpacked`. We resolve from `node-pty/package.json`, move
- * to the real unpacked directory before any filesystem operation, and enumerate
- * every `prebuilds/*` directory so a wrong-arch guess, a Rosetta run, or an extra
- * prebuild still gets fixed up.
+ * node-pty 1.1.0 loads the helper beside its native module: normally
+ * `prebuilds/-` on macOS and `build/Release` after a Linux
+ * node-gyp build. In a packaged Electron app the same files live under
+ * `app.asar.unpacked`. Resolve both legitimate layouts from package.json.
*/
async function defaultSpawnHelperPaths(): Promise {
const require = createRequire(import.meta.url);
diff --git a/main/services/workspace-files.test.ts b/main/services/workspace-files.test.ts
index e3bd25b0..6a4b5944 100644
--- a/main/services/workspace-files.test.ts
+++ b/main/services/workspace-files.test.ts
@@ -4,6 +4,7 @@ import * as os from "node:os";
import * as path from "node:path";
import test from "node:test";
import {
+ linuxRecoveryUse,
listWorkspaceFiles,
readWorkspaceFile,
WorkspaceFileError,
@@ -194,3 +195,17 @@ test("workspace editor rejects traversal and binary files", async (t) => {
await assert.rejects(readWorkspaceFile(root, "../outside.txt"), /outside the workspace/);
await assert.rejects(readWorkspaceFile(root, "binary.dat"), /binary/);
});
+
+test(
+ "Linux recovery inspection detects current-user open descriptors",
+ { skip: process.platform !== "linux" || !process.getuid },
+ async (t) => {
+ const root = await workspace(t);
+ const file = path.join(root, "recovery.txt");
+ await fs.writeFile(file, "original");
+ const handle = await fs.open(file, "r");
+ assert.equal(await linuxRecoveryUse(file), "open");
+ await handle.close();
+ assert.equal(await linuxRecoveryUse(file), "clear");
+ },
+);
diff --git a/main/services/workspace-files.ts b/main/services/workspace-files.ts
index 6034b369..ddb9ee9b 100644
--- a/main/services/workspace-files.ts
+++ b/main/services/workspace-files.ts
@@ -77,7 +77,53 @@ export interface WorkspaceFileWriteHooks {
recoveryUse?: (recoveryPath: string) => Promise<"clear" | "open" | "unknown">;
}
+export async function linuxRecoveryUse(
+ recoveryPath: string,
+ procRoot = "/proc",
+): Promise<"clear" | "open" | "unknown"> {
+ let target: { dev: number; ino: number };
+ try {
+ const stats = await fs.stat(recoveryPath);
+ target = { dev: stats.dev, ino: stats.ino };
+ } catch {
+ return "unknown";
+ }
+
+ let processEntries: string[];
+ try {
+ processEntries = (await fs.readdir(procRoot)).filter((entry) => /^\d+$/u.test(entry));
+ } catch {
+ return "unknown";
+ }
+
+ const currentUid = process.getuid?.();
+ let complete = true;
+ for (const pid of processEntries) {
+ const processPath = path.join(procRoot, pid);
+ try {
+ const processStats = await fs.stat(processPath);
+ if (currentUid !== undefined && processStats.uid !== currentUid) continue;
+ const descriptors = await fs.readdir(path.join(processPath, "fd"));
+ for (const descriptor of descriptors) {
+ try {
+ const stats = await fs.stat(path.join(processPath, "fd", descriptor));
+ if (stats.dev === target.dev && stats.ino === target.ino) return "open";
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") complete = false;
+ }
+ }
+ } catch (error) {
+ const code = (error as NodeJS.ErrnoException).code;
+ // A same-user /proc entry that cannot be inspected must fail closed so
+ // recovery data is never removed while a descriptor may still be open.
+ if (code !== "ENOENT") complete = false;
+ }
+ }
+ return complete ? "clear" : "unknown";
+}
+
async function recoveryUse(recoveryPath: string): Promise<"clear" | "open" | "unknown"> {
+ if (process.platform === "linux") return linuxRecoveryUse(recoveryPath);
if (process.platform !== "darwin") return "unknown";
return new Promise((resolve) => {
execFile(
diff --git a/main/services/workspace-worktree-application-service.ts b/main/services/workspace-worktree-application-service.ts
index 661f4815..f5305aab 100644
--- a/main/services/workspace-worktree-application-service.ts
+++ b/main/services/workspace-worktree-application-service.ts
@@ -51,7 +51,7 @@ function displayName(source: Workspace, branch: string, requested?: string): str
/**
* Shared renderer/remote orchestration for Aiden-owned Git worktrees. All
- * filesystem and Git-admin identity is reloaded from persisted Mac state.
+ * filesystem and Git-admin identity is reloaded from persisted desktop state.
*/
export function createWorkspaceWorktreeApplicationService(
dependencies: WorkspaceWorktreeApplicationDependencies,
diff --git a/main/windows/main-window-options.test.ts b/main/windows/main-window-options.test.ts
new file mode 100644
index 00000000..bd68bcd3
--- /dev/null
+++ b/main/windows/main-window-options.test.ts
@@ -0,0 +1,23 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { mainWindowOptions } from "./main-window-options.js";
+
+test("macOS keeps Aiden's inset transparent window treatment", () => {
+ const options = mainWindowOptions("/tmp/preload.cjs", "darwin");
+ assert.equal(options.titleBarStyle, "hiddenInset");
+ assert.equal(options.transparent, true);
+ assert.equal(options.vibrancy, "sidebar");
+ assert.deepEqual(options.trafficLightPosition, { x: 14, y: 20 });
+});
+
+test("Linux uses compositor-owned opaque native window chrome", () => {
+ const options = mainWindowOptions("/tmp/preload.cjs", "linux");
+ assert.equal(options.titleBarStyle, "default");
+ assert.equal(options.transparent, false);
+ assert.equal(options.backgroundColor, "#f6f7f9");
+ assert.equal(options.vibrancy, undefined);
+ assert.equal(options.trafficLightPosition, undefined);
+ assert.equal(options.webPreferences?.sandbox, true);
+ assert.equal(mainWindowOptions("/tmp/preload.cjs", "linux", true).backgroundColor, "#181b21");
+});
diff --git a/main/windows/main-window-options.ts b/main/windows/main-window-options.ts
new file mode 100644
index 00000000..e6f1541c
--- /dev/null
+++ b/main/windows/main-window-options.ts
@@ -0,0 +1,41 @@
+import type { BrowserWindowConstructorOptions } from "electron";
+
+export function mainWindowOptions(
+ preload: string,
+ platform: NodeJS.Platform = process.platform,
+ dark = false,
+): BrowserWindowConstructorOptions {
+ const shared: BrowserWindowConstructorOptions = {
+ width: 1000,
+ height: 700,
+ minWidth: 390,
+ minHeight: 456,
+ show: false,
+ webPreferences: {
+ preload,
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: true,
+ },
+ };
+ if (platform !== "darwin") {
+ return {
+ ...shared,
+ // Linux compositors own the native title bar and window shadow. An
+ // opaque semantic surface avoids transparency artifacts under Wayland.
+ backgroundColor: dark ? "#181b21" : "#f6f7f9",
+ titleBarStyle: "default",
+ transparent: false,
+ };
+ }
+ return {
+ ...shared,
+ titleBarStyle: "hiddenInset",
+ // Center the 12px macOS window controls in the renderer's 52px top bar.
+ trafficLightPosition: { x: 14, y: 20 },
+ backgroundColor: "#00000000",
+ transparent: true,
+ vibrancy: "sidebar",
+ visualEffectState: "active",
+ };
+}
diff --git a/main/windows/pill-window-platform.test.ts b/main/windows/pill-window-platform.test.ts
new file mode 100644
index 00000000..fda9509d
--- /dev/null
+++ b/main/windows/pill-window-platform.test.ts
@@ -0,0 +1,10 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { shouldPositionDictationPill } from "./pill-window-platform.js";
+
+test("dictation pill positioning respects Wayland compositor ownership", () => {
+ assert.equal(shouldPositionDictationPill("darwin", undefined), true);
+ assert.equal(shouldPositionDictationPill("linux", "x11"), true);
+ assert.equal(shouldPositionDictationPill("linux", "wayland"), false);
+ assert.equal(shouldPositionDictationPill("linux", "WAYLAND"), false);
+});
diff --git a/main/windows/pill-window-platform.ts b/main/windows/pill-window-platform.ts
new file mode 100644
index 00000000..1a1154ff
--- /dev/null
+++ b/main/windows/pill-window-platform.ts
@@ -0,0 +1,6 @@
+export function shouldPositionDictationPill(
+ platform: NodeJS.Platform = process.platform,
+ sessionType: string | undefined = process.env.XDG_SESSION_TYPE,
+): boolean {
+ return !(platform === "linux" && sessionType?.toLocaleLowerCase("en-US") === "wayland");
+}
diff --git a/main/windows/pill-window.ts b/main/windows/pill-window.ts
index 5c8111aa..6f7c51cc 100644
--- a/main/windows/pill-window.ts
+++ b/main/windows/pill-window.ts
@@ -7,6 +7,7 @@ import { BrowserWindow, logger, screen } from "../platform.js";
import type { IpcMainInvokeEvent } from "electron";
import { getPillPreloadPath, getWindowUrl } from "./window-paths.js";
import { isTrustedPillSender } from "./pill-window-security.js";
+import { shouldPositionDictationPill } from "./pill-window-platform.js";
const PILL_WIDTH = 280;
const PILL_HEIGHT = 56;
@@ -18,6 +19,7 @@ let loading: Promise | null = null;
let pillUrl = "";
function positionPill(window: BrowserWindow): void {
+ if (!shouldPositionDictationPill()) return;
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint());
const { workArea } = display;
window.setBounds({
@@ -55,7 +57,7 @@ async function createPillWindow(): Promise {
// Float above other apps (including fullscreen spaces) without stealing focus.
window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
- window.setAlwaysOnTop(true, "status");
+ window.setAlwaysOnTop(true, process.platform === "darwin" ? "status" : "normal");
window.on("closed", () => {
pillWindow = null;
diff --git a/native/bot-inbox-writer/main.c b/native/bot-inbox-writer/main.c
index 0af8d271..e4ac9c8f 100644
--- a/native/bot-inbox-writer/main.c
+++ b/native/bot-inbox-writer/main.c
@@ -1,4 +1,8 @@
+#ifdef __APPLE__
#define _DARWIN_C_SOURCE 1
+#else
+#define _GNU_SOURCE
+#endif
#include
#include
diff --git a/native/global-shortcuts-portal/main.c b/native/global-shortcuts-portal/main.c
new file mode 100644
index 00000000..beea29ba
--- /dev/null
+++ b/native/global-shortcuts-portal/main.c
@@ -0,0 +1,246 @@
+#define _GNU_SOURCE
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+/* Launched only for an explicit user bind action. No startup registration. */
+#define PORTAL "org.freedesktop.portal.Desktop"
+#define ROOT "/org/freedesktop/portal/desktop"
+#define SHORTCUTS "org.freedesktop.portal.GlobalShortcuts"
+#define REQUEST "org.freedesktop.portal.Request"
+#define SESSION "org.freedesktop.portal.Session"
+#define REGISTRY "org.freedesktop.host.portal.Registry"
+#define APP_ID "com.sambitcreate.aiden-agent"
+static GDBusConnection *bus;
+static GMainLoop *loop;
+static char *owner, *request_path, *session_path, *sender_component;
+static const char *preferred;
+static gboolean bound, active, finishing, waiting_create = TRUE;
+static guint deadline;
+static int exit_status;
+
+static void output(const char *line) {
+ size_t length = strlen(line);
+ if (length > 2048 || write(STDOUT_FILENO, line, length) != (ssize_t)length) {
+ exit_status = 2;
+ if (loop) g_main_loop_quit(loop);
+ }
+}
+static void finish(const char *code) {
+ if (finishing) return;
+ finishing = TRUE;
+ exit_status = code ? 2 : 0;
+ if (code) {
+ char line[128];
+ g_snprintf(line, sizeof(line), "{\"type\":\"error\",\"code\":\"%s\"}\n", code);
+ output(line);
+ } else output("{\"type\":\"closed\"}\n");
+ if (loop) g_main_loop_quit(loop);
+}
+static gboolean expired(gpointer data) { (void)data; deadline = 0; finish("timeout"); return G_SOURCE_REMOVE; }
+static gboolean terminated(gpointer data) { (void)data; finish(NULL); return G_SOURCE_REMOVE; }
+static gboolean stdin_ready(gint fd, GIOCondition condition, gpointer data) {
+ (void)data;
+ char bytes[64];
+ if ((condition & (G_IO_HUP | G_IO_ERR)) || read(fd, bytes, sizeof(bytes)) <= 0) finish(NULL);
+ else finish("protocol");
+ return G_SOURCE_REMOVE;
+}
+static char *token(void) {
+ char *value = g_uuid_string_random();
+ for (char *p = value; *p; ++p) if (*p == '-') *p = '_';
+ return value;
+}
+static GVariant *options(const char *handle, const char *session) {
+ GVariantBuilder b;
+ g_variant_builder_init(&b, G_VARIANT_TYPE_VARDICT);
+ g_variant_builder_add(&b, "{sv}", "handle_token", g_variant_new_string(handle));
+ if (session) g_variant_builder_add(&b, "{sv}", "session_handle_token", g_variant_new_string(session));
+ return g_variant_builder_end(&b);
+}
+static gboolean supports_hold_session(void) {
+ /* Mutter 50.4 can lose a chord release when its modifier is released first.
+ * The portal returns only a localized trigger_description, not the assigned
+ * accelerator, so even a requested plain function key cannot be verified.
+ * Conservatively retain toggle dictation on GNOME until release handling is
+ * fixed and accepted. This nonactivating probe is not process authentication. */
+ GError *error = NULL;
+ GVariant *reply = g_dbus_connection_call_sync(bus, "org.freedesktop.DBus",
+ "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner",
+ g_variant_new("(s)", "org.gnome.Shell"), G_VARIANT_TYPE("(b)"),
+ G_DBUS_CALL_FLAGS_NONE, 5000, NULL, &error);
+ gboolean gnome = TRUE;
+ if (reply) { g_variant_get(reply, "(b)", &gnome); g_variant_unref(reply); }
+ gboolean supported = !error && !gnome;
+ g_clear_error(&error);
+ if (!supported) finish("unavailable");
+ return supported;
+}
+static gboolean register_app(void) {
+ /* Portal display/association metadata, not authenticated process identity.
+ * Register this connection before any portal call, against the fenced owner. */
+ GError *error = NULL;
+ GVariantBuilder values;
+ g_variant_builder_init(&values, G_VARIANT_TYPE_VARDICT);
+ GVariant *reply = g_dbus_connection_call_sync(bus, owner, ROOT, REGISTRY, "Register",
+ g_variant_new("(s@a{sv})", APP_ID, g_variant_builder_end(&values)),
+ G_VARIANT_TYPE_UNIT, G_DBUS_CALL_FLAGS_NONE, 10000, NULL, &error);
+ gboolean supported_or_legacy = reply != NULL ||
+ g_error_matches(error, G_DBUS_ERROR, G_DBUS_ERROR_UNKNOWN_INTERFACE) ||
+ g_error_matches(error, G_DBUS_ERROR, G_DBUS_ERROR_UNKNOWN_METHOD);
+ if (reply) g_variant_unref(reply);
+ g_clear_error(&error);
+ if (!supported_or_legacy) finish("unavailable");
+ return supported_or_legacy;
+}
+static gboolean call_request(const char *method, GVariant *parameters, const char *handle) {
+ g_free(request_path);
+ request_path = g_strdup_printf(ROOT "/request/%s/%s", sender_component, handle);
+ GError *error = NULL;
+ GVariant *reply = g_dbus_connection_call_sync(bus, owner, ROOT, SHORTCUTS, method, parameters,
+ G_VARIANT_TYPE("(o)"), G_DBUS_CALL_FLAGS_NONE, 10000, NULL, &error);
+ if (!reply || error) { g_clear_error(&error); if (reply) g_variant_unref(reply); finish("unavailable"); return FALSE; }
+ const char *returned;
+ g_variant_get(reply, "(&o)", &returned);
+ gboolean valid = strcmp(returned, request_path) == 0;
+ g_variant_unref(reply);
+ if (!valid) finish("protocol");
+ return valid;
+}
+static void bind_shortcut(void) {
+ GVariantBuilder shortcuts, properties;
+ g_variant_builder_init(&shortcuts, G_VARIANT_TYPE("a(sa{sv})"));
+ g_variant_builder_init(&properties, G_VARIANT_TYPE_VARDICT);
+ g_variant_builder_add(&properties, "{sv}", "description", g_variant_new_string("Hold to dictate in Aiden"));
+ if (preferred && *preferred) g_variant_builder_add(&properties, "{sv}", "preferred_trigger", g_variant_new_string(preferred));
+ g_variant_builder_add(&shortcuts, "(s@a{sv})", "dictation", g_variant_builder_end(&properties));
+ char *handle = token();
+ call_request("BindShortcuts", g_variant_new("(o@a(sa{sv})s@a{sv})", session_path,
+ g_variant_builder_end(&shortcuts), "", options(handle, NULL)), handle);
+ g_free(handle);
+}
+static void emit_bound(const char *description) {
+ GString *line = g_string_new("{\"type\":\"bound\",\"triggerDescription\":\"");
+ for (const unsigned char *p = (const unsigned char *)description; *p; ++p) {
+ if (*p == '"' || *p == '\\') g_string_append_c(line, '\\');
+ if (*p < 0x20) g_string_append_printf(line, "\\u%04x", *p);
+ else g_string_append_c(line, (char)*p);
+ }
+ g_string_append(line, "\"}\n"); output(line->str); g_string_free(line, TRUE);
+}
+static void response(GDBusConnection *connection, const gchar *sender, const gchar *object,
+ const gchar *interface, const gchar *signal, GVariant *parameters, gpointer data) {
+ (void)connection; (void)interface; (void)signal; (void)data;
+ if (finishing || !request_path || strcmp(sender, owner) || strcmp(object, request_path)) return;
+ if (!g_variant_is_of_type(parameters, G_VARIANT_TYPE("(ua{sv})"))) { finish("protocol"); return; }
+ guint result; GVariant *values;
+ g_variant_get(parameters, "(u@a{sv})", &result, &values);
+ if (result != 0) { g_variant_unref(values); finish(result == 1 ? "cancelled" : "unavailable"); return; }
+ if (waiting_create) {
+ const char *created = NULL;
+ if (!g_variant_lookup(values, "session_handle", "&s", &created) || strcmp(created, session_path)) {
+ g_variant_unref(values); finish("protocol"); return;
+ }
+ waiting_create = FALSE;
+ g_variant_unref(values); bind_shortcut(); return;
+ }
+ GVariant *shortcuts = g_variant_lookup_value(values, "shortcuts", G_VARIANT_TYPE("a(sa{sv})"));
+ if (!shortcuts || g_variant_n_children(shortcuts) != 1) {
+ if (shortcuts) g_variant_unref(shortcuts);
+ g_variant_unref(values); finish("cancelled"); return;
+ }
+ const char *id; GVariant *properties;
+ g_variant_get_child(shortcuts, 0, "(&s@a{sv})", &id, &properties);
+ const char *description = "Desktop shortcut";
+ g_variant_lookup(properties, "trigger_description", "&s", &description);
+ if (strcmp(id, "dictation") || strlen(description) > 256 || !g_utf8_validate(description, -1, NULL)) finish("protocol");
+ else { bound = TRUE; emit_bound(description); g_clear_pointer(&request_path, g_free); if (deadline) { g_source_remove(deadline); deadline = 0; } }
+ g_variant_unref(properties); g_variant_unref(shortcuts); g_variant_unref(values);
+
+}
+static void shortcut_event(GDBusConnection *connection, const gchar *sender, const gchar *object,
+ const gchar *interface, const gchar *signal, GVariant *parameters, gpointer data) {
+ (void)connection; (void)object; (void)interface; (void)data;
+ if (!bound || finishing || strcmp(sender, owner)) return;
+ if (!g_variant_is_of_type(parameters, G_VARIANT_TYPE("(osta{sv})"))) { finish("protocol"); return; }
+ const char *session, *id; guint64 timestamp; GVariant *values;
+ g_variant_get(parameters, "(&o&st@a{sv})", &session, &id, ×tamp, &values);
+ (void)timestamp;
+ if (!strcmp(session, session_path) && !strcmp(id, "dictation")) {
+ if (!strcmp(signal, "Activated") && !active) { active = TRUE; output("{\"type\":\"activated\"}\n"); }
+ else if (!strcmp(signal, "Deactivated") && active) { active = FALSE; output("{\"type\":\"deactivated\"}\n"); }
+ }
+ g_variant_unref(values);
+}
+static void shortcuts_changed(GDBusConnection *connection, const gchar *sender, const gchar *object,
+ const gchar *interface, const gchar *signal, GVariant *parameters, gpointer data) {
+ (void)connection; (void)object; (void)interface; (void)signal; (void)data;
+ if (!bound || finishing || strcmp(sender, owner)) return;
+ if (!g_variant_is_of_type(parameters, G_VARIANT_TYPE("(oa(sa{sv}))"))) { finish("protocol"); return; }
+ const char *session;
+ g_variant_get_child(parameters, 0, "&o", &session);
+ // A desktop-side edit invalidates the displayed chord and the hold state.
+ // Rebinding requires another explicit user action rather than a hidden prompt.
+ if (!strcmp(session, session_path)) finish("unavailable");
+}
+static void session_closed(GDBusConnection *connection, const gchar *sender, const gchar *object,
+ const gchar *interface, const gchar *signal, GVariant *parameters, gpointer data) {
+ (void)connection; (void)interface; (void)signal; (void)parameters; (void)data;
+ if (!strcmp(sender, owner) && !strcmp(object, session_path)) finish(NULL);
+}
+static void owner_changed(GDBusConnection *connection, const gchar *sender, const gchar *object,
+ const gchar *interface, const gchar *signal, GVariant *parameters, gpointer data) {
+ (void)connection; (void)sender; (void)object; (void)interface; (void)signal; (void)data;
+ const char *name, *old_owner, *new_owner;
+ g_variant_get(parameters, "(&s&s&s)", &name, &old_owner, &new_owner);
+ if (!strcmp(name, PORTAL) && !strcmp(old_owner, owner) && strcmp(new_owner, owner)) finish("unavailable");
+}
+static void connection_closed(GDBusConnection *connection, gboolean remote, GError *error, gpointer data) {
+ (void)connection; (void)remote; (void)error; (void)data; finish("unavailable");
+}
+int main(int argc, char **argv) {
+ signal(SIGPIPE, SIG_IGN);
+ if (argc < 2 || argc > 3 || strcmp(argv[1], "bind")) return 6;
+ if (argc == 3) {
+ if (strlen(argv[2]) > 128) return 6;
+ for (const unsigned char *p = (const unsigned char *)argv[2]; *p; ++p) if (*p < 0x20 || *p > 0x7e) return 6;
+ preferred = argv[2];
+ }
+ fcntl(STDOUT_FILENO, F_SETFL, fcntl(STDOUT_FILENO, F_GETFL) | O_NONBLOCK);
+ GError *error = NULL;
+ bus = g_bus_get_sync(G_BUS_TYPE_SESSION, NULL, &error);
+ if (!bus) { g_clear_error(&error); finish("unavailable"); return exit_status; }
+ g_dbus_connection_set_exit_on_close(bus, FALSE);
+ if (!supports_hold_session()) { g_object_unref(bus); return exit_status; }
+ GVariant *started = g_dbus_connection_call_sync(bus, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "StartServiceByName", g_variant_new("(su)", PORTAL, 0), G_VARIANT_TYPE("(u)"), G_DBUS_CALL_FLAGS_NONE, 10000, NULL, NULL);
+ if (started) g_variant_unref(started);
+ GVariant *reply = g_dbus_connection_call_sync(bus, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetNameOwner", g_variant_new("(s)", PORTAL), G_VARIANT_TYPE("(s)"), G_DBUS_CALL_FLAGS_NONE, 5000, NULL, &error);
+ if (!reply) { g_clear_error(&error); finish("unavailable"); g_object_unref(bus); return exit_status; }
+ g_variant_get(reply, "(s)", &owner); g_variant_unref(reply);
+ sender_component = g_strdup(g_dbus_connection_get_unique_name(bus) + 1);
+ for (char *p = sender_component; *p; ++p) if (*p == '.') *p = '_';
+ char *session_token = token(), *handle = token();
+ session_path = g_strdup_printf(ROOT "/session/%s/%s", sender_component, session_token);
+ loop = g_main_loop_new(NULL, FALSE);
+ g_dbus_connection_signal_subscribe(bus, owner, REQUEST, "Response", NULL, NULL, G_DBUS_SIGNAL_FLAGS_NONE, response, NULL, NULL);
+ g_dbus_connection_signal_subscribe(bus, owner, SHORTCUTS, "Activated", ROOT, NULL, G_DBUS_SIGNAL_FLAGS_NONE, shortcut_event, NULL, NULL);
+ g_dbus_connection_signal_subscribe(bus, owner, SHORTCUTS, "Deactivated", ROOT, NULL, G_DBUS_SIGNAL_FLAGS_NONE, shortcut_event, NULL, NULL);
+ g_dbus_connection_signal_subscribe(bus, owner, SHORTCUTS, "ShortcutsChanged", ROOT, NULL, G_DBUS_SIGNAL_FLAGS_NONE, shortcuts_changed, NULL, NULL);
+ g_dbus_connection_signal_subscribe(bus, owner, SESSION, "Closed", session_path, NULL, G_DBUS_SIGNAL_FLAGS_NONE, session_closed, NULL, NULL);
+ g_dbus_connection_signal_subscribe(bus, "org.freedesktop.DBus", "org.freedesktop.DBus", "NameOwnerChanged", "/org/freedesktop/DBus", PORTAL, G_DBUS_SIGNAL_FLAGS_NONE, owner_changed, NULL, NULL);
+ g_signal_connect(bus, "closed", G_CALLBACK(connection_closed), NULL);
+ g_unix_signal_add(SIGTERM, terminated, NULL); g_unix_signal_add(SIGINT, terminated, NULL);
+ g_unix_fd_add(STDIN_FILENO, G_IO_IN | G_IO_HUP | G_IO_ERR, stdin_ready, NULL);
+ deadline = g_timeout_add_seconds(120, expired, NULL);
+ if (register_app() && call_request("CreateSession", g_variant_new("(@a{sv})", options(handle, session_token)), handle)) g_main_loop_run(loop);
+ if (request_path) g_dbus_connection_call(bus, owner, request_path, REQUEST, "Close", NULL, NULL, G_DBUS_CALL_FLAGS_NONE, 1000, NULL, NULL, NULL);
+ if (session_path) g_dbus_connection_call(bus, owner, session_path, SESSION, "Close", NULL, NULL, G_DBUS_CALL_FLAGS_NONE, 1000, NULL, NULL, NULL);
+ g_dbus_connection_flush_sync(bus, NULL, NULL);
+ g_free(session_token); g_free(handle); g_free(owner); g_free(sender_component); g_free(session_path); g_free(request_path);
+ g_main_loop_unref(loop); g_object_unref(bus);
+ return exit_status;
+}
diff --git a/native/global-shortcuts-portal/mock.c b/native/global-shortcuts-portal/mock.c
new file mode 100644
index 00000000..de47e514
--- /dev/null
+++ b/native/global-shortcuts-portal/mock.c
@@ -0,0 +1,146 @@
+/* Test-only D-Bus portal fixture; never bundled in application packages. */
+#include
+#include
+#include
+#define ROOT "/org/freedesktop/portal/desktop"
+#define IFACE "org.freedesktop.portal.GlobalShortcuts"
+static GDBusConnection *bus;
+static char *session, *request, *client;
+static char *registered_client;
+static const char *mode;
+static gboolean creating;
+static GMainLoop *loop;
+static guint registration;
+static const char xml[] =
+""
+""
+""
+""
+"";
+static void emit_event(const char *signal, const char *target, const char *id) {
+ GVariantBuilder values; g_variant_builder_init(&values, G_VARIANT_TYPE_VARDICT);
+ g_dbus_connection_emit_signal(bus, client, ROOT, IFACE, signal,
+ g_variant_new("(ost@a{sv})", target, id, (guint64)1, g_variant_builder_end(&values)), NULL);
+}
+static gboolean finish_events(gpointer data) {
+ (void)data;
+ if (!strcmp(mode, "shortcuts-changed")) {
+ GVariantBuilder shortcuts; g_variant_builder_init(&shortcuts, G_VARIANT_TYPE("a(sa{sv})"));
+ g_dbus_connection_emit_signal(bus, client, ROOT, IFACE, "ShortcutsChanged", g_variant_new("(o@a(sa{sv}))", session, g_variant_builder_end(&shortcuts)), NULL);
+ } else if (!strcmp(mode, "owner-lost")) {
+ g_dbus_connection_call_sync(bus, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "ReleaseName", g_variant_new("(s)", "org.freedesktop.portal.Desktop"), NULL, G_DBUS_CALL_FLAGS_NONE, 1000, NULL, NULL);
+ } else {
+ GVariantBuilder values; g_variant_builder_init(&values, G_VARIANT_TYPE_VARDICT);
+ g_dbus_connection_emit_signal(bus, client, session, "org.freedesktop.portal.Session", "Closed", g_variant_new("(@a{sv})", g_variant_builder_end(&values)), NULL);
+ }
+ return G_SOURCE_REMOVE;
+}
+static gboolean events(gpointer data) {
+ (void)data;
+ GDBusConnection *spoof = g_dbus_connection_new_for_address_sync(g_getenv("DBUS_SESSION_BUS_ADDRESS"),
+ G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT | G_DBUS_CONNECTION_FLAGS_MESSAGE_BUS_CONNECTION, NULL, NULL, NULL);
+ if (spoof) {
+ GVariantBuilder values; g_variant_builder_init(&values, G_VARIANT_TYPE_VARDICT);
+ g_dbus_connection_emit_signal(spoof, client, ROOT, IFACE, "Activated",
+ g_variant_new("(ost@a{sv})", session, "dictation", (guint64)1, g_variant_builder_end(&values)), NULL);
+ g_dbus_connection_flush_sync(spoof, NULL, NULL); g_dbus_connection_close_sync(spoof, NULL, NULL); g_object_unref(spoof);
+ }
+ emit_event("Activated", ROOT "/session/stale/other", "dictation");
+ emit_event("Activated", session, "wrong-id");
+ emit_event("Deactivated", session, "dictation");
+ emit_event("Activated", session, "dictation");
+ emit_event("Activated", session, "dictation");
+ emit_event("Deactivated", session, "dictation");
+ emit_event("Deactivated", session, "dictation");
+ if (strcmp(mode, "await-close") && strcmp(mode, "await-terminate")) g_timeout_add(30, finish_events, NULL);
+ return G_SOURCE_REMOVE;
+}
+static gboolean respond(gpointer data) {
+ (void)data;
+ GVariantBuilder values; g_variant_builder_init(&values, G_VARIANT_TYPE_VARDICT);
+ guint status = 0;
+ if (creating) {
+ g_variant_builder_add(&values, "{sv}", "session_handle", g_variant_new_string(!strcmp(mode, "wrong-session") ? ROOT "/session/wrong/path" : session));
+ } else {
+ if (!strcmp(mode, "cancelled")) status = 1;
+ GVariantBuilder shortcuts, properties;
+ g_variant_builder_init(&shortcuts, G_VARIANT_TYPE("a(sa{sv})"));
+ g_variant_builder_init(&properties, G_VARIANT_TYPE_VARDICT);
+ g_variant_builder_add(&properties, "{sv}", "trigger_description", g_variant_new_string("Ctrl+\"D\""));
+ if (strcmp(mode, "absent-binding")) g_variant_builder_add(&shortcuts, "(s@a{sv})", "dictation", g_variant_builder_end(&properties));
+ else g_variant_builder_clear(&properties);
+ g_variant_builder_add(&values, "{sv}", "shortcuts", g_variant_builder_end(&shortcuts));
+ }
+ g_dbus_connection_emit_signal(bus, client, request, "org.freedesktop.portal.Request", "Response",
+ g_variant_new("(u@a{sv})", status, g_variant_builder_end(&values)), NULL);
+ if (!creating && status == 0 && strcmp(mode, "absent-binding")) g_timeout_add(30, events, NULL);
+ return G_SOURCE_REMOVE;
+}
+static void method(GDBusConnection *connection, const gchar *sender, const gchar *object,
+ const gchar *interface, const gchar *name, GVariant *parameters, GDBusMethodInvocation *invocation, gpointer data) {
+ (void)connection; (void)object; (void)interface; (void)data;
+ if (!strcmp(name, "Close")) { puts("closed"); fflush(stdout); g_dbus_method_invocation_return_value(invocation, NULL); return; }
+ if (!strcmp(name, "Register")) {
+ const char *app_id; GVariant *values;
+ g_variant_get(parameters, "(&s@a{sv})", &app_id, &values);
+ gboolean valid = !strcmp(app_id, "com.sambitcreate.aiden-agent") && !g_variant_n_children(values) && !registered_client;
+ g_variant_unref(values);
+ puts("register"); fflush(stdout);
+ if (!valid || !strcmp(mode, "registry-rejected")) {
+ g_dbus_method_invocation_return_dbus_error(invocation, "org.freedesktop.portal.Error.NotAllowed", "Registration rejected"); return;
+ }
+ if (!strcmp(mode, "registry-unknown-interface") || !strcmp(mode, "registry-unknown-method")) {
+ g_dbus_method_invocation_return_dbus_error(invocation,
+ !strcmp(mode, "registry-unknown-interface") ? "org.freedesktop.DBus.Error.UnknownInterface" : "org.freedesktop.DBus.Error.UnknownMethod", "Legacy portal"); return;
+ }
+ registered_client = g_strdup(sender);
+ g_dbus_method_invocation_return_value(invocation, NULL); return;
+ }
+ puts("portal-call"); fflush(stdout);
+ if ((!registered_client || strcmp(sender, registered_client)) &&
+ strcmp(mode, "registry-unknown-interface") && strcmp(mode, "registry-unknown-method")) {
+ g_dbus_method_invocation_return_dbus_error(invocation, "org.freedesktop.portal.Error.NotAllowed", "Register first"); return;
+ }
+ GVariant *options;
+ creating = !strcmp(name, "CreateSession");
+ if (creating) g_variant_get(parameters, "(@a{sv})", &options);
+ else { const char *target, *parent; GVariant *shortcuts; g_variant_get(parameters, "(&o@a(sa{sv})&s@a{sv})", &target, &shortcuts, &parent, &options); g_variant_unref(shortcuts); }
+ const char *handle, *session_token;
+ g_variant_lookup(options, "handle_token", "&s", &handle);
+ char *component = g_strdup(sender + 1); for (char *p = component; *p; ++p) if (*p == '.') *p = '_';
+ g_free(request); request = g_strdup_printf(ROOT "/request/%s/%s", component, handle);
+ if (creating) {
+ g_variant_lookup(options, "session_handle_token", "&s", &session_token);
+ g_free(session); session = g_strdup_printf(ROOT "/session/%s/%s", component, session_token);
+ g_free(client); client = g_strdup(sender);
+ if (registration) g_dbus_connection_unregister_object(bus, registration);
+ GDBusNodeInfo *info = g_dbus_node_info_new_for_xml(xml, NULL);
+ static const GDBusInterfaceVTable vtable = { .method_call = method };
+ registration = g_dbus_connection_register_object(bus, session, info->interfaces[1], &vtable, NULL, NULL, NULL);
+ g_dbus_node_info_unref(info);
+ }
+ g_dbus_method_invocation_return_value(invocation, g_variant_new("(o)", request));
+ g_variant_unref(options); g_free(component);
+ g_idle_add(respond, NULL);
+}
+int main(int argc, char **argv) {
+ mode = argc > 1 ? argv[1] : "success";
+ bus = g_bus_get_sync(G_BUS_TYPE_SESSION, NULL, NULL);
+ if (!bus) return 1;
+ GDBusNodeInfo *info = g_dbus_node_info_new_for_xml(xml, NULL);
+ static const GDBusInterfaceVTable vtable = { .method_call = method };
+ g_dbus_connection_register_object(bus, ROOT, info->interfaces[0], &vtable, NULL, NULL, NULL);
+ g_dbus_connection_register_object(bus, ROOT, info->interfaces[2], &vtable, NULL, NULL, NULL);
+ GVariant *reply = g_dbus_connection_call_sync(bus, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "RequestName", g_variant_new("(su)", "org.freedesktop.portal.Desktop", 0), NULL, G_DBUS_CALL_FLAGS_NONE, 1000, NULL, NULL);
+ if (!reply) return 2;
+ if (!strcmp(mode, "gnome-running")) {
+ GVariant *shell = g_dbus_connection_call_sync(bus, "org.freedesktop.DBus", "/org/freedesktop/DBus",
+ "org.freedesktop.DBus", "RequestName", g_variant_new("(su)", "org.gnome.Shell", 0),
+ NULL, G_DBUS_CALL_FLAGS_NONE, 1000, NULL, NULL);
+ if (!shell) return 2;
+ g_variant_unref(shell);
+ }
+ puts("ready"); fflush(stdout);
+ loop = g_main_loop_new(NULL, FALSE); g_main_loop_run(loop);
+ return 0;
+}
diff --git a/native/linux-managed-payload/.gitignore b/native/linux-managed-payload/.gitignore
new file mode 100644
index 00000000..b83d2226
--- /dev/null
+++ b/native/linux-managed-payload/.gitignore
@@ -0,0 +1 @@
+/target/
diff --git a/native/linux-managed-payload/Cargo.lock b/native/linux-managed-payload/Cargo.lock
new file mode 100644
index 00000000..07919656
--- /dev/null
+++ b/native/linux-managed-payload/Cargo.lock
@@ -0,0 +1,192 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aiden-managed-payload"
+version = "0.1.0"
+dependencies = [
+ "libc",
+ "serde",
+ "serde_json",
+ "sha2",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/native/linux-managed-payload/Cargo.toml b/native/linux-managed-payload/Cargo.toml
new file mode 100644
index 00000000..67766953
--- /dev/null
+++ b/native/linux-managed-payload/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "aiden-managed-payload"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+[dependencies]
+libc = "=0.2.186"
+serde = { version = "=1.0.229", features = ["derive"] }
+serde_json = "=1.0.149"
+sha2 = "=0.10.9"
diff --git a/native/linux-managed-payload/README.md b/native/linux-managed-payload/README.md
new file mode 100644
index 00000000..0b3f3ddf
--- /dev/null
+++ b/native/linux-managed-payload/README.md
@@ -0,0 +1,77 @@
+# Local managed payload staging
+
+This Linux-only crate creates a private, root-managed generation. It does not
+verify release attestations, activate or execute a payload, install SELinux
+policy, enable Computer Use, or make files kernel-immutable. Host root, the
+kernel, host namespaces and other root writers remain trusted.
+
+Build with `cargo build --locked --release`. The current acceptance host uses
+Fedora cargo/rust 1.98.1. Runtime requires SELinux enforcing, procfs, working
+extended-attribute APIs, libselinux, `openat2` resolution flags and `renameat2`
+no-replace; unsupported host behavior fails without a compatibility fallback.
+
+```
+aiden-managed-payload stage --store /var/lib/aiden-staging/store --source /absolute/extracted/payload --inventory /var/lib/aiden-staging/inventory.json --approval /var/lib/aiden-staging/approval.json
+```
+
+Create the store and approval area explicitly through trusted administration.
+The store must have mode 0700. Its ancestors and approval/inventory ancestors
+must be root:root, lack group/world write and have no extended/default ACL.
+The inventory uses the Phase 16 schema and must be external to both payload and
+store. Store and source must not overlap. The strict approval record is:
+
+```json
+{"schemaVersion":1,"kind":"local-staging-only","inventorySha256":"","packageSha256":"<64 lowercase hex diagnostic operator value>"}
+```
+
+The package digest is diagnostic: this component establishes no cryptographic
+relationship between that package and the inventory. This local-only approval
+must never satisfy a future authenticated-release admission gate.
+
+The destination is `STORE/INVENTORY_SHA256/payload`, with separate inventory,
+approval and receipt files inside its root-only generation container. All
+payload modes and contents match the approved inventory. Group/world write,
+setuid/setgid/sticky modes, unreadable files and unsearchable directories are
+outside the initial profile. No special chrome-sandbox permission exception is
+made. ACLs, capabilities and other unsupported xattrs are rejected, not copied.
+New objects receive the store's exact SELinux context through a scoped
+libselinux fscreate setting and readback, preventing filename transitions such
+as Fedora's `shared` directory rule. The guard requires an initially default
+creation context, restores and reads back the default before publication and on
+error, and aborts if restoration fails. No existing inode is relabeled. This
+staging label is not executable identity. A complete destination traversal and
+rehash precede an exclusive atomic rename and parent-directory fsync. No active
+pointer exists.
+
+Caller-controlled traversal uses descriptor-relative `openat2` with BENEATH,
+NO_SYMLINKS, NO_MAGICLINKS and NO_XDEV below established roots. Source objects
+are first pinned/classified with O_PATH, preventing device or FIFO I/O. Reading
+uses an intentional exception: reopening our retained numeric descriptor through
+verified `/proc/self/fd`, comparing metadata before/after. Caller paths are never
+used in that exception. Copying into fresh inodes means retained writable source
+descriptors do not become handles to the staged destination.
+
+Failures before rename remove only the newly created private temporary tree.
+Cleanup errors are reported. If rename succeeds but parent fsync fails, the CLI
+reports uncertain durability and leaves the private generation in place; it does
+not claim success or delete a published generation.
+
+Run ordinary unprivileged tests with `cargo test --locked`. The registered npm
+wrapper runs these on Linux, fails if cargo is unavailable, and skips on macOS.
+The ignored root integration is explicit, runs only in an authorized disposable
+Fedora host, and never invokes sudo itself:
+
+```
+# Build as the normal development user, then invoke the built library test
+# executable as trusted root with --ignored --nocapture.
+cargo test --locked --lib --no-run
+```
+
+It uses exclusive `/var/lib/aiden-managed-payload-tests-PID-N` directories and
+cleans them after each scenario. The build needs libselinux development files
+(`libselinux-devel` on Fedora, `libselinux1-dev` on Ubuntu). The integration host
+also needs `setfacl` from Fedora's `acl` package. Tests cover fresh
+inode/retained writer separation, pinned path swaps, actual device classification
+without IN_OPEN, malformed inventory/approval, ACL/capability/link/FIFO
+rejection, ownership/mode and overlap checks, exclusive publication and injected
+prepublication failures with cleanup.
diff --git a/native/linux-managed-payload/src/inventory.rs b/native/linux-managed-payload/src/inventory.rs
new file mode 100644
index 00000000..c0310796
--- /dev/null
+++ b/native/linux-managed-payload/src/inventory.rs
@@ -0,0 +1,134 @@
+use serde::Deserialize;
+use sha2::{Digest, Sha256};
+use std::collections::BTreeMap;
+
+pub type Result = std::result::Result>;
+pub const MANIFEST_BYTES: usize = 32 * 1024 * 1024;
+pub const FILE_BYTES: u64 = 8 * 1024 * 1024 * 1024;
+pub const TOTAL_BYTES: u64 = 64 * 1024 * 1024 * 1024;
+pub const ENTRIES: usize = 100_000;
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields, rename_all = "camelCase")]
+pub struct Inventory {
+ pub schema_version: u32,
+ pub hash_algorithm: String,
+ pub entries: Vec,
+}
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct Entry {
+ pub path: String,
+ #[serde(rename = "type")]
+ pub kind: String,
+ pub mode: u32,
+ pub size: Option,
+ pub sha256: Option,
+}
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields, rename_all = "camelCase")]
+pub struct Approval {
+ pub schema_version: u32,
+ pub kind: String,
+ pub inventory_sha256: String,
+ /// Diagnostic operator input; this code does NOT verify package provenance
+ /// or establish a cryptographic relationship to the staged payload.
+ pub package_sha256: String,
+}
+pub fn hash(bytes: &[u8]) -> String {
+ format!("{:x}", Sha256::digest(bytes))
+}
+pub fn digest(value: &str) -> bool {
+ value.len() == 64
+ && value
+ .bytes()
+ .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
+}
+pub fn relative(value: &str, root: bool) -> bool {
+ if root && value == "." {
+ return true;
+ }
+ !value.is_empty()
+ && value.len() <= 4096
+ && !value
+ .chars()
+ .any(|c| c < ' ' || c == '\u{7f}' || c == '\\' || c == '\u{fffd}')
+ && value.split('/').count() <= 64
+ && value
+ .split('/')
+ .all(|p| !p.is_empty() && p != "." && p != "..")
+}
+pub fn parse(bytes: &[u8]) -> Result {
+ if bytes.len() > MANIFEST_BYTES {
+ return Err("inventory exceeds byte bound".into());
+ }
+ let value: Inventory = serde_json::from_slice(bytes)?;
+ // Inspect field presence too: Option alone would accept explicit null fields
+ // on directories, which is outside the Phase 16 schema.
+ let fields: serde_json::Value = serde_json::from_slice(bytes)?;
+ if value.schema_version != 1
+ || value.hash_algorithm != "sha256"
+ || value.entries.is_empty()
+ || value.entries.len() > ENTRIES
+ {
+ return Err("unsupported inventory".into());
+ }
+ let mut seen = BTreeMap::new();
+ let mut previous: Option<&str> = None;
+ let mut total = 0u64;
+ for (i, entry) in value.entries.iter().enumerate() {
+ if !relative(&entry.path, i == 0)
+ || (i == 0 && (entry.path != "." || entry.kind != "directory"))
+ || seen.contains_key(entry.path.as_str())
+ || (i > 1
+ && previous
+ .is_some_and(|p| p.encode_utf16().cmp(entry.path.encode_utf16()).is_ge()))
+ {
+ return Err("invalid inventory path/order".into());
+ }
+ if entry.mode > 0o7777 {
+ return Err("invalid permission mode".into());
+ }
+ if i > 0 {
+ let parent = entry.path.rsplit_once('/').map(|v| v.0).unwrap_or(".");
+ if seen.get(parent) != Some(&"directory") {
+ return Err("missing directory parent".into());
+ }
+ }
+ let count = fields["entries"][i]
+ .as_object()
+ .ok_or("entry must be object")?
+ .len();
+ match entry.kind.as_str() {
+ "directory" if count == 3 => {}
+ "file" if count == 5 => {
+ let size = entry.size.ok_or("missing size")?;
+ if size > FILE_BYTES || !entry.sha256.as_deref().is_some_and(digest) {
+ return Err("invalid file metadata".into());
+ }
+ total = total.checked_add(size).ok_or("total overflow")?;
+ if total > TOTAL_BYTES {
+ return Err("payload too large".into());
+ }
+ }
+ _ => return Err("invalid entry type/fields".into()),
+ }
+ previous = Some(&entry.path);
+ seen.insert(entry.path.as_str(), entry.kind.as_str());
+ }
+ Ok(value)
+}
+pub fn approval(bytes: &[u8], inventory_bytes: &[u8]) -> Result {
+ if bytes.len() > 4096 {
+ return Err("approval exceeds byte bound".into());
+ }
+ let value: Approval = serde_json::from_slice(bytes)?;
+ if value.schema_version != 1
+ || value.kind != "local-staging-only"
+ || !digest(&value.package_sha256)
+ || value.inventory_sha256 != hash(inventory_bytes)
+ {
+ return Err("invalid local-only approval or inventory digest".into());
+ }
+ Ok(value)
+}
diff --git a/native/linux-managed-payload/src/lib.rs b/native/linux-managed-payload/src/lib.rs
new file mode 100644
index 00000000..5680a311
--- /dev/null
+++ b/native/linux-managed-payload/src/lib.rs
@@ -0,0 +1,7 @@
+//! Local operator-approved staging only. No release authentication, execution,
+//! activation, kernel immutability, or Computer Use admission is implemented.
+pub mod inventory;
+#[cfg(target_os = "linux")]
+pub mod stage;
+#[cfg(target_os = "linux")]
+mod trusted_path;
diff --git a/native/linux-managed-payload/src/main.rs b/native/linux-managed-payload/src/main.rs
new file mode 100644
index 00000000..726745cd
--- /dev/null
+++ b/native/linux-managed-payload/src/main.rs
@@ -0,0 +1,34 @@
+fn main() {
+ #[cfg(target_os = "linux")]
+ {
+ let args: Vec = std::env::args().collect();
+ if args.len() != 10
+ || args[1] != "stage"
+ || args[2] != "--store"
+ || args[4] != "--source"
+ || args[6] != "--inventory"
+ || args[8] != "--approval"
+ {
+ eprintln!("usage: aiden-managed-payload stage --store ABS --source ABS --inventory ABS --approval ABS");
+ std::process::exit(2);
+ }
+ let request = aiden_managed_payload::stage::Request {
+ store: &args[3],
+ source: &args[5],
+ inventory: &args[7],
+ approval: &args[9],
+ };
+ match aiden_managed_payload::stage::stage(&request) {
+ Ok(receipt) => println!("{}", receipt),
+ Err(err) => {
+ eprintln!("managed payload staging failed: {err}");
+ std::process::exit(1);
+ }
+ }
+ }
+ #[cfg(not(target_os = "linux"))]
+ {
+ eprintln!("Linux only");
+ std::process::exit(2);
+ }
+}
diff --git a/native/linux-managed-payload/src/stage.rs b/native/linux-managed-payload/src/stage.rs
new file mode 100644
index 00000000..9f74ec8b
--- /dev/null
+++ b/native/linux-managed-payload/src/stage.rs
@@ -0,0 +1,671 @@
+use crate::{
+ inventory::{self, Inventory, Result},
+ trusted_path as fs,
+};
+use sha2::{Digest, Sha256};
+use std::{
+ fs::File,
+ io::{Read, Write},
+ os::unix::fs::MetadataExt,
+};
+
+pub struct Request<'a> {
+ pub store: &'a str,
+ pub source: &'a str,
+ pub inventory: &'a str,
+ pub approval: &'a str,
+}
+fn external(left: &str, right: &str) -> bool {
+ left != right && !right.starts_with(&format!("{}/", left.trim_end_matches('/')))
+}
+fn trusted_input(path: &str, maximum: usize) -> Result> {
+ let (parent, base) = path.rsplit_once('/').ok_or("absolute input required")?;
+ if base.is_empty() || base == "." || base == ".." {
+ return Err("invalid input basename".into());
+ }
+ let parent = fs::absolute(if parent.is_empty() { "/" } else { parent }, true)?;
+ let mut file = fs::read_at(&parent, base)?;
+ fs::protected(&file, false)?;
+ fs::payload_metadata(&file)?;
+ let before = file.metadata()?;
+ if before.len() > maximum as u64 {
+ return Err("input exceeds byte bound".into());
+ }
+ let mut bytes = Vec::new();
+ (&mut file)
+ .take(maximum as u64 + 1)
+ .read_to_end(&mut bytes)?;
+ if bytes.len() > maximum
+ || !same(&before, &file.metadata()?)
+ || bytes.len() as u64 != before.len()
+ {
+ return Err("input changed during read".into());
+ }
+ Ok(bytes)
+}
+pub(crate) fn same(a: &std::fs::Metadata, b: &std::fs::Metadata) -> bool {
+ a.dev() == b.dev()
+ && a.ino() == b.ino()
+ && a.mode() == b.mode()
+ && a.nlink() == b.nlink()
+ && a.uid() == b.uid()
+ && a.gid() == b.gid()
+ && a.len() == b.len()
+ && a.mtime() == b.mtime()
+ && a.mtime_nsec() == b.mtime_nsec()
+ && a.ctime() == b.ctime()
+ && a.ctime_nsec() == b.ctime_nsec()
+}
+fn safe_modes(inventory: &Inventory) -> Result<()> {
+ for entry in &inventory.entries {
+ // First staging profile deliberately excludes setuid/setgid/sticky and
+ // writable-by-others payloads. No chrome-sandbox privilege exception.
+ if entry.mode & !0o755 != 0
+ || entry.mode & 0o400 == 0
+ || (entry.kind == "directory" && entry.mode & 0o100 == 0)
+ {
+ return Err("unsupported or unsafe staging mode".into());
+ }
+ }
+ Ok(())
+}
+fn file_hash(
+ mut file: &File,
+ expected_size: u64,
+ mut destination: Option<&mut File>,
+) -> Result {
+ let before = file.metadata()?;
+ if !before.is_file() || before.nlink() != 1 || before.len() != expected_size {
+ return Err("unexpected regular file/size/link count".into());
+ }
+ let mut total = 0u64;
+ let mut hash = Sha256::new();
+ let mut bytes = vec![0u8; 1024 * 1024];
+ loop {
+ let n = file.read(&mut bytes)?;
+ if n == 0 {
+ break;
+ }
+ total += n as u64;
+ if total > expected_size {
+ return Err("source grew during copy".into());
+ }
+ hash.update(&bytes[..n]);
+ if let Some(output) = destination.as_mut() {
+ output.write_all(&bytes[..n])?;
+ }
+ }
+ if total != expected_size || !same(&before, &file.metadata()?) {
+ return Err("file changed during copy/hash".into());
+ }
+ Ok(format!("{:x}", hash.finalize()))
+}
+fn walk(root: &File) -> Result> {
+ fn visit(root: &File, current: &str, results: &mut Vec, depth: usize) -> Result<()> {
+ if depth > 64 || results.len() >= inventory::ENTRIES {
+ return Err("tree bounds exceeded".into());
+ }
+ results.push(current.to_owned());
+ let object = fs::read_at(root, current)?;
+ let before = object.metadata()?;
+ if before.is_dir() {
+ for child in fs::children(&object)? {
+ let name = if current == "." {
+ child
+ } else {
+ format!("{current}/{child}")
+ };
+ if !inventory::relative(&name, false) {
+ return Err("invalid filesystem path".into());
+ }
+ visit(root, &name, results, depth + 1)?;
+ }
+ if !same(&before, &object.metadata()?) {
+ return Err("directory changed during traversal".into());
+ }
+ } else if !before.is_file() || before.nlink() != 1 {
+ return Err("unsupported tree entry".into());
+ }
+ Ok(())
+ }
+ let mut paths = Vec::new();
+ visit(root, ".", &mut paths, 0)?;
+ paths[1..].sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
+ Ok(paths)
+}
+fn verify_tree(root: &File, inventory: &Inventory, expected_context: Option<&[u8]>) -> Result<()> {
+ let actual = walk(root)?;
+ if actual
+ != inventory
+ .entries
+ .iter()
+ .map(|e| e.path.clone())
+ .collect::>()
+ {
+ return Err("tree entries differ from inventory".into());
+ }
+ for entry in &inventory.entries {
+ let file = fs::read_at(root, &entry.path)?;
+ let meta = file.metadata()?;
+ if meta.mode() & 0o7777 != entry.mode || meta.is_dir() != (entry.kind == "directory") {
+ return Err("tree mode/type mismatch".into());
+ }
+ let context = fs::payload_metadata(&file)?;
+ if let Some(expected) = expected_context {
+ fs::protected(&file, entry.kind == "directory")?;
+ if context != expected {
+ return Err(format!(
+ "unexpected destination staging context at {:?}: expected {:?}, actual {:?}",
+ entry.path,
+ String::from_utf8_lossy(expected),
+ String::from_utf8_lossy(&context)
+ )
+ .into());
+ }
+ }
+ if entry.kind == "file"
+ && file_hash(&file, entry.size.unwrap(), None)? != *entry.sha256.as_ref().unwrap()
+ {
+ return Err("tree content digest mismatch".into());
+ }
+ }
+ Ok(())
+}
+fn write_record(generation: &File, name: &str, bytes: &[u8], context: &[u8]) -> Result<()> {
+ let mut file = fs::open_at(
+ generation,
+ name,
+ libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL,
+ 0o600,
+ true,
+ )?;
+ file.write_all(bytes)?;
+ fs::chmod(&file, 0o400)?;
+ fs::protected(&file, false)?;
+ if fs::payload_metadata(&file)? != context {
+ return Err("unexpected record context".into());
+ }
+ file.sync_all()?;
+ Ok(())
+}
+/// Stage an operator-approved local generation. Caller must be trusted root in
+/// the host mount/user namespace. Host root, kernel and concurrent root writers
+/// remain trusted; this does not implement production provenance or admission.
+pub fn stage(request: &Request<'_>) -> Result {
+ stage_inner(request, |_| Ok(()))
+}
+
+fn stage_inner(
+ request: &Request<'_>,
+ checkpoint: impl Fn(&str) -> Result<()>,
+) -> Result {
+ if unsafe { libc::getuid() } != 0
+ || unsafe { libc::geteuid() } != 0
+ || unsafe { libc::getgid() } != 0
+ || unsafe { libc::getegid() } != 0
+ {
+ return Err("trusted host root execution required".into());
+ }
+ if std::fs::read_to_string("/sys/fs/selinux/enforce")?.trim() != "1" {
+ return Err("SELinux enforcing required for staging metadata profile".into());
+ }
+ for other in [request.store, request.inventory, request.approval] {
+ if !external(request.source, other) {
+ return Err("store/inputs must be external to source".into());
+ }
+ }
+ if !external(request.store, request.source)
+ || !external(request.store, request.inventory)
+ || !external(request.store, request.approval)
+ {
+ return Err("source/inputs must be external to generation store".into());
+ }
+ let store = fs::absolute(request.store, true)?;
+ if store.metadata()?.mode() & 0o7777 != 0o700 {
+ return Err("store must be mode 0700".into());
+ }
+ let context = fs::payload_metadata(&store)?;
+ let inventory_bytes = trusted_input(request.inventory, inventory::MANIFEST_BYTES)?;
+ let approval_bytes = trusted_input(request.approval, 4096)?;
+ let inventory = inventory::parse(&inventory_bytes)?;
+ let approval = inventory::approval(&approval_bytes, &inventory_bytes)?;
+ safe_modes(&inventory)?;
+ let source = fs::absolute(request.source, false)?;
+ let source_meta = source.metadata()?;
+ let store_meta = store.metadata()?;
+ if source_meta.dev() == store_meta.dev() && source_meta.ino() == store_meta.ino() {
+ return Err("source and store alias the same directory".into());
+ }
+ verify_tree(&source, &inventory, None)?;
+ let mut random = [0u8; 16];
+ File::open("/dev/urandom")?.read_exact(&mut random)?;
+ let temporary = format!(".staging-{}", inventory::hash(&random));
+ let generation_name = approval.inventory_sha256.clone();
+ let mut creation_context = fs::CreationContext::enter(&context)?;
+ let generation = fs::mkdir(&store, &temporary)?;
+ let mut published = false;
+ let result = (|| {
+ fs::chmod(&generation, 0o700)?;
+ fs::protected(&generation, true)?;
+ if fs::payload_metadata(&generation)? != context {
+ return Err("unexpected generation context".into());
+ }
+ let payload = fs::mkdir(&generation, "payload")?;
+ for entry in inventory.entries.iter().skip(1) {
+ let (parent, base) = entry.path.rsplit_once('/').unwrap_or((".", &entry.path));
+ let parent_fd = fs::open_at(
+ &payload,
+ parent,
+ libc::O_RDONLY | libc::O_DIRECTORY,
+ 0,
+ true,
+ )?;
+ if entry.kind == "directory" {
+ fs::mkdir(&parent_fd, base)?;
+ } else {
+ let input = fs::read_at(&source, &entry.path)?;
+ if input.metadata()?.mode() & 0o7777 != entry.mode {
+ return Err("source mode changed".into());
+ }
+ fs::payload_metadata(&input)?;
+ let mut output = fs::open_at(
+ &parent_fd,
+ base,
+ libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL,
+ 0o600,
+ true,
+ )?;
+ if file_hash(&input, entry.size.unwrap(), Some(&mut output))?
+ != *entry.sha256.as_ref().unwrap()
+ {
+ return Err("copied bytes differ from approved digest".into());
+ }
+ fs::chmod(&output, entry.mode)?;
+ output.sync_all()?;
+ }
+ }
+ checkpoint("copied")?;
+ for entry in inventory
+ .entries
+ .iter()
+ .rev()
+ .filter(|e| e.kind == "directory")
+ {
+ let directory = fs::open_at(
+ &payload,
+ &entry.path,
+ libc::O_RDONLY | libc::O_DIRECTORY,
+ 0,
+ true,
+ )?;
+ fs::chmod(&directory, entry.mode)?;
+ directory.sync_all()?;
+ }
+ verify_tree(&payload, &inventory, Some(&context))?;
+ let receipt = serde_json::json!({"schemaVersion":1,"kind":"local-staging-only","generation":generation_name,
+ "inventorySha256":approval.inventory_sha256,"packageSha256Diagnostic":approval.package_sha256,
+ "entries":inventory.entries.len(),"stagingContext":String::from_utf8(context.clone())?.trim_end_matches('\0'),
+ "releaseAuthenticated":false,"runtimeAdmission":false,"kernelImmutable":false});
+ write_record(&generation, "inventory.json", &inventory_bytes, &context)?;
+ write_record(&generation, "approval.json", &approval_bytes, &context)?;
+ write_record(
+ &generation,
+ "receipt.json",
+ &serde_json::to_vec_pretty(&receipt)?,
+ &context,
+ )?;
+ generation.sync_all()?;
+ creation_context.reset()?;
+ checkpoint("before-publish")?;
+ fs::publish(&store, &temporary, &generation_name)?;
+ published = true;
+ // A failure here means the private generation exists but durability is
+ // uncertain. Never delete it or claim successful publication on error.
+ store
+ .sync_all()
+ .map_err(|e| format!("generation published but parent fsync failed: {e}"))?;
+ Ok(receipt)
+ })();
+ if result.is_err() && !published {
+ if let Err(cleanup) =
+ fs::remove_tree(&store, &temporary).and_then(|_| Ok(store.sync_all()?))
+ {
+ return Err(format!(
+ "{}; cleanup failed for {temporary}: {cleanup}",
+ result.unwrap_err()
+ )
+ .into());
+ }
+ }
+ result
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::{
+ os::unix::{
+ fs::{symlink, PermissionsExt},
+ io::AsRawFd,
+ },
+ path::PathBuf,
+ sync::atomic::{AtomicUsize, Ordering},
+ };
+ static NEXT: AtomicUsize = AtomicUsize::new(0);
+ struct Fixture {
+ base: PathBuf,
+ store: String,
+ source: String,
+ inventory: String,
+ approval: String,
+ }
+ impl Fixture {
+ fn new() -> Self {
+ let base = PathBuf::from(format!(
+ "/var/lib/aiden-managed-payload-tests-{}-{}",
+ std::process::id(),
+ NEXT.fetch_add(1, Ordering::SeqCst)
+ ));
+ std::fs::create_dir(&base).unwrap();
+ std::fs::set_permissions(&base, std::fs::Permissions::from_mode(0o700)).unwrap();
+ let f = Self {
+ store: base.join("store").to_str().unwrap().into(),
+ source: base.join("source").to_str().unwrap().into(),
+ inventory: base.join("inventory.json").to_str().unwrap().into(),
+ approval: base.join("approval.json").to_str().unwrap().into(),
+ base,
+ };
+ for path in [&f.store, &f.source] {
+ std::fs::create_dir(path).unwrap();
+ std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap();
+ }
+ std::fs::write(format!("{}/app", f.source), b"approved bytes").unwrap();
+ std::fs::set_permissions(
+ format!("{}/app", f.source),
+ std::fs::Permissions::from_mode(0o644),
+ )
+ .unwrap();
+ f.records(0o644);
+ f
+ }
+ fn records(&self, mode: u32) {
+ let inventory = serde_json::json!({"schemaVersion":1,"hashAlgorithm":"sha256","entries":[
+ {"path":".","type":"directory","mode":448},{"path":"app","type":"file","mode":mode,"size":14,"sha256":inventory::hash(b"approved bytes")}]});
+ let bytes = serde_json::to_vec(&inventory).unwrap();
+ std::fs::write(&self.inventory, &bytes).unwrap();
+ std::fs::write(&self.approval,serde_json::to_vec(&serde_json::json!({"schemaVersion":1,"kind":"local-staging-only","inventorySha256":inventory::hash(&bytes),"packageSha256":"a".repeat(64)})).unwrap()).unwrap();
+ }
+ fn request(&self) -> Request<'_> {
+ Request {
+ store: &self.store,
+ source: &self.source,
+ inventory: &self.inventory,
+ approval: &self.approval,
+ }
+ }
+ fn empty(&self) {
+ assert_eq!(std::fs::read_dir(&self.store).unwrap().count(), 0);
+ }
+ }
+ impl Drop for Fixture {
+ fn drop(&mut self) {
+ std::fs::remove_dir_all(&self.base).unwrap();
+ }
+ }
+ fn attr(path: &str, key: &str, value: &[u8]) {
+ let path = std::ffi::CString::new(path).unwrap();
+ let key = std::ffi::CString::new(key).unwrap();
+ assert_eq!(
+ unsafe {
+ libc::setxattr(
+ path.as_ptr(),
+ key.as_ptr(),
+ value.as_ptr().cast(),
+ value.len(),
+ 0,
+ )
+ },
+ 0
+ );
+ }
+ #[test]
+ #[ignore = "explicit trusted-root Fedora SELinux integration; never invokes sudo"]
+ fn root_staging_integration() {
+ assert_eq!(unsafe { libc::geteuid() }, 0);
+ assert_eq!(
+ std::fs::read_to_string("/sys/fs/selinux/enforce")
+ .unwrap()
+ .trim(),
+ "1"
+ );
+ // The initial generation mkdir must roll back even before the outer
+ // staging closure exists, if its descriptor reopen fails.
+ let f = Fixture::new();
+ let store = fs::absolute(&f.store, true).unwrap();
+ let error = fs::mkdir_reopen_failure(&store, ".staging-injected").unwrap_err();
+ assert!(error
+ .to_string()
+ .contains("injected post-mkdir reopen failure"));
+ f.empty();
+ drop(f);
+ // Fresh destination inode remains independent of an already-open writer.
+ let f = Fixture::new();
+ let held = std::fs::OpenOptions::new()
+ .write(true)
+ .open(format!("{}/app", f.source))
+ .unwrap();
+ let receipt = stage_inner(&f.request(), |point| {
+ if point == "copied" {
+ (&held).write_all(b"hostile change")?;
+ held.sync_all()?;
+ }
+ Ok(())
+ })
+ .unwrap();
+ let output = PathBuf::from(&f.store)
+ .join(receipt["generation"].as_str().unwrap())
+ .join("payload/app");
+ assert_eq!(std::fs::read(&output).unwrap(), b"approved bytes");
+ assert_ne!(
+ held.metadata().unwrap().ino(),
+ std::fs::metadata(&output).unwrap().ino()
+ );
+ assert_eq!(receipt["releaseAuthenticated"], false);
+ assert_eq!(receipt["runtimeAdmission"], false);
+ drop(held);
+ drop(f);
+ // Own O_PATH pin survives a malicious pathname replacement. A fresh
+ // traversal rejects the replacement rather than following its target.
+ let f = Fixture::new();
+ let source = fs::absolute(&f.source, false).unwrap();
+ let pin = fs::open_at(&source, "app", libc::O_PATH, 0, true).unwrap();
+ std::fs::rename(format!("{}/app", f.source), f.base.join("retained")).unwrap();
+ symlink("/dev/zero", format!("{}/app", f.source)).unwrap();
+ let mut read = fs::read_pinned(&pin).unwrap();
+ let mut bytes = Vec::new();
+ read.read_to_end(&mut bytes).unwrap();
+ assert_eq!(bytes, b"approved bytes");
+ assert!(fs::read_at(&source, "app").is_err());
+ drop(f);
+ // A device is classified through O_PATH and never opened for I/O.
+ let f = Fixture::new();
+ let app = format!("{}/app", f.source);
+ std::fs::remove_file(&app).unwrap();
+ let name = std::ffi::CString::new(app.clone()).unwrap();
+ assert_eq!(
+ unsafe { libc::mknod(name.as_ptr(), libc::S_IFCHR | 0o600, libc::makedev(1, 5)) },
+ 0
+ );
+ let watch = unsafe { libc::inotify_init1(libc::IN_NONBLOCK | libc::IN_CLOEXEC) };
+ assert!(watch >= 0);
+ assert!(unsafe { libc::inotify_add_watch(watch, name.as_ptr(), libc::IN_OPEN) } >= 0);
+ assert!(stage(&f.request()).is_err());
+ let mut events = [0u8; 1024];
+ assert_eq!(
+ unsafe { libc::read(watch, events.as_mut_ptr().cast(), events.len()) },
+ -1
+ );
+ assert_eq!(
+ std::io::Error::last_os_error().raw_os_error(),
+ Some(libc::EAGAIN)
+ );
+ unsafe {
+ libc::close(watch);
+ }
+ f.empty();
+ drop(f);
+ // Lexical ancestor overlap in either direction cannot mutate the source.
+ let f = Fixture::new();
+ let nested = format!("{}/nested", f.source);
+ std::fs::create_dir(&nested).unwrap();
+ let mut request = f.request();
+ request.store = &nested;
+ assert!(stage(&request).is_err());
+ let nested = format!("{}/nested", f.store);
+ std::fs::create_dir(&nested).unwrap();
+ let mut request = f.request();
+ request.source = &nested;
+ assert!(stage(&request).is_err());
+ drop(f);
+ // Fedora filename transition for "shared" must not change the exact
+ // explicitly selected staging label, including nested directories.
+ let f = Fixture::new();
+ std::fs::create_dir(format!("{}/nested", f.source)).unwrap();
+ std::fs::create_dir(format!("{}/nested/shared", f.source)).unwrap();
+ for p in ["nested", "nested/shared"] {
+ std::fs::set_permissions(
+ format!("{}/{p}", f.source),
+ std::fs::Permissions::from_mode(0o755),
+ )
+ .unwrap();
+ }
+ let mut inventory: serde_json::Value =
+ serde_json::from_slice(&std::fs::read(&f.inventory).unwrap()).unwrap();
+ for p in ["nested", "nested/shared"] {
+ inventory["entries"]
+ .as_array_mut()
+ .unwrap()
+ .push(serde_json::json!({"path":p,"type":"directory","mode":493}));
+ }
+ let bytes = serde_json::to_vec(&inventory).unwrap();
+ std::fs::write(&f.inventory, &bytes).unwrap();
+ std::fs::write(&f.approval, serde_json::to_vec(&serde_json::json!({"schemaVersion":1,"kind":"local-staging-only","inventorySha256":inventory::hash(&bytes),"packageSha256":"a".repeat(64)})).unwrap()).unwrap();
+ stage(&f.request()).unwrap();
+ // A following invocation also proves the preceding guard reset, because
+ // entering with any preexisting creation context is rejected.
+ assert!(stage(&f.request())
+ .unwrap_err()
+ .to_string()
+ .contains("File exists"));
+ drop(f);
+ // Publication never replaces existing generations.
+ let f = Fixture::new();
+ stage(&f.request()).unwrap();
+ assert!(stage(&f.request()).is_err());
+ assert_eq!(std::fs::read_dir(&f.store).unwrap().count(), 1);
+ drop(f);
+ // Inject failures after materializing fresh files and just before rename.
+ for point in ["copied", "before-publish"] {
+ let f = Fixture::new();
+ assert!(stage_inner(&f.request(), |p| if p == point {
+ Err("injected I/O failure".into())
+ } else {
+ Ok(())
+ })
+ .is_err());
+ f.empty();
+ }
+ for attack in [
+ "source-link",
+ "source-hardlink",
+ "extra",
+ "bad-hash",
+ "unsafe-mode",
+ "setuid",
+ "source-acl",
+ "store-acl",
+ "approval-acl",
+ "xattr",
+ "capability",
+ "source-fifo",
+ "store-mode",
+ "store-owner",
+ "approval-owner",
+ "approval-link",
+ "ancestor-link",
+ ] {
+ let f = Fixture::new();
+ let app = format!("{}/app", f.source);
+ match attack {
+ "source-link" => {
+ std::fs::remove_file(&app).unwrap();
+ symlink(&f.inventory, &app).unwrap();
+ }
+ "source-hardlink" => std::fs::hard_link(&app, f.base.join("alias")).unwrap(),
+ "extra" => std::fs::write(format!("{}/extra", f.source), b"x").unwrap(),
+ "bad-hash" => std::fs::write(&app, b"modified bytes").unwrap(),
+ "unsafe-mode" | "setuid" => {
+ let mode = if attack == "setuid" { 0o4755 } else { 0o666 };
+ std::fs::set_permissions(&app, std::fs::Permissions::from_mode(mode)).unwrap();
+ f.records(mode);
+ }
+ "source-acl" | "store-acl" | "approval-acl" => {
+ let target = if attack == "source-acl" {
+ &app
+ } else if attack == "store-acl" {
+ &f.store
+ } else {
+ &f.approval
+ };
+ assert!(std::process::Command::new("setfacl")
+ .args(["-m", "u:1000:r", target])
+ .status()
+ .unwrap()
+ .success());
+ }
+ "xattr" => attr(&app, "user.unexpected", b"x"),
+ "capability" => {
+ let mut cap = [0u8; 20];
+ cap[..4].copy_from_slice(&0x02000001u32.to_le_bytes());
+ cap[4..8].copy_from_slice(&1u32.to_le_bytes());
+ attr(&app, "security.capability", &cap);
+ }
+ "source-fifo" => {
+ std::fs::remove_file(&app).unwrap();
+ let p = std::ffi::CString::new(app).unwrap();
+ assert_eq!(unsafe { libc::mkfifo(p.as_ptr(), 0o600) }, 0);
+ }
+ "store-mode" => {
+ std::fs::set_permissions(&f.store, std::fs::Permissions::from_mode(0o777))
+ .unwrap()
+ }
+ "store-owner" | "approval-owner" => {
+ let path = if attack == "store-owner" {
+ &f.store
+ } else {
+ &f.approval
+ };
+ let fd = File::open(path).unwrap();
+ assert_eq!(unsafe { libc::fchown(fd.as_raw_fd(), 1000, 0) }, 0);
+ }
+ "approval-link" => {
+ std::fs::remove_file(&f.approval).unwrap();
+ symlink(&f.inventory, &f.approval).unwrap();
+ }
+ "ancestor-link" => {
+ std::fs::rename(&f.store, f.base.join("other")).unwrap();
+ symlink(f.base.join("other"), &f.store).unwrap();
+ }
+ _ => unreachable!(),
+ }
+ assert!(stage(&f.request()).is_err(), "{attack}");
+ f.empty();
+ }
+ assert_eq!(
+ std::fs::read_to_string("/sys/fs/selinux/enforce")
+ .unwrap()
+ .trim(),
+ "1"
+ );
+ }
+}
diff --git a/native/linux-managed-payload/src/trusted_path.rs b/native/linux-managed-payload/src/trusted_path.rs
new file mode 100644
index 00000000..52d5531e
--- /dev/null
+++ b/native/linux-managed-payload/src/trusted_path.rs
@@ -0,0 +1,372 @@
+use crate::inventory::{Result, ENTRIES};
+use std::{
+ ffi::{CStr, CString},
+ fs::File,
+ io,
+ os::fd::{AsRawFd, FromRawFd},
+ os::unix::fs::MetadataExt,
+};
+
+fn name(value: &str) -> Result {
+ Ok(CString::new(value)?)
+}
+#[repr(C)]
+struct OpenHow {
+ flags: u64,
+ mode: u64,
+ resolve: u64,
+}
+const NO_XDEV: u64 = 0x01;
+const NO_MAGICLINKS: u64 = 0x02;
+const NO_SYMLINKS: u64 = 0x04;
+const BENEATH: u64 = 0x08;
+/// No compatibility fallback: insufficient kernel path-resolution support fails.
+pub fn open_at(parent: &File, path: &str, flags: i32, mode: u32, no_xdev: bool) -> Result {
+ let how = OpenHow {
+ flags: (flags
+ | libc::O_CLOEXEC
+ | libc::O_NOFOLLOW
+ | if flags & libc::O_PATH == 0 {
+ libc::O_NONBLOCK
+ } else {
+ 0
+ }) as u64,
+ mode: mode as u64,
+ resolve: BENEATH | NO_SYMLINKS | NO_MAGICLINKS | if no_xdev { NO_XDEV } else { 0 },
+ };
+ let fd = unsafe {
+ libc::syscall(
+ libc::SYS_openat2,
+ parent.as_raw_fd(),
+ name(path)?.as_ptr(),
+ &how,
+ std::mem::size_of::(),
+ )
+ };
+ if fd < 0 {
+ return Err(io::Error::last_os_error().into());
+ }
+ Ok(unsafe { File::from_raw_fd(fd as i32) })
+}
+/// Classify without device/FIFO I/O, then reopen ONLY our retained kernel FD.
+/// This is the intentional exception to caller-path no-magic-link resolution:
+/// the numeric name is generated internally, in verified procfs, never supplied
+/// by a caller. The O_PATH descriptor pins the inode across source renames.
+pub fn read_at(parent: &File, path: &str) -> Result {
+ let pinned = open_at(parent, path, libc::O_PATH, 0, true)?;
+ read_pinned(&pinned)
+}
+pub fn read_pinned(pinned: &File) -> Result {
+ let before = pinned.metadata()?;
+ if !(before.is_dir() || (before.is_file() && before.nlink() == 1)) {
+ return Err("unsupported pinned object type/link count".into());
+ }
+ let proc = File::open("/proc/self/fd")?;
+ let mut stat = std::mem::MaybeUninit::::uninit();
+ if unsafe { libc::fstatfs(proc.as_raw_fd(), stat.as_mut_ptr()) } != 0 {
+ return Err(io::Error::last_os_error().into());
+ }
+ if unsafe { stat.assume_init() }.f_type != libc::PROC_SUPER_MAGIC {
+ return Err("own descriptor directory is not procfs".into());
+ }
+ let numeric = name(&pinned.as_raw_fd().to_string())?;
+ let raw = unsafe {
+ libc::openat(
+ proc.as_raw_fd(),
+ numeric.as_ptr(),
+ libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NONBLOCK | libc::O_NOCTTY,
+ )
+ };
+ if raw < 0 {
+ return Err(io::Error::last_os_error().into());
+ }
+ let file = unsafe { File::from_raw_fd(raw) };
+ let after = file.metadata()?;
+ if !crate::stage::same(&before, &after) || !crate::stage::same(&before, &pinned.metadata()?) {
+ return Err("pinned object changed during reopen".into());
+ }
+ Ok(file)
+}
+pub fn absolute(path: &str, trusted: bool) -> Result {
+ if !path.starts_with('/')
+ || path.len() > 4096
+ || path.chars().any(|c| c < ' ' || c == '\u{7f}')
+ || (path != "/"
+ && path
+ .split('/')
+ .skip(1)
+ .any(|p| p.is_empty() || p == "." || p == ".."))
+ {
+ return Err("expected canonical absolute directory".into());
+ }
+ let mut fd = File::open("/")?;
+ if trusted {
+ protected(&fd, true)?;
+ }
+ for part in path.split('/').filter(|p| !p.is_empty()) {
+ // Trusted root mounts may differ (e.g. /var). Descendants of the final
+ // pinned source/store descriptor may not cross mounts.
+ fd = open_at(&fd, part, libc::O_RDONLY | libc::O_DIRECTORY, 0, false)?;
+ if trusted {
+ protected(&fd, true)?;
+ }
+ }
+ Ok(fd)
+}
+pub fn xattr(file: &File, key: &str) -> Result